[www] add discord delink

This commit is contained in:
Devin Zuczek
2026-10-26 12:11:16 -04:00
parent 95538e89ae
commit d14d96f489
7 changed files with 426 additions and 9 deletions
+34
View File
@@ -489,6 +489,40 @@ export async function isPlatformIdentityLinked(
return row !== null 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<PlatformLink[]> {
const { results } = await db
.prepare(`${SELECT_LINK} WHERE account_id = ?1 AND platform = ?2 ORDER BY linked_at`)
.bind(accountId, platform)
.all<LinkRow>()
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 * 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 * and link caps are enforced against, so an identity can't accumulate accounts by
+17
View File
@@ -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 logged rather than failing the claim. Unset, the var awards nothing and the claim is as
before. 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 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 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 a snapshot from claim time, because the claim revokes the player's token the moment it's
+81 -1
View File
@@ -560,6 +560,16 @@ interface BenefitsStatus {
hasPlus: boolean hasPlus: boolean
/** Whether a Discord identity is already tied to it. Which one is deliberately not served. */ /** Whether a Discord identity is already tied to it. Which one is deliberately not served. */
linked: boolean 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<ClaimResult> => const claimBenefits = (code: string): Promise<ClaimResult> =>
call<ClaimResult>('/api/benefits/claim', { json: { code }, authed: true }) call<ClaimResult>('/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<BenefitsStatus> =>
call<BenefitsStatus>('/api/benefits/unlink', { json: {}, authed: true })
/** /**
* The per-attempt CSRF nonce for the Discord round-trip, in sessionStorage. * 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 // 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. // it silently did nothing, which is the single most likely support question here.
const [relogin, setRelogin] = useState(false) 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 // 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. // run would redeem a spent code and report a failure over a claim that just worked.
const redeemed = useRef(false) const redeemed = useRef(false)
@@ -818,7 +837,14 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site
setPending(true) setPending(true)
claimBenefits(code) claimBenefits(code)
.then((result) => { .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}` : '' const who = result.discordUsername ? ` as ${result.discordUsername}` : ''
// Linked either way; whether Plus came with it depends on the roles. Without a // 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 // 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 Verify with Discord
</button> </button>
)} )}
{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.
<div className="unlink">
<h3>Unlink Discord</h3>
{status.hasEmail ? (
<>
<p className="muted">
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.
</p>
<button
type="button"
className="linkish danger"
disabled={unlinking}
onClick={() => {
if (
!confirm(
'Unlink your Discord from this account? Rec Room Plus and Studio access on this account will be removed.'
)
)
return
setUnlinking(true)
setError('')
setDone('')
unlinkDiscord()
.then((next) => {
setStatus(next)
setRelogin(false)
setDone('Your Discord has been unlinked from this account.')
})
.catch((err: unknown) =>
setError(err instanceof Error ? err.message : String(err))
)
.finally(() => setUnlinking(false))
}}
>
{unlinking ? 'Unlinking…' : 'Unlink Discord'}
</button>
</>
) : (
<p className="hint">
Before you can unlink, add an email address to this account (the{' '}
<strong>Email</strong> tab), so the account can be recovered without Discord.
</p>
)}
</div>
)}
</section> </section>
) )
} }
+27
View File
@@ -447,6 +447,33 @@ body {
border-color: #4752c4; 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. */ /* Secondary action: same shape, outlined instead of filled. */
.cta.ghost { .cta.ghost {
background: transparent; background: transparent;
+148 -4
View File
@@ -49,6 +49,7 @@ import {
linkPlatformIdentity, linkPlatformIdentity,
PLATFORM_SCHEMA_DDL, PLATFORM_SCHEMA_DDL,
recordFirstRoles, recordFirstRoles,
unlinkPlatform,
} from '../../../../auth/src/platform-db' } from '../../../../auth/src/platform-db'
import { import {
BALANCE_SCHEMA_DDL, BALANCE_SCHEMA_DDL,
@@ -679,25 +680,168 @@ it('reports where an account stands on benefits', async () => {
headers: { authorization: `Bearer ${token}` }, headers: { authorization: `Bearer ${token}` },
}) })
expect(res.status).toBe(200) 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 // 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 updateAccount(env.DB, 4003, { hasPlus: true })
await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001') await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001')
res = await SELF.fetch('https://example.com/api/benefits/status', { res = await SELF.fetch('https://example.com/api/benefits/status', {
headers: { authorization: `Bearer ${token}` }, 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, // 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 // never which one — the id is of no use to the page, and an account's linked identities
// have no business on the wire. // have no business on the wire.
await updateAccount(env.DB, 4003, { email: 'p@example.com' })
await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001', []) await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001', [])
res = await SELF.fetch('https://example.com/api/benefits/status', { res = await SELF.fetch('https://example.com/api/benefits/status', {
headers: { authorization: `Bearer ${token}` }, 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 // How the claim decides "first claim" now that a Discord link can predate it: a link made by
+100 -3
View File
@@ -16,10 +16,12 @@ import { generateIdentityAssertion, validateAndGetAccountId } from '@repo/jwt'
// verify, and its picker lists only the platforms that can be (CACHED_LOGIN_PLATFORMS). // verify, and its picker lists only the platforms that can be (CACHED_LOGIN_PLATFORMS).
import { import {
countAccountsForPlatformIdentity, countAccountsForPlatformIdentity,
getLinksForAccount,
hasRecordedRoles, hasRecordedRoles,
isPlatformIdentityLinked, isPlatformIdentityLinked,
linkPlatformIdentity, linkPlatformIdentity,
recordFirstRoles, recordFirstRoles,
unlinkPlatform,
} from '../../auth/src/platform-db' } from '../../auth/src/platform-db'
// The signup grant's default and the Discord supporter gift, both `econ`'s. The gift is // 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 // 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<App>()
.get('/api/benefits/status', async (c) => { .get('/api/benefits/status', async (c) => {
const accountId = await claimant(c) const accountId = await claimant(c)
if (accountId === null) return c.body(null, 401) 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), getAccount(c.env.DB, accountId),
hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord), hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord),
getLinksForAccount(c.env.DB, accountId),
]) ])
return c.json({ return c.json({
hasPlus: account?.hasPlus ?? false, hasPlus: account?.hasPlus ?? false,
@@ -475,6 +478,93 @@ const app = new Hono<App>()
// bare link, which "Sign in with Discord" leaves on a new account, reads as not yet // 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. // claimed: the page then offers the claim, which is what that player still has to do.
linked: claimed, 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<App>()
* 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,
* which this design specifically avoids — but it does mean the flag records "held the * 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 * 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 * econ's cron would (`DISCORD_ROLE_TOKENS`, the amount for the best of the roles just
@@ -585,7 +676,13 @@ const app = new Hono<App>()
// DIFFERENT one the once-only guard doesn't stop) has claimed before. A link with NO // 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, // 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. // 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 // 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 // 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. // when the link is the caller's: on anyone else's it would be refused above.
+19 -1
View File
@@ -168,7 +168,10 @@ export interface Account {
* *
* So never read a Discord link as a precondition for Plus, and never revoke one because * 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 * 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 * 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 * `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`. * real: the website's claim page warns about it, and so does `grant-plus`.
*/ */
hasPlus?: boolean 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 * 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 * by reading the token for a `role` claim of exactly `betastudio`, which `auth` stamps