mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[www] fix role sync not grabbing all roles
This commit is contained in:
+9
-5
@@ -87,9 +87,13 @@ The tests seed the same pair into their own local store in `beforeAll`.
|
|||||||
|
|
||||||
### Benefits claim and Discord
|
### Benefits claim and Discord
|
||||||
|
|
||||||
The **Claim benefits** tab on the account page lets a player prove they hold one of
|
The **Claim benefits** tab on the account page lets a player link their Discord to
|
||||||
the qualifying roles in the community Discord and, if they do, gives their account Rec
|
their account. Any member of the community Discord can link: the roles they hold there
|
||||||
Room Plus (`account.hasPlus`). The same panel also renders at `/claim`, which is the
|
are recorded on the link (`platform_account.role`, which the game reads — `auth`'s
|
||||||
|
rollout gate, for one — and `www`'s daily sweep keeps fresh), and if one of them is a
|
||||||
|
qualifying role the claim also gives the account Rec Room Plus (`account.hasPlus`). A
|
||||||
|
member with no qualifying role is still linked and answered `hasPlus: false`, not
|
||||||
|
refused. The same panel also renders at `/claim`, which is the
|
||||||
app's registered `redirect_uri` — Discord sends the browser back there mid-flow, so
|
app's registered `redirect_uri` — Discord sends the browser back there mid-flow, so
|
||||||
that route has to keep working on a cold load even though nothing links to it. It runs a standard
|
that route has to keep working on a cold load even though nothing links to it. It runs a standard
|
||||||
OAuth2 **authorization code** flow:
|
OAuth2 **authorization code** flow:
|
||||||
@@ -102,8 +106,8 @@ OAuth2 **authorization code** flow:
|
|||||||
`POST /api/benefits/claim` with the player's bearer token.
|
`POST /api/benefits/claim` with the player's bearer token.
|
||||||
3. The worker swaps the code for an access token with the client secret, reads
|
3. The worker swaps the code for an access token with the client secret, reads
|
||||||
`GET /users/@me/guilds/{guild}/member` to get the player's roles, revokes the
|
`GET /users/@me/guilds/{guild}/member` to get the player's roles, revokes the
|
||||||
token, and — if any one of the configured roles is there — writes `hasPlus` onto
|
token, links the Discord id with the full role list, and — if any one of the
|
||||||
the account and links the Discord id.
|
configured roles is there — writes `hasPlus` onto the account.
|
||||||
|
|
||||||
**A claim takes effect on the player's next sign-in, not immediately.** `auth` stamps
|
**A claim takes effect on the player's next sign-in, not immediately.** `auth` stamps
|
||||||
`hasPlus` into every token it mints as the `rn.plus` claim, and `econ` decides the
|
`hasPlus` into every token it mints as the `rn.plus` claim, and `econ` decides the
|
||||||
|
|||||||
@@ -545,6 +545,12 @@ const fetchBenefitsStatus = (): Promise<BenefitsStatus> =>
|
|||||||
|
|
||||||
/** What a successful claim answers. */
|
/** What a successful claim answers. */
|
||||||
interface ClaimResult {
|
interface ClaimResult {
|
||||||
|
/**
|
||||||
|
* Where the account stands after this claim. The Discord is linked and its roles saved
|
||||||
|
* either way; this is false when none of them is a qualifying role (and the account had
|
||||||
|
* no Plus from before).
|
||||||
|
*/
|
||||||
|
hasPlus: boolean
|
||||||
discordUsername?: string
|
discordUsername?: string
|
||||||
/**
|
/**
|
||||||
* The supporter gift paid on this claim — the tokens the server hands a linked role once,
|
* The supporter gift paid on this claim — the tokens the server hands a linked role once,
|
||||||
@@ -755,17 +761,23 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site
|
|||||||
setPending(true)
|
setPending(true)
|
||||||
claimBenefits(code)
|
claimBenefits(code)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setStatus({ hasPlus: true, linked: true })
|
setStatus({ hasPlus: result.hasPlus, linked: true })
|
||||||
const verified = result.discordUsername
|
const who = result.discordUsername ? ` as ${result.discordUsername}` : ''
|
||||||
? `Verified as ${result.discordUsername} — Rec Room Plus is now on your account.`
|
// Linked either way; whether Plus came with it depends on the roles. Without a
|
||||||
: 'Verified — Rec Room Plus is now on your account.'
|
// qualifying role the roles are still saved for the game, and that is said
|
||||||
|
// plainly rather than reading as a failure.
|
||||||
|
const verified = result.hasPlus
|
||||||
|
? `Verified${who} — Rec Room Plus is now on your account.`
|
||||||
|
: `Linked${who} — your Discord roles are saved to your account, but none of them currently grants Rec Room Plus.`
|
||||||
// The first-link gift, when one was paid: it's sitting in a box in the game, and
|
// The first-link gift, when one was paid: it's sitting in a box in the game, and
|
||||||
// the player would otherwise only find it by opening their gifts.
|
// the player would otherwise only find it by opening their gifts.
|
||||||
const gift = result.tokensAwarded
|
const gift = result.tokensAwarded
|
||||||
? ` A welcome gift of ${result.tokensAwarded.toLocaleString()} tokens is waiting in your gift boxes.`
|
? ` A welcome gift of ${result.tokensAwarded.toLocaleString()} tokens is waiting in your gift boxes.`
|
||||||
: ''
|
: ''
|
||||||
setDone(verified + gift)
|
setDone(verified + gift)
|
||||||
setRelogin(true)
|
// The re-login hint is about Plus riding on the game token; without Plus there
|
||||||
|
// is nothing a sign-in would pick up.
|
||||||
|
setRelogin(result.hasPlus)
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)))
|
.catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)))
|
||||||
.finally(() => setPending(false))
|
.finally(() => setPending(false))
|
||||||
@@ -783,14 +795,15 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site
|
|||||||
}
|
}
|
||||||
|
|
||||||
const claimed = status?.hasPlus === true
|
const claimed = status?.hasPlus === true
|
||||||
|
const linked = status?.linked === true
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2>Rec Room Plus</h2>
|
<h2>Link your Discord</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Members of our Discord with a supporter role get Rec Room Plus on their account. Verify with
|
Link your Discord to carry the roles you hold in our server into the game. Members with a
|
||||||
Discord and we’ll check your roles — we only ever read your username and which roles you
|
supporter role also get Rec Room Plus on their account. We only ever read your username and
|
||||||
hold in our server.
|
which roles you hold in our server.
|
||||||
</p>
|
</p>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Claiming as <strong>@{account.username}</strong> (#{account.accountId}). A Discord account
|
Claiming as <strong>@{account.username}</strong> (#{account.accountId}). A Discord account
|
||||||
@@ -826,6 +839,21 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site
|
|||||||
Re-verify with Discord
|
Re-verify with Discord
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
|
) : linked ? (
|
||||||
|
// Linked, no Plus: a member without a qualifying role, or one who lost it. Their
|
||||||
|
// roles are on the account; re-verifying refreshes them (and grants Plus if a
|
||||||
|
// qualifying role has since been added).
|
||||||
|
<>
|
||||||
|
{!done && (
|
||||||
|
<p className="muted">
|
||||||
|
Your Discord is linked and its roles are saved to this account. None of them currently
|
||||||
|
grants Rec Room Plus.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button className="linkish" onClick={() => startDiscordAuth(authorizeUrl)}>
|
||||||
|
Re-verify with Discord
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import type { Env } from './context'
|
|||||||
/**
|
/**
|
||||||
* Discord OAuth2, the identity check behind the website's benefits claim.
|
* Discord OAuth2, the identity check behind the website's benefits claim.
|
||||||
*
|
*
|
||||||
* A player proves they hold one of the qualifying roles in the community Discord, and
|
* A player links their Discord to their account: the claim records the roles they hold
|
||||||
* the claim grants them Rec Room Plus (`account.hasPlus`). The proof is a real OAuth2
|
* in the community Discord on the link (read in-game, e.g. by `auth`'s rollout gate), and
|
||||||
|
* if one of them is a qualifying role it also grants Rec Room Plus (`account.hasPlus`).
|
||||||
|
* The link happens whether or not a qualifying role is held. The proof is a real OAuth2
|
||||||
* AUTHORIZATION CODE exchange, not a token the browser hands us: the SPA sends only the
|
* AUTHORIZATION CODE exchange, not a token the browser hands us: the SPA sends only the
|
||||||
* short-lived `code` Discord redirected it back with, and this worker swaps that for an
|
* short-lived `code` Discord redirected it back with, and this worker swaps that for an
|
||||||
* access token using the client SECRET, which — like the Turnstile secret next door —
|
* access token using the client SECRET, which — like the Turnstile secret next door —
|
||||||
|
|||||||
+49
-24
@@ -371,8 +371,16 @@ const app = new Hono<App>()
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redeem a Discord authorization code and, if the player holds the configured role in
|
* Redeem a Discord authorization code, link the Discord identity to the account with the
|
||||||
* the configured guild, give the account Rec Room Plus.
|
* roles the player holds in the configured guild, and — if any of those is a configured
|
||||||
|
* qualifying role — give the account Rec Room Plus.
|
||||||
|
*
|
||||||
|
* The LINK is not gated on the roles: any member of the guild is linked, and the whole
|
||||||
|
* role list is recorded on the link whether or not a qualifying one is among them. The
|
||||||
|
* roles are read in-game (`auth`'s rollout gate reads them, and the daily sweep keeps
|
||||||
|
* them fresh), so a member with no supporter role still has a reason to link. Only the
|
||||||
|
* `hasPlus` grant asks the qualifying question; a claim without a qualifying role
|
||||||
|
* answers 200 with `hasPlus: false` rather than refusing.
|
||||||
*
|
*
|
||||||
* The browser sends ONLY the code. It never sees an access token: the exchange happens
|
* The browser sends ONLY the code. It never sees an access token: the exchange happens
|
||||||
* here with the client secret, the roles are read with the resulting token, and the
|
* here with the client secret, the roles are read with the resulting token, and the
|
||||||
@@ -383,9 +391,9 @@ const app = new Hono<App>()
|
|||||||
* The claim is once-only PER DISCORD USER, not per account: the Discord id is stored
|
* The claim is once-only PER DISCORD USER, not per account: the Discord id is stored
|
||||||
* beside the flag, and a code from a Discord member who has already claimed elsewhere
|
* beside the flag, and a code from a Discord member who has already claimed elsewhere
|
||||||
* is refused. Otherwise one person with the role could walk it around every account
|
* is refused. Otherwise one person with the role could walk it around every account
|
||||||
* they own. Re-claiming on the same account is allowed and simply re-affirms the flag,
|
* they own. Re-claiming on the same account is allowed: it refreshes the roles and, if
|
||||||
* which is what makes the page safe to reload and a lapsed-then-restored role
|
* a qualifying one is held, re-affirms the flag, which is what makes the page safe to
|
||||||
* re-claimable.
|
* reload and a lapsed-then-restored role re-claimable.
|
||||||
*
|
*
|
||||||
* Nothing here ever REVOKES Plus: losing the Discord role later leaves the flag set.
|
* Nothing here ever REVOKES Plus: losing the Discord role later leaves the flag set.
|
||||||
* That's deliberate for now — a sweep would need a bot token to enumerate the guild,
|
* That's deliberate for now — a sweep would need a bot token to enumerate the guild,
|
||||||
@@ -432,18 +440,11 @@ const app = new Hono<App>()
|
|||||||
if (membership === null) {
|
if (membership === null) {
|
||||||
return c.json({ error: 'You are not a member of our Discord server.' }, 403)
|
return c.json({ error: 'You are not a member of our Discord server.' }, 403)
|
||||||
}
|
}
|
||||||
// Any ONE of the configured roles qualifies — see `qualifies`. The message stays
|
|
||||||
// singular-ish and names no role: which roles qualify is the operator's business to
|
|
||||||
// advertise in their own server, and listing them here would leak the guild's role
|
|
||||||
// layout to anyone who pressed the button.
|
|
||||||
if (!qualifies(membership.roles, config.roleIds)) {
|
|
||||||
return c.json({ error: 'Your Discord account does not have a qualifying role.' }, 403)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The once-only guard, asked of the link table: is this Discord identity already on an
|
// The once-only guard, asked of the link table: is this Discord identity already on an
|
||||||
// account, and is that account someone else's? Re-claiming on the caller's OWN account
|
// account, and is that account someone else's? Re-claiming on the caller's OWN account
|
||||||
// is the idempotent case and must fall through — it's how a player whose role lapsed
|
// is the idempotent case and must fall through — it's how a player whose roles changed
|
||||||
// and came back re-affirms Plus, and it's what makes the page safe to reload.
|
// refreshes them and re-affirms Plus, and it's what makes the page safe to reload.
|
||||||
//
|
//
|
||||||
// `countAccountsForPlatformIdentity` counts EVERY link for the identity, unfiltered by
|
// `countAccountsForPlatformIdentity` counts EVERY link for the identity, unfiltered by
|
||||||
// platform, which is why the claim can use the same helper `auth`'s per-identity
|
// platform, which is why the claim can use the same helper `auth`'s per-identity
|
||||||
@@ -479,14 +480,15 @@ const app = new Hono<App>()
|
|||||||
firstLink = !links.some((link) => link.platform === PlatformType.Discord)
|
firstLink = !links.some((link) => link.platform === PlatformType.Discord)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both writes are idempotent: the link is INSERT OR IGNORE (so `linkedAt` keeps the
|
// The link is written for EVERY guild member, qualifying role or not, and it is
|
||||||
// FIRST claim's time), and the flag is already true on a re-claim.
|
// idempotent: INSERT OR IGNORE (so `linkedAt` keeps the FIRST claim's time), with the
|
||||||
|
// roles refreshed on a re-claim.
|
||||||
//
|
//
|
||||||
// The member's roles ride along onto the link's `role` column — the WHOLE list Discord
|
// The member's roles ride along onto the link's `role` column — the WHOLE list Discord
|
||||||
// served, not just the ones that qualified. `hasPlus` records only that a qualifying
|
// served, not just the ones that qualified. `hasPlus` records only that a qualifying
|
||||||
// role was held; this records which, so an operator can tell a supporter from a
|
// role was held; this records which, so the game can act on any role an operator cares
|
||||||
// booster from staff without going and asking Discord, and can still read a grant made
|
// about (`auth`'s rollout gate reads this list) and an operator can tell a supporter
|
||||||
// under a role that has since been retired from the config. Roles are the one part of
|
// from a booster from staff without going and asking Discord. Roles are the one part of
|
||||||
// the row a re-claim REFRESHES, because they're a snapshot of a membership that moves
|
// the row a re-claim REFRESHES, because they're a snapshot of a membership that moves
|
||||||
// and this reading is the fresher one.
|
// and this reading is the fresher one.
|
||||||
const created = await linkPlatformIdentity(
|
const created = await linkPlatformIdentity(
|
||||||
@@ -496,8 +498,24 @@ const app = new Hono<App>()
|
|||||||
membership.userId,
|
membership.userId,
|
||||||
membership.roles
|
membership.roles
|
||||||
)
|
)
|
||||||
await updateAccount(c.env.DB, accountId, { hasPlus: true })
|
|
||||||
logger.info('granted plus from a discord benefits claim', { accountId })
|
// Any ONE of the configured roles qualifies — see `qualifies`. Without one, the link
|
||||||
|
// stands and the flag is left exactly as it was: never set, and never REVOKED either —
|
||||||
|
// a player whose role lapsed keeps the Plus they were granted (see the note above).
|
||||||
|
// Which roles qualify is the operator's business to advertise in their own server, so
|
||||||
|
// nothing here names them.
|
||||||
|
let hasPlus: boolean
|
||||||
|
if (qualifies(membership.roles, config.roleIds)) {
|
||||||
|
await updateAccount(c.env.DB, accountId, { hasPlus: true })
|
||||||
|
logger.info('granted plus from a discord benefits claim', { accountId })
|
||||||
|
hasPlus = true
|
||||||
|
} else {
|
||||||
|
hasPlus = (await getAccount(c.env.DB, accountId))?.hasPlus ?? false
|
||||||
|
logger.info('linked a discord without a qualifying role', {
|
||||||
|
accountId,
|
||||||
|
roleCount: membership.roles.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// The one-time supporter gift: the box econ's cron would hand this member, paid now,
|
// The one-time supporter gift: the box econ's cron would hand this member, paid now,
|
||||||
// once. Gated on the insert having happened too (`created`), so two claims racing on
|
// once. Gated on the insert having happened too (`created`), so two claims racing on
|
||||||
@@ -530,9 +548,16 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The username is echoed for the confirmation line only — it is never stored, and a
|
// `hasPlus` is where the account stands AFTER this claim, so the page can tell "linked
|
||||||
// Discord member who has since renamed themselves is not a problem to solve here.
|
// and granted" from "linked, no qualifying role" without a second read. The username
|
||||||
return c.json({ hasPlus: true, discordUsername: membership.username, tokensAwarded })
|
// is echoed for the confirmation line only — it is never stored, and a Discord member
|
||||||
|
// who has since renamed themselves is not a problem to solve here.
|
||||||
|
return c.json({
|
||||||
|
hasPlus,
|
||||||
|
linked: true,
|
||||||
|
discordUsername: membership.username,
|
||||||
|
tokensAwarded,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Whether the signed-in account has studio upload access (`hasStudio`). The page
|
// Whether the signed-in account has studio upload access (`hasStudio`). The page
|
||||||
|
|||||||
Reference in New Issue
Block a user