mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[www] add discord delink
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user