mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-28 00:18:58 -07:00
543 lines
23 KiB
TypeScript
543 lines
23 KiB
TypeScript
/**
|
|
* Platform identity links on the shared `recflare` D1 database (owned by the `auth`
|
|
* worker, migration 0007). One row per (platform, platform id, account): the Steam
|
|
* user 76561…211 is linked to account 42, the Meta user 27061… is linked to account
|
|
* 42 as well, and both let that player into that account without a password.
|
|
*
|
|
* This table replaced the single `platformId`/`platform` pair on the account blob as
|
|
* the thing logins are decided from, because that pair could only hold ONE identity —
|
|
* a player with a PC and a headset had to pick which device got a cached login. The
|
|
* blob fields are kept as the account's *primary* identity (the first one linked) for
|
|
* the account DTO and the refresh grant's claims; nothing authorizes off them.
|
|
*
|
|
* It is deliberately the ONE source of truth for both halves of a cached login: the
|
|
* picker (`/cachedlogin/forplatformid`) lists the accounts this table links to an
|
|
* identity, and the `cached_login` grant asks this table whether the account it was
|
|
* handed is linked to the identity that was proven. When those two disagreed the
|
|
* client was offered an account it could never log into — see the regression test.
|
|
*
|
|
* A link is only ever written from a VERIFIED identity (a Steam-signed ticket or a
|
|
* Meta-validated nonce). It is what turns "this platform user" into "may enter this
|
|
* account with no password", so an unproven `platform_id` must never reach it.
|
|
*
|
|
* Not every link is a LOGIN, though. The table is the account's set of external
|
|
* identities, and some are stored for what they entitle the player to rather than for
|
|
* entry: `PlatformType.Discord` (101) is written by the website's benefits claim, from an
|
|
* OAuth2 code exchange Discord itself vouched for. Nobody signs in with it —
|
|
* `verifyPlatformProof` answers `unsupported` for anything but Steam and Meta, so a
|
|
* `cached_login` naming platform 101 is refused — and the picker must not offer it
|
|
* either. See {@link CACHED_LOGIN_PLATFORMS}, which is what keeps those two in step.
|
|
*
|
|
* That entitlement is also the one thing a link carries beyond the identity itself: the
|
|
* `role` column (migration 0010) records the Discord roles the claim read, so what a
|
|
* player was granted Plus FOR survives the claim instead of collapsing into a bare
|
|
* `hasPlus`. The `www` worker's daily cron re-reads them through a bot token when one is
|
|
* configured (apps/www/src/discord-roles.ts). See {@link PlatformLink.roles}.
|
|
*/
|
|
|
|
import { PlatformType } from '@repo/domain/src/enums'
|
|
|
|
/**
|
|
* Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill, plus
|
|
* 0010's `role` column — which is an ALTER on prod and a plain column here, since this
|
|
* builds the table from nothing).
|
|
*/
|
|
export const PLATFORM_SCHEMA_DDL: string[] = [
|
|
`CREATE TABLE IF NOT EXISTS platform_account (
|
|
account_id INTEGER NOT NULL,
|
|
platform INTEGER NOT NULL,
|
|
platform_id TEXT NOT NULL,
|
|
linked_at TEXT NOT NULL,
|
|
role TEXT,
|
|
PRIMARY KEY (platform, platform_id, account_id)
|
|
)`,
|
|
// The picker's lookup: "which accounts does this identity open?". Covered by the
|
|
// primary key's leading columns, so no separate index is needed for it.
|
|
`CREATE INDEX IF NOT EXISTS idx_platform_account_account ON platform_account (account_id)`,
|
|
// Lookup by bare platform id, across platforms — the bulk (friends) route, which
|
|
// resolves ids it has no platform for.
|
|
`CREATE INDEX IF NOT EXISTS idx_platform_account_platform_id ON platform_account (platform_id)`,
|
|
]
|
|
|
|
/**
|
|
* The one-time backfill 0007 ran after creating the table: every identity already bound
|
|
* to an account became a link, so nobody lost their cached login at deploy. It has run;
|
|
* this exists so a test can still exercise it, which is the only coverage that legacy
|
|
* blob-bound accounts get a link at all.
|
|
*
|
|
* `platform` is COALESCEd to 0 because nothing ever defaulted that field — an account
|
|
* can carry a platformId with no platform recorded, and back when Steam was the only
|
|
* verifiable platform an unset one *was* Steam.
|
|
*
|
|
* NOT byte-identical to the migration any more, deliberately. 0007 selected the
|
|
* `account.platform_id` generated column; 0008 drops it, so that text is unrunnable
|
|
* against the head schema the tests build. This selects the blob directly instead —
|
|
* the same values, since the dropped column was DEFINED as
|
|
* `json_extract(data, '$.platformId')`. 0007 is left exactly as it ran on prod.
|
|
*/
|
|
export const PLATFORM_BACKFILL_SQL = `INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
|
|
SELECT
|
|
account_id,
|
|
COALESCE(json_extract(data, '$.platform'), 0),
|
|
json_extract(data, '$.platformId'),
|
|
COALESCE(json_extract(data, '$.createdAt'), '1970-01-01T00:00:00Z')
|
|
FROM account
|
|
WHERE json_extract(data, '$.platformId') IS NOT NULL
|
|
AND json_extract(data, '$.platformId') <> ''`
|
|
|
|
/**
|
|
* The platforms a cached login can actually be redeemed for — the ones
|
|
* `verifyPlatformProof` can prove, which is Steam (a Steam-signed ticket) and Meta/Oculus
|
|
* (a Meta-validated nonce).
|
|
*
|
|
* This is the picker's filter, and it exists to keep a promise the picker's own API
|
|
* documentation makes: "an entry here is always redeemable by a `cached_login` grant
|
|
* (both read the same table)". Once the table began holding identities that are NOT
|
|
* credentials — Discord, from the website's benefits claim — listing every row would have
|
|
* broken that promise in two ways at once. The client would be offered an account it can
|
|
* never log into (the grant refuses platform 101 outright), and, worse, the picker is
|
|
* PUBLIC and unauthenticated: `GET /cachedlogin/forplatformid/101/<snowflake>` would have
|
|
* told anyone which RecFlare account a given Discord user owns, and the bulk route would
|
|
* have done it for a list of them at once. A Discord id is trivially readable by anyone in
|
|
* a shared server, so that is a deanonymisation of every player who claimed benefits.
|
|
*
|
|
* Adding a platform here means asserting `verifyPlatformProof` can prove it. Filtering
|
|
* happens in the two picker reads only — {@link isPlatformIdentityLinked},
|
|
* {@link countAccountsForPlatformIdentity} and {@link getLinksForAccount} deliberately see
|
|
* every link, because they answer "is this identity taken / whose is it", which is exactly
|
|
* the question the benefits claim's once-only guard asks about a Discord id.
|
|
*/
|
|
export const CACHED_LOGIN_PLATFORMS: readonly number[] = [PlatformType.Steam, PlatformType.Oculus]
|
|
|
|
/**
|
|
* `IN (…)` fragment for the allowlist, so the filter is applied by the query rather than
|
|
* in JS. Placeholders are numbered from ?2 because the one caller binds the platform id
|
|
* as ?1 — explicit indices rather than bare `?`, which SQLite would number by position
|
|
* and quietly renumber the moment another parameter is added ahead of it.
|
|
*/
|
|
const CACHED_LOGIN_FILTER = `platform IN (${CACHED_LOGIN_PLATFORMS.map((_, i) => `?${i + 2}`).join(', ')})`
|
|
|
|
/** One account ↔ platform identity link. */
|
|
export interface PlatformLink {
|
|
accountId: number
|
|
platform: number
|
|
platformId: string
|
|
/** ISO-8601 time the link was made. */
|
|
linkedAt: string
|
|
/**
|
|
* The roles this identity held in its platform's community when the link was last
|
|
* written — Discord role id snowflakes, kept as strings (one exceeds 2^53). Empty for
|
|
* every platform that has no such thing, which is all of them but Discord.
|
|
*
|
|
* A SNAPSHOT, not a live read. The benefits claim writes it from the member record the
|
|
* player's own OAuth token served, and the `www` worker's daily cron re-reads every
|
|
* Discord link through a bot token when the operator has configured one
|
|
* (apps/www/src/discord-roles.ts) — so with a bot it trails the guild by at most a day,
|
|
* and without one it says what they held when they claimed. Stored as a JSON array in
|
|
* the `role` column (migration 0010).
|
|
*/
|
|
roles: string[]
|
|
}
|
|
|
|
interface LinkRow {
|
|
accountId: number
|
|
platform: number
|
|
platformId: string
|
|
linkedAt: string
|
|
/** The `role` column: a JSON array of role ids, or NULL for a link that has none. */
|
|
role: string | null
|
|
}
|
|
|
|
const SELECT_LINK = `SELECT account_id AS accountId, platform, platform_id AS platformId,
|
|
linked_at AS linkedAt, role FROM platform_account`
|
|
|
|
/**
|
|
* A stored `role` array back into role ids. Tolerant by design — every failure mode is
|
|
* "this link has no roles recorded", which is the truth for every Steam and Meta link and
|
|
* the honest answer for a row written before 0010. A NULL, a value that isn't JSON, or a
|
|
* JSON value that isn't an array of strings all land there rather than throwing: the roles
|
|
* are a record, and no caller's correctness depends on them, so a bad one must not take
|
|
* down the login picker that reads the same rows.
|
|
*/
|
|
const parseRoles = (raw: string | null): string[] => {
|
|
if (raw === null || raw === '') return []
|
|
try {
|
|
const parsed: unknown = JSON.parse(raw)
|
|
return Array.isArray(parsed) ? parsed.filter((r): r is string => typeof r === 'string') : []
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
/** One row as the rest of the codebase sees it. */
|
|
const toLink = (row: LinkRow): PlatformLink => ({
|
|
accountId: row.accountId,
|
|
platform: row.platform,
|
|
platformId: row.platformId,
|
|
linkedAt: row.linkedAt,
|
|
roles: parseRoles(row.role),
|
|
})
|
|
|
|
/**
|
|
* Link a verified platform identity to an account. Idempotent — re-logging in on the
|
|
* same platform doesn't churn the row, and `linkedAt` keeps the time of the FIRST
|
|
* link. Returns true when this created a new link.
|
|
*
|
|
* Callers must pass an identity the platform itself proved. Nothing in here can tell
|
|
* a verified id from a spoofed one.
|
|
*
|
|
* `roles` records what that identity held in its platform's community — Discord role ids,
|
|
* from the benefits claim's member read. Omitting it (every login path: Steam and Meta
|
|
* have no such thing) leaves the column NULL rather than writing an empty array, so
|
|
* "never had roles" stays distinguishable in the table from "claimed holding none". An
|
|
* EMPTY array is a real value and is stored as one.
|
|
*
|
|
* Unlike the rest of the row, roles are REFRESHED on a repeat link: they're a snapshot of
|
|
* a membership that moves, and the later claim is the fresher reading. That is also why
|
|
* the update is separate from the insert rather than an upsert — the insert's
|
|
* `meta.changes` is what tells a new link from an existing one, and an
|
|
* `ON CONFLICT DO UPDATE` reports a change either way, which would make every re-login
|
|
* look like a first link to {@link linkLoginIdentity}'s primary-identity write. The second
|
|
* statement only runs on the Discord path, and only when the row was already there.
|
|
*/
|
|
export async function linkPlatformIdentity(
|
|
db: D1Database,
|
|
accountId: number,
|
|
platform: number,
|
|
platformId: string,
|
|
roles?: readonly string[]
|
|
): Promise<boolean> {
|
|
if (platformId === '') return false
|
|
const role = roles === undefined ? null : JSON.stringify([...roles])
|
|
const res = await db
|
|
.prepare(
|
|
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at, role)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)`
|
|
)
|
|
.bind(accountId, platform, platformId, new Date().toISOString(), role)
|
|
.run()
|
|
if (res.meta.changes > 0) return true
|
|
|
|
if (roles !== undefined) {
|
|
await setPlatformLinkRoles(db, accountId, platform, platformId, roles)
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Overwrite the roles recorded on an EXISTING link. The daily sweep's write
|
|
* (apps/www/src/discord-roles.ts), and the second half of {@link linkPlatformIdentity}'s
|
|
* re-claim. Returns whether a link matched: the sweep reads links and writes them back, so
|
|
* a false here means the row went away in between, which is worth a log line and nothing
|
|
* more.
|
|
*/
|
|
export async function setPlatformLinkRoles(
|
|
db: D1Database,
|
|
accountId: number,
|
|
platform: number,
|
|
platformId: string,
|
|
roles: readonly string[]
|
|
): Promise<boolean> {
|
|
if (platformId === '') return false
|
|
const res = await db
|
|
.prepare(
|
|
`UPDATE platform_account SET role = ?4
|
|
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3`
|
|
)
|
|
.bind(accountId, platform, platformId, JSON.stringify([...roles]))
|
|
.run()
|
|
return res.meta.changes > 0
|
|
}
|
|
|
|
/**
|
|
* Every link on one platform, oldest first — the sweep's read, which walks all of them.
|
|
*
|
|
* Unfiltered by {@link CACHED_LOGIN_PLATFORMS} on purpose — its one caller asks about
|
|
* Discord, the platform that filter exists to exclude. It is not a lookup by identity, so
|
|
* the privacy argument for the filter (see {@link getLinksForPlatformId}) doesn't apply:
|
|
* nothing here is reachable from a request.
|
|
*/
|
|
export async function getLinksForPlatform(
|
|
db: D1Database,
|
|
platform: number
|
|
): Promise<PlatformLink[]> {
|
|
const { results } = await db
|
|
.prepare(`${SELECT_LINK} WHERE platform = ?1 ORDER BY linked_at, account_id`)
|
|
.bind(platform)
|
|
.all<LinkRow>()
|
|
return results.map(toLink)
|
|
}
|
|
|
|
/**
|
|
* The accounts a platform identity opens — what the login-screen picker lists.
|
|
* Ordered oldest link first so the list is stable between launches (D1 row order
|
|
* isn't). Empty id yields nothing rather than matching every link.
|
|
*/
|
|
export async function getLinksForPlatformIdentity(
|
|
db: D1Database,
|
|
platform: number,
|
|
platformId: string
|
|
): Promise<PlatformLink[]> {
|
|
if (platformId === '') return []
|
|
// Asking about a platform nobody can log in from yields nothing at all, rather than a
|
|
// list the grant would refuse — see CACHED_LOGIN_PLATFORMS.
|
|
if (!CACHED_LOGIN_PLATFORMS.includes(platform)) return []
|
|
const { results } = await db
|
|
.prepare(
|
|
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
|
|
)
|
|
.bind(platform, platformId)
|
|
.all<LinkRow>()
|
|
return results.map(toLink)
|
|
}
|
|
|
|
/**
|
|
* Links for a bare platform id, whatever platform it belongs to. For the bulk
|
|
* (friends-resolution) lookup, which posts ids with no platform alongside them, and
|
|
* for the single-id route when the client sends a non-numeric platform.
|
|
*/
|
|
export async function getLinksForPlatformId(
|
|
db: D1Database,
|
|
platformId: string
|
|
): Promise<PlatformLink[]> {
|
|
if (platformId === '') return []
|
|
// Matches on any platform a cached login can be redeemed for — but only those. This is
|
|
// the route a bare id takes, so without the filter a Discord snowflake posted here
|
|
// would resolve its account even though naming platform 101 explicitly would not.
|
|
const { results } = await db
|
|
.prepare(
|
|
`${SELECT_LINK} WHERE platform_id = ?1 AND ${CACHED_LOGIN_FILTER}
|
|
ORDER BY linked_at, account_id`
|
|
)
|
|
.bind(platformId, ...CACHED_LOGIN_PLATFORMS)
|
|
.all<LinkRow>()
|
|
return results.map(toLink)
|
|
}
|
|
|
|
/**
|
|
* Every account a platform identity is linked to, on ANY platform — including the ones
|
|
* {@link CACHED_LOGIN_PLATFORMS} keeps out of the picker.
|
|
*
|
|
* This is the unfiltered twin of {@link getLinksForPlatformIdentity}, and it must stay off
|
|
* every public route: the filter there exists so nobody can learn which account a Discord
|
|
* user owns by naming their snowflake. The one caller is `auth`'s `identity_assertion`
|
|
* grant, where the identity being asked about has just been PROVEN by the worker that
|
|
* minted the assertion — the website's Discord sign-in — so the answer goes to the person
|
|
* it is about and nobody else. Add a caller only under the same condition.
|
|
*/
|
|
export async function getLinksForIdentity(
|
|
db: D1Database,
|
|
platform: number,
|
|
platformId: string
|
|
): Promise<PlatformLink[]> {
|
|
if (platformId === '') return []
|
|
const { results } = await db
|
|
.prepare(
|
|
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
|
|
)
|
|
.bind(platform, platformId)
|
|
.all<LinkRow>()
|
|
return results.map(toLink)
|
|
}
|
|
|
|
/**
|
|
* Whether any of the account's links on `platform` has roles RECORDED — a non-null `role`
|
|
* column, which only the website's benefits claim (and the sweep behind it) ever writes.
|
|
*
|
|
* This is what "has this account claimed its Discord benefits" means now that a Discord
|
|
* link can exist WITHOUT a claim: signing in with Discord (`auth`'s `identity_assertion`
|
|
* grant) links the identity on a brand-new account with `role` NULL. The claim's one-time
|
|
* gift and its page both ask this rather than "is there a link", so a player who made
|
|
* their account from Discord is still owed, and still shown, their first claim.
|
|
* An EMPTY role list counts as recorded: that is a claim that found no roles.
|
|
*/
|
|
export async function hasRecordedRoles(
|
|
db: D1Database,
|
|
accountId: number,
|
|
platform: number
|
|
): Promise<boolean> {
|
|
const row = await db
|
|
.prepare(
|
|
`SELECT 1 AS ok FROM platform_account
|
|
WHERE account_id = ?1 AND platform = ?2 AND role IS NOT NULL LIMIT 1`
|
|
)
|
|
.bind(accountId, platform)
|
|
.first<{ ok: number }>()
|
|
return row !== null
|
|
}
|
|
|
|
/**
|
|
* Record roles on an existing link that has NONE yet (`role` NULL), atomically. True when
|
|
* this call was the one that did it; false when the link is missing or already carries a
|
|
* role list — in which case nothing is written, and {@link linkPlatformIdentity}'s refresh
|
|
* is the caller's next step.
|
|
*
|
|
* The one-shot flip is the point: the benefits claim pays its first-claim gift on the
|
|
* INSERT of a fresh link, which two racing claims cannot both win. A link made by a Discord
|
|
* sign-in is already inserted by the time the claim comes, so this gives the claim the
|
|
* same single-winner write on the roles column instead.
|
|
*/
|
|
export async function recordFirstRoles(
|
|
db: D1Database,
|
|
accountId: number,
|
|
platform: number,
|
|
platformId: string,
|
|
roles: readonly string[]
|
|
): Promise<boolean> {
|
|
if (platformId === '') return false
|
|
const res = await db
|
|
.prepare(
|
|
`UPDATE platform_account SET role = ?4
|
|
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3 AND role IS NULL`
|
|
)
|
|
.bind(accountId, platform, platformId, JSON.stringify([...roles]))
|
|
.run()
|
|
return res.meta.changes > 0
|
|
}
|
|
|
|
/**
|
|
* A list of Discord role ids out of an operator var — numeric snowflakes separated by
|
|
* commas and/or whitespace, the grammar every `*_ROLE_IDS`-shaped var here shares
|
|
* (`auth`'s DISCORD_ROLLOUT_ROLE_IDS, `STUDIO_ROLLOUT`; `www` reads
|
|
* DISCORD_BENEFITS_ROLE_IDS the same way). Unset or blank parses to an empty list.
|
|
*/
|
|
export function parseRoleIdList(raw: string | undefined): string[] {
|
|
return (raw ?? '')
|
|
.split(/[\s,]+/)
|
|
.map((id) => id.trim())
|
|
.filter((id) => id !== '')
|
|
}
|
|
|
|
/**
|
|
* Whether an account's Discord link records ANY of `roleIds`. False with none configured —
|
|
* the link read is skipped, so a var that names no role grants nobody anything. Roles are
|
|
* the SNAPSHOT the website's benefits claim and `www`'s daily sweep keep (see
|
|
* {@link PlatformLink.roles}), so a role granted in Discord today counts once the player
|
|
* re-claims or the sweep runs — not instantly.
|
|
*/
|
|
export async function accountHoldsDiscordRole(
|
|
db: D1Database,
|
|
accountId: number,
|
|
roleIds: readonly string[]
|
|
): Promise<boolean> {
|
|
if (roleIds.length === 0) return false
|
|
const links = await getLinksForAccount(db, accountId)
|
|
return links.some(
|
|
(link) => link.platform === PlatformType.Discord && link.roles.some((r) => roleIds.includes(r))
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Whether an account has Rec Room Studio upload access — what `auth` stamps into the
|
|
* token as the `betastudio` role, `studio` answers on its access lookups and `www`'s
|
|
* `/api/studio-access` reports. Two grants, either suffices:
|
|
*
|
|
* - the account's own `hasStudio` flag, set by staff on the website or
|
|
* `runx admin grant-studio`;
|
|
* - the STUDIO ROLLOUT: a Discord link holding a role named in the `STUDIO_ROLLOUT` var
|
|
* (`RECFLARE_STUDIO_ROLLOUT` in the root `.env`, reaching every worker), which hands a
|
|
* whole Discord role the same access without touching anyone's account. Unset, or
|
|
* naming no role, it grants nobody — the flag alone decides, as before.
|
|
*
|
|
* Every reader goes through this so the three surfaces cannot disagree: a role holder
|
|
* whom `auth` stamps `betastudio` for must also be told "granted" by the website, and
|
|
* `studio`'s lookups (which rebuild the role from the account rather than a token) must
|
|
* see the same answer. The flag is checked first so an account that has it costs no
|
|
* link read.
|
|
*/
|
|
export async function hasStudioAccess(
|
|
db: D1Database,
|
|
account: { accountId: number; hasStudio?: boolean } | null | undefined,
|
|
studioRolloutRoleIds: string | undefined
|
|
): Promise<boolean> {
|
|
if (!account) return false
|
|
if (account.hasStudio === true) return true
|
|
return accountHoldsDiscordRole(db, account.accountId, parseRoleIdList(studioRolloutRoleIds))
|
|
}
|
|
|
|
/** Every platform identity linked to an account (a player's PC and headset, say). */
|
|
export async function getLinksForAccount(
|
|
db: D1Database,
|
|
accountId: number
|
|
): Promise<PlatformLink[]> {
|
|
const { results } = await db
|
|
.prepare(`${SELECT_LINK} WHERE account_id = ?1 ORDER BY linked_at, platform`)
|
|
.bind(accountId)
|
|
.all<LinkRow>()
|
|
return results.map(toLink)
|
|
}
|
|
|
|
/**
|
|
* Whether this account is linked to this platform identity — the single check the
|
|
* `cached_login` grant authorizes on. An account with no link for the presented
|
|
* identity cannot be cached-logged-into and must use a password.
|
|
*/
|
|
export async function isPlatformIdentityLinked(
|
|
db: D1Database,
|
|
accountId: number,
|
|
platform: number,
|
|
platformId: string
|
|
): Promise<boolean> {
|
|
if (platformId === '') return false
|
|
const row = await db
|
|
.prepare(
|
|
`SELECT 1 AS ok FROM platform_account
|
|
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3`
|
|
)
|
|
.bind(accountId, platform, platformId)
|
|
.first<{ ok: number }>()
|
|
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
|
|
* and link caps are enforced against, so an identity can't accumulate accounts by
|
|
* creating them under the cap and then linking more in.
|
|
*/
|
|
export async function countAccountsForPlatformIdentity(
|
|
db: D1Database,
|
|
platform: number,
|
|
platformId: string
|
|
): Promise<number> {
|
|
if (platformId === '') return 0
|
|
const row = await db
|
|
.prepare(`SELECT COUNT(*) AS n FROM platform_account WHERE platform = ?1 AND platform_id = ?2`)
|
|
.bind(platform, platformId)
|
|
.first<{ n: number }>()
|
|
return row?.n ?? 0
|
|
}
|