[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 ### 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
+37 -9
View File
@@ -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"
+4 -2
View File
@@ -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
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 * 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