[www] fix role sync not grabbing all roles

This commit is contained in:
Devin Zuczek
2026-10-12 16:30:40 -04:00
parent a0345af765
commit dfe5071878
4 changed files with 99 additions and 40 deletions
+9 -5
View File
@@ -87,9 +87,13 @@ The tests seed the same pair into their own local store in `beforeAll`.
### Benefits claim and Discord
The **Claim benefits** tab on the account page lets a player prove they hold one of
the qualifying roles in the community Discord and, if they do, gives their account Rec
Room Plus (`account.hasPlus`). The same panel also renders at `/claim`, which is the
The **Claim benefits** tab on the account page lets a player link their Discord to
their account. Any member of the community Discord can link: the roles they hold there
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
that route has to keep working on a cold load even though nothing links to it. It runs a standard
OAuth2 **authorization code** flow:
@@ -102,8 +106,8 @@ OAuth2 **authorization code** flow:
`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
`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
the account and links the Discord id.
token, links the Discord id with the full role list, and — if any one of the
configured roles is there — writes `hasPlus` onto the account.
**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
+37 -9
View File
@@ -545,6 +545,12 @@ const fetchBenefitsStatus = (): Promise<BenefitsStatus> =>
/** What a successful claim answers. */
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
/**
* 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)
claimBenefits(code)
.then((result) => {
setStatus({ hasPlus: true, linked: true })
const verified = result.discordUsername
? `Verified as ${result.discordUsername} — Rec Room Plus is now on your account.`
: 'Verified — Rec Room Plus is now on your account.'
setStatus({ hasPlus: result.hasPlus, linked: true })
const who = result.discordUsername ? ` as ${result.discordUsername}` : ''
// Linked either way; whether Plus came with it depends on the roles. Without a
// 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 player would otherwise only find it by opening their gifts.
const gift = result.tokensAwarded
? ` A welcome gift of ${result.tokensAwarded.toLocaleString()} tokens is waiting in your gift boxes.`
: ''
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)))
.finally(() => setPending(false))
@@ -783,14 +795,15 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site
}
const claimed = status?.hasPlus === true
const linked = status?.linked === true
return (
<section className="card">
<h2>Rec Room Plus</h2>
<h2>Link your Discord</h2>
<p className="muted">
Members of our Discord with a supporter role get Rec Room Plus on their account. Verify with
Discord and we’ll check your roles — we only ever read your username and which roles you
hold in our server.
Link your Discord to carry the roles you hold in our server into the game. Members with a
supporter role also get Rec Room Plus on their account. We only ever read your username and
which roles you hold in our server.
</p>
<p className="muted">
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
</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
type="button"
+4 -2
View File
@@ -5,8 +5,10 @@ import type { Env } from './context'
/**
* 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
* the claim grants them Rec Room Plus (`account.hasPlus`). The proof is a real OAuth2
* A player links their Discord to their account: the claim records the roles they hold
* 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
* 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 —
+49 -24
View File
@@ -371,8 +371,16 @@ const app = new Hono<App>()
})
/**
* Redeem a Discord authorization code and, if the player holds the configured role in
* the configured guild, give the account Rec Room Plus.
* Redeem a Discord authorization code, link the Discord identity to the account with the
* 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
* 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
* 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
* they own. Re-claiming on the same account is allowed and simply re-affirms the flag,
* which is what makes the page safe to reload and a lapsed-then-restored role
* re-claimable.
* they own. Re-claiming on the same account is allowed: it refreshes the roles and, if
* a qualifying one is held, re-affirms the flag, which is what makes the page safe to
* reload and a lapsed-then-restored role re-claimable.
*
* 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,
@@ -432,18 +440,11 @@ const app = new Hono<App>()
if (membership === null) {
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
// 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
// and came back re-affirms Plus, and it's what makes the page safe to reload.
// is the idempotent case and must fall through — it's how a player whose roles changed
// 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
// 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)
}
// Both writes are idempotent: the link is INSERT OR IGNORE (so `linkedAt` keeps the
// FIRST claim's time), and the flag is already true on a re-claim.
// The link is written for EVERY guild member, qualifying role or not, and it is
// 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
// 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
// booster from staff without going and asking Discord, and can still read a grant made
// under a role that has since been retired from the config. Roles are the one part of
// role was held; this records which, so the game can act on any role an operator cares
// about (`auth`'s rollout gate reads this list) and an operator can tell a supporter
// 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
// and this reading is the fresher one.
const created = await linkPlatformIdentity(
@@ -496,8 +498,24 @@ const app = new Hono<App>()
membership.userId,
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,
// 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
// Discord member who has since renamed themselves is not a problem to solve here.
return c.json({ hasPlus: true, discordUsername: membership.username, tokensAwarded })
// `hasPlus` is where the account stands AFTER this claim, so the page can tell "linked
// and granted" from "linked, no qualifying role" without a second read. The username
// 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