From d14d96f489c6def9d1f0df0cfc657f3ff4175aa6 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 26 Oct 2026 12:11:16 -0400 Subject: [PATCH] [www] add discord delink --- apps/auth/src/platform-db.ts | 34 +++++ apps/www/README.md | 17 +++ apps/www/src/client/App.tsx | 82 +++++++++++- apps/www/src/client/styles.css | 27 ++++ apps/www/src/test/integration/api.test.ts | 152 +++++++++++++++++++++- apps/www/src/www.app.ts | 103 ++++++++++++++- packages/domain/src/accounts-db.ts | 20 ++- 7 files changed, 426 insertions(+), 9 deletions(-) diff --git a/apps/auth/src/platform-db.ts b/apps/auth/src/platform-db.ts index d18d06c..60383cb 100644 --- a/apps/auth/src/platform-db.ts +++ b/apps/auth/src/platform-db.ts @@ -489,6 +489,40 @@ export async function isPlatformIdentityLinked( return row !== null } +/** + * Remove EVERY link an account holds on one platform, and return the links that were + * removed (empty when it held none). The website's Discord unlink is the one caller: + * a player taking their Discord off their account, which gives the identity back — + * "Sign in with Discord" with it afterwards makes a NEW account, and the benefits claim's + * once-only guard lets it claim elsewhere again. + * + * The removed rows are returned rather than a count because the caller needs to know what + * they carried: a link whose `role` was recorded is a CLAIM, and the account must remember + * having made one after the row is gone (see `account.hasClaimedDiscord`), or the + * first-link gift would pay again on every link/unlink cycle. Read-then-delete, not + * `DELETE … RETURNING`, so it runs on every D1 the tests do. + * + * Not for Steam or Meta: those links are what a cached login redeems, and nothing on the + * website removes one. The helper doesn't refuse them — the table has no idea what a + * platform is for — so a caller must only ever name a platform nobody logs in with. + */ +export async function unlinkPlatform( + db: D1Database, + accountId: number, + platform: number +): Promise { + const { results } = await db + .prepare(`${SELECT_LINK} WHERE account_id = ?1 AND platform = ?2 ORDER BY linked_at`) + .bind(accountId, platform) + .all() + if (results.length === 0) return [] + await db + .prepare(`DELETE FROM platform_account WHERE account_id = ?1 AND platform = ?2`) + .bind(accountId, platform) + .run() + return results.map(toLink) +} + /** * How many accounts one platform identity already opens — the count both signup caps * and link caps are enforced against, so an identity can't accumulate accounts by diff --git a/apps/www/README.md b/apps/www/README.md index 553f5d7..c488369 100644 --- a/apps/www/README.md +++ b/apps/www/README.md @@ -154,6 +154,23 @@ box. The grant runs after Plus is granted and the link written, and a failure in logged rather than failing the claim. Unset, the var awards nothing and the claim is as before. +**Unlinking.** The same panel offers **Unlink Discord** whenever any Discord is on the +account — a claim's link or the bare one "Sign in with Discord" leaves — and +`POST /api/benefits/unlink` removes every `PlatformType.Discord` link the account holds. +It is refused (403) until the account has an **email address**: an account made from +Discord has none until the player sets one, the link may be its only way in, and an address +is what the account can be recovered through (a password is not required — a passwordless +account sets one in game). The row decides, not the page; `/api/benefits/status` serves the +same flag (`hasEmail`, beside `discordLinked`) so the panel can say so first. Afterwards that +Discord opens no account — signing in with it makes a new one — and the once-only guard lets +it claim elsewhere. **The grants go with the link**: `hasPlus` and `hasStudio` are both +cleared, whoever set them (an operator's grant too — nothing on the row says which wrote +it), and the panel warns before the confirm; both reach the game on the next token. What the +account does keep is that it claimed: removing a link with roles recorded stamps +`hasClaimedDiscord` on the account, and the claim's "first link" check consults it, or +link → unlink → link would pay the one-time supporter gift on every cycle. The route is not gated on the Discord +configuration — the link is in the table whether or not the claim is still open. + The link also records the member's **roles** (`platform_account.role`, a JSON array of role ids). The claim writes them from the member record it read; on their own they'd be a snapshot from claim time, because the claim revokes the player's token the moment it's diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 09ac34e..ccafb37 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -560,6 +560,16 @@ interface BenefitsStatus { hasPlus: boolean /** Whether a Discord identity is already tied to it. Which one is deliberately not served. */ linked: boolean + /** + * Whether ANY Discord is on the account — a claim's link or the bare one "Sign in with + * Discord" leaves. What the unlink button keys on; `linked` is only the claimed case. + */ + discordLinked: boolean + /** + * The unlink's one precondition: the account must have an email address, or taking the + * Discord off could leave nothing to recover it by. Served so the panel can say so first. + */ + hasEmail: boolean } /** @@ -590,6 +600,14 @@ interface ClaimResult { const claimBenefits = (code: string): Promise => call('/api/benefits/claim', { json: { code }, authed: true }) +/** + * Take the Discord off the account. `www` refuses (403, with a sentence) unless the account + * has a password and an email — the panel checks the same two flags first so the button + * only shows when it can work. Answers the status as it now stands. + */ +const unlinkDiscord = (): Promise => + call('/api/benefits/unlink', { json: {}, authed: true }) + /** * The per-attempt CSRF nonce for the Discord round-trip, in sessionStorage. * @@ -778,6 +796,7 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site // and tokens last a day and are never refreshed. Without this line the claim looks like // it silently did nothing, which is the single most likely support question here. const [relogin, setRelogin] = useState(false) + const [unlinking, setUnlinking] = useState(false) // StrictMode runs effects twice in dev, and a Discord code is single-use: the second // run would redeem a spent code and report a failure over a claim that just worked. const redeemed = useRef(false) @@ -818,7 +837,14 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site setPending(true) claimBenefits(code) .then((result) => { - setStatus({ hasPlus: result.hasPlus, linked: true }) + setStatus((prev) => ({ + hasPlus: result.hasPlus, + linked: true, + discordLinked: true, + // The claim says nothing about this; keep what the status read said, and assume + // none until it answers. + hasEmail: prev?.hasEmail ?? false, + })) 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 @@ -920,6 +946,60 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site Verify with Discord )} + + {status?.discordLinked && !pending && ( + // Taking the Discord off again. Offered for ANY link — a claim's, or the bare one a + // Discord sign-in made — but only once the account can be recovered without it: an + // account made from Discord has no email until the player sets one, and `www` + // refuses the unlink until there is one. The panel says so rather than showing a + // button that would fail. +
+

Unlink Discord

+ {status.hasEmail ? ( + <> +

+ Removes your Discord from this account. Rec Room Plus and Rec Room Studio access + granted to this account are removed with it, your roles stop being read, and signing + in with that Discord afterwards will make a new account rather than open this one. + You can link again at any time. +

+ + + ) : ( +

+ Before you can unlink, add an email address to this account (the{' '} + Email tab), so the account can be recovered without Discord. +

+ )} +
+ )} ) } diff --git a/apps/www/src/client/styles.css b/apps/www/src/client/styles.css index 8d20937..22f954e 100644 --- a/apps/www/src/client/styles.css +++ b/apps/www/src/client/styles.css @@ -447,6 +447,33 @@ body { border-color: #4752c4; } +/* The way back out of the Discord link, at the foot of the benefits card: set apart by a + rule so it doesn't read as part of the claim, and the button in the error colour so it + reads as the undo it is. The hint beside it says what has to be set first. */ +.unlink { + margin-top: 22px; + padding-top: 16px; + border-top: 1px solid var(--line); +} + +.unlink h3 { + margin: 0 0 8px; + font-size: 1rem; +} + +.linkish.danger { + color: var(--error); +} + +.linkish.danger:hover { + color: var(--text); +} + +.linkish.danger:disabled { + opacity: 0.6; + cursor: default; +} + /* Secondary action: same shape, outlined instead of filled. */ .cta.ghost { background: transparent; diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index 2115d66..cbdb762 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -49,6 +49,7 @@ import { linkPlatformIdentity, PLATFORM_SCHEMA_DDL, recordFirstRoles, + unlinkPlatform, } from '../../../../auth/src/platform-db' import { BALANCE_SCHEMA_DDL, @@ -679,25 +680,168 @@ it('reports where an account stands on benefits', async () => { headers: { authorization: `Bearer ${token}` }, }) expect(res.status).toBe(200) - expect(await res.json()).toEqual({ hasPlus: false, linked: false }) + // No email either: the unlink's one precondition, served alongside so the page can say + // so first. Nothing linked, so there is nothing to unlink yet. + expect(await res.json()).toEqual({ + hasPlus: false, + linked: false, + discordLinked: false, + hasEmail: false, + }) // A bare link — what "Sign in with Discord" leaves on an account it just made — is NOT a - // claim: no roles were ever recorded, so the page must still offer the claim. + // claim: no roles were ever recorded, so the page must still offer the claim. It IS a + // Discord on the account, though, so `discordLinked` says so: the unlink is offered for + // it as much as for a claim's link. await updateAccount(env.DB, 4003, { hasPlus: true }) await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001') res = await SELF.fetch('https://example.com/api/benefits/status', { headers: { authorization: `Bearer ${token}` }, }) - expect(await res.json()).toEqual({ hasPlus: true, linked: false }) + expect(await res.json()).toEqual({ + hasPlus: true, + linked: false, + discordLinked: true, + hasEmail: false, + }) // Claimed (roles recorded, even none). `linked` says THAT a Discord identity has claimed, // never which one — the id is of no use to the page, and an account's linked identities // have no business on the wire. + await updateAccount(env.DB, 4003, { email: 'p@example.com' }) await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001', []) res = await SELF.fetch('https://example.com/api/benefits/status', { headers: { authorization: `Bearer ${token}` }, }) - expect(await res.json()).toEqual({ hasPlus: true, linked: true }) + expect(await res.json()).toEqual({ + hasPlus: true, + linked: true, + discordLinked: true, + hasEmail: true, + }) +}) + +// ---- Discord unlink -------------------------------------------------------- +// Taking the Discord off an account. The link may be the only way in — an account made by +// "Sign in with Discord" has no email until the player sets one — so the unlink is refused +// until the account has an address to be recovered through, decided from the row and never +// from the page. A password is NOT required: a passwordless account sets one in game. + +const unlink = (token: string) => + SELF.fetch('https://example.com/api/benefits/unlink', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: '{}', + }) + +it('requires a valid session to unlink a discord', async () => { + expect((await unlink('')).status).toBe(401) + const forged = await generateToken('4020', '', 4, 'not-the-real-secret') + expect((await unlink(forged)).status).toBe(401) +}) + +it('refuses to unlink a discord until the account has an email address', async () => { + const token = await tokenFor(4021) + await linkPlatformIdentity(env.DB, 4021, PlatformType.Discord, '99021') + + // No email — and a password doesn't stand in for one. + await updateAccount(env.DB, 4021, { passwordHash: 'salt:hash' }) + const res = await unlink(token) + expect(res.status).toBe(403) + expect(await res.json()).toEqual({ + error: + 'Add an email address to your account before unlinking Discord, so the account can be recovered without it.', + }) + + // Refused means untouched: the link is still there. + await expect(isPlatformIdentityLinked(env.DB, 4021, PlatformType.Discord, '99021')).resolves.toBe( + true + ) + + // An email alone, no password, is enough. + await updateAccount(env.DB, 4021, { passwordHash: undefined, email: 'p@example.com' }) + expect((await unlink(token)).status).toBe(200) + await expect(isPlatformIdentityLinked(env.DB, 4021, PlatformType.Discord, '99021')).resolves.toBe( + false + ) +}) + +it('unlinks every discord on the account, and takes plus and studio with it', async () => { + const token = await tokenFor(4022) + await updateAccount(env.DB, 4022, { + username: 'Leaver', + email: 'p@example.com', + hasPlus: true, + hasStudio: true, + }) + // A claimed link and (the once-only guard aside) a second identity, both Discord — and a + // Steam login that must survive: only the Discord goes. + await linkPlatformIdentity(env.DB, 4022, PlatformType.Discord, '99022', ['r1']) + await linkPlatformIdentity(env.DB, 4022, PlatformType.Discord, '99023') + await linkPlatformIdentity(env.DB, 4022, PlatformType.Steam, '76561000000000022') + + const res = await unlink(token) + expect(res.status).toBe(200) + // The status as it now stands, in `/api/benefits/status`'s shape: the grants went with + // the link. + expect(await res.json()).toEqual({ + hasPlus: false, + linked: false, + discordLinked: false, + hasEmail: true, + }) + expect((await getLinksForAccount(env.DB, 4022)).map((l) => [l.platform, l.platformId])).toEqual([ + [PlatformType.Steam, '76561000000000022'], + ]) + // The identity is free again: the once-only guard counts nobody holding it now. + await expect( + countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, '99022') + ).resolves.toBe(0) + // The account remembers it CLAIMED (a link with roles recorded was removed), so a re-link + // won't be a "first" one to the gift — see the test below. Plus and Studio are CLEARED — + // dropped from the blob, not written false — and the rest of the row is untouched. + const after = await getAccount(env.DB, 4022) + expect(after?.hasClaimedDiscord).toBe(true) + expect(after?.hasPlus).toBeUndefined() + expect(after?.hasStudio).toBeUndefined() + expect(after?.username).toBe('Leaver') + expect(after?.email).toBe('p@example.com') + + // Nothing left to unlink. + const again = await unlink(token) + expect(again.status).toBe(404) + expect(await again.json()).toEqual({ error: 'No Discord account is linked to this account.' }) +}) + +// Removing a BARE link (a Discord sign-in's, no roles recorded) is not a claim to remember: +// that player's first claim, on whatever account they next link, is still their first. +it('does not remember a claim when the unlinked discord never claimed', async () => { + const token = await tokenFor(4023) + await updateAccount(env.DB, 4023, { email: 'p@example.com' }) + await linkPlatformIdentity(env.DB, 4023, PlatformType.Discord, '99024') + expect((await unlink(token)).status).toBe(200) + expect((await getAccount(env.DB, 4023))?.hasClaimedDiscord).toBeUndefined() + + // But a claim that found NO roles is still a claim — `[]` is recorded, NULL is not — and + // `unlinkPlatform`'s parsed rows can't tell the two apart, which is why the route asks + // the raw column before it deletes. + await linkPlatformIdentity(env.DB, 4023, PlatformType.Discord, '99024', []) + expect((await unlink(token)).status).toBe(200) + expect((await getAccount(env.DB, 4023))?.hasClaimedDiscord).toBe(true) +}) + +// The helper itself: returns what it removed (so the caller knows what the rows carried), +// and an account with nothing on that platform yields an empty list and no write. +it('removes every link on one platform and returns them', async () => { + await linkPlatformIdentity(env.DB, 4024, PlatformType.Discord, '99025', ['r1', 'r2']) + await linkPlatformIdentity(env.DB, 4024, PlatformType.Steam, '76561000000000024') + await expect(unlinkPlatform(env.DB, 4024, PlatformType.Oculus)).resolves.toEqual([]) + const removed = await unlinkPlatform(env.DB, 4024, PlatformType.Discord) + expect(removed.map((l) => [l.platformId, l.roles])).toEqual([['99025', ['r1', 'r2']]]) + await expect(unlinkPlatform(env.DB, 4024, PlatformType.Discord)).resolves.toEqual([]) + expect((await getLinksForAccount(env.DB, 4024)).map((l) => l.platform)).toEqual([ + PlatformType.Steam, + ]) }) // How the claim decides "first claim" now that a Discord link can predate it: a link made by diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts index cd2538d..2024a71 100644 --- a/apps/www/src/www.app.ts +++ b/apps/www/src/www.app.ts @@ -16,10 +16,12 @@ import { generateIdentityAssertion, validateAndGetAccountId } from '@repo/jwt' // verify, and its picker lists only the platforms that can be (CACHED_LOGIN_PLATFORMS). import { countAccountsForPlatformIdentity, + getLinksForAccount, hasRecordedRoles, isPlatformIdentityLinked, linkPlatformIdentity, recordFirstRoles, + unlinkPlatform, } from '../../auth/src/platform-db' // The signup grant's default and the Discord supporter gift, both `econ`'s. The gift is // econ's cron paid on demand: the first time an account links its Discord, the claim hands @@ -463,9 +465,10 @@ const app = new Hono() .get('/api/benefits/status', async (c) => { const accountId = await claimant(c) if (accountId === null) return c.body(null, 401) - const [account, claimed] = await Promise.all([ + const [account, claimed, links] = await Promise.all([ getAccount(c.env.DB, accountId), hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord), + getLinksForAccount(c.env.DB, accountId), ]) return c.json({ hasPlus: account?.hasPlus ?? false, @@ -475,6 +478,93 @@ const app = new Hono() // bare link, which "Sign in with Discord" leaves on a new account, reads as not yet // claimed: the page then offers the claim, which is what that player still has to do. linked: claimed, + // Whether ANY Discord identity is on the account, claimed or bare — what the unlink + // button keys on, since a sign-in's bare link is as much a link to take off as a + // claim's. Still never WHICH one. + discordLinked: links.some((link) => link.platform === PlatformType.Discord), + // The unlink's one precondition (see `/api/benefits/unlink`), served beside the link + // so the page can say so before the button fails. The self DTO carries the address + // too; this is the server's own reading of the same row the unlink will decide from. + hasEmail: !!account?.email, + }) + }) + + /** + * Take the account's Discord off it: every `PlatformType.Discord` link goes, whether a + * claim wrote it or "Sign in with Discord" did. Afterwards that Discord opens no account — + * signing in with it makes a NEW one — and the benefits claim's once-only guard lets it + * claim on another account. + * + * Refused unless the account has an EMAIL ADDRESS. The link may be the only way into the + * account: an account made by "Sign in with Discord" has no password and no email until + * the player sets them, and removing its link would leave nothing to recover the account + * by. An address is enough — it is what the account can be recovered through — so a + * password is NOT required: a player with an email and no password sets one in game, as + * any passwordless account does. Checked here, on the row, and not trusted from the page + * (which shows the same flag from `/api/benefits/status` so it can say so before anyone + * presses anything). + * + * The grants the link carried go with it. `hasPlus` and `hasStudio` are both cleared: + * the Discord roles are what the claim granted Plus for and what the Studio rollout reads, + * and a player taking the link off is giving that proof back — unlike a role that merely + * lapses, which the claim deliberately never revokes for. This clears an operator's + * grant too (`runx admin grant-plus`/`grant-studio` write the same flags, and nothing on + * the row says which wrote them); the page warns, and staff can re-grant. Both reach the + * game on the NEXT token, like every account flag. + * + * What it does NOT do is forget that the account has claimed. A link with roles recorded + * was a claim, and the first-link supporter gift keys on "never claimed before", read from + * the link table — which would read as never-linked once the row is gone. So a claimed + * link's removal stamps `hasClaimedDiscord` on the account, and the claim consults both; + * otherwise link → unlink → link would pay the one-time box on every cycle. + * + * Not gated on the Discord configuration: the link is in the table whether or not the + * operator still runs the claim, and a player can always take it off. Answers the same + * shape `/api/benefits/status` does, as the account now stands. + */ + .post('/api/benefits/unlink', async (c) => { + const accountId = await claimant(c) + if (accountId === null) return c.body(null, 401) + + const account = await getAccount(c.env.DB, accountId) + if (!account?.email) { + return c.json( + { + error: + 'Add an email address to your account before unlinking Discord, so the account can be recovered without it.', + }, + 403 + ) + } + + // Whether what's being removed includes a CLAIM — a link with `role` recorded, even as + // `[]`. Asked of the raw column BEFORE the delete: the parsed `PlatformLink` reads a + // NULL role and an empty list both as `[]`, and only the column tells a sign-in's bare + // link from a claim that found no roles. + const claimed = await hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord) + const removed = await unlinkPlatform(c.env.DB, accountId, PlatformType.Discord) + if (removed.length === 0) { + return c.json({ error: 'No Discord account is linked to this account.' }, 404) + } + // One write: remember the claim (when there was one), and take back what the link + // carried. `undefined` drops the key from the blob, which is how "no Plus" is spelled. + await updateAccount(c.env.DB, accountId, { + hasClaimedDiscord: claimed || account.hasClaimedDiscord === true ? true : undefined, + hasPlus: undefined, + hasStudio: undefined, + }) + logger.info('unlinked a discord from an account', { + accountId, + links: removed.length, + claimed, + hadPlus: account.hasPlus === true, + hadStudio: account.hasStudio === true, + }) + return c.json({ + hasPlus: false, + linked: false, + discordLinked: false, + hasEmail: true, }) }) @@ -506,7 +596,8 @@ const app = new Hono() * 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, * which this design specifically avoids — but it does mean the flag records "held the - * role once", not "holds it today". + * role once", not "holds it today". The player's own unlink (`/api/benefits/unlink`) is + * the one thing that takes it back. * * The FIRST time an account links a Discord, the claim also pays the supporter gift * econ's cron would (`DISCORD_ROLE_TOKENS`, the amount for the best of the roles just @@ -585,7 +676,13 @@ const app = new Hono() // DIFFERENT one the once-only guard doesn't stop) has claimed before. A link with NO // roles has not: that is what "Sign in with Discord" leaves on an account it just made, // and that player's first claim is still their first. - const firstClaim = !(await hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord)) + // + // AND from the account: an unlink removes a claimed link's row, and stamps + // `hasClaimedDiscord` on the account so the table reading as never-linked afterwards + // doesn't make the next link a "first" one. Both are asked; either says not-first. + const firstClaim = + (await getAccount(c.env.DB, accountId))?.hasClaimedDiscord !== true && + !(await hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord)) // On a link that already exists without roles, writing them is the claim's one-shot // write — the single-winner guard a fresh link gets from its INSERT below. Asked only // when the link is the caller's: on anyone else's it would be refused above. diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index c5f11c7..b81f05f 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -168,7 +168,10 @@ export interface Account { * * So never read a Discord link as a precondition for Plus, and never revoke one because * the other is missing: a manually granted account has `hasPlus` and no link at all, and - * that is a normal, supported state. + * that is a normal, supported state. The one thing that clears it besides an operator is + * the player UNLINKING their Discord (`www` `POST /api/benefits/unlink`), which clears + * this and `hasStudio` together — the player is handing back the proof — and does so + * whoever set them, since nothing on the row says which. * * Nothing reads this per request. `auth` stamps it into every token it mints as the * `rn.plus` claim, and `econ` decides the CampusCard and the subscriber discount from @@ -177,6 +180,21 @@ export interface Account { * real: the website's claim page warns about it, and so does `grant-plus`. */ hasPlus?: boolean + /** + * Whether this account has EVER claimed its Discord benefits — had roles recorded on a + * `PlatformType.Discord` link by `www`'s `POST /api/benefits/claim` — even if that link is + * gone. Set only by the website's Discord UNLINK, when the link it removes carries roles: + * while the link exists the link table itself says so (`hasRecordedRoles`), and a link + * that never claimed has nothing to remember. + * + * What it protects is the first-link supporter gift. The claim pays that box the FIRST + * time an account links a Discord, deciding "first" from the link table — which, after an + * unlink, reads as if the account had never linked. Without this flag, link → unlink → + * link would pay the one-time gift on every cycle. Nothing else reads it: `hasPlus` stands + * on its own (above), and the once-only-per-Discord-user guard is the link table's. + * Absent/false means no claim remembered. + */ + hasClaimedDiscord?: boolean /** * Whether this account may upload from Rec Room Studio. The editor decides Full access * by reading the token for a `role` claim of exactly `betastudio`, which `auth` stamps