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
|
||||
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user