From 3037043a0086e81e39d31ffecd11c7e1408c1b49 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Fri, 25 Sep 2026 17:17:58 -0400 Subject: [PATCH] [www] discord role sync --- .env.example | 11 + apps/auth/src/platform-db.ts | 67 ++++- apps/www/README.md | 54 ++++ apps/www/src/context.ts | 6 + apps/www/src/discord-roles.ts | 283 +++++++++++++++++++++ apps/www/src/discord.ts | 16 +- apps/www/src/test/integration/api.test.ts | 285 +++++++++++++++++++++- apps/www/src/www.app.ts | 16 +- apps/www/wrangler.jsonc | 25 ++ 9 files changed, 741 insertions(+), 22 deletions(-) create mode 100644 apps/www/src/discord-roles.ts diff --git a/.env.example b/.env.example index 06401ec..27104b6 100644 --- a/.env.example +++ b/.env.example @@ -202,3 +202,14 @@ RECFLARE_DOMAIN=rec.example.com # Also add https:///claim to the app's Redirects in the Discord developer # portal, or the exchange is refused. Granting Plus takes effect on the player's NEXT # sign-in. `runx admin grant-plus` sets it directly, with no Discord involved. +# +# OPTIONAL: the daily role refresh (www cron) re-reads claimed members' roles through a +# bot token, so `platform_account.role` tracks the guild instead of the day they claimed. +# Add a bot user to the same Discord app, invite it to the server (no permissions needed), +# and store its token in the Secrets Store: +# +# wrangler secrets-store secret create --name DISCORD_BOT_TOKEN \ +# --scopes workers --remote +# +# The binding has to resolve for `www` to deploy, so create it with a placeholder even if +# you don't want the sweep — it then does nothing. It never touches hasPlus. diff --git a/apps/auth/src/platform-db.ts b/apps/auth/src/platform-db.ts index 527b97f..ba25b60 100644 --- a/apps/auth/src/platform-db.ts +++ b/apps/auth/src/platform-db.ts @@ -31,7 +31,8 @@ * 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`. See {@link PlatformLink.roles}. + * `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' @@ -128,10 +129,12 @@ export interface PlatformLink { * 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 from the claim, not a live read: nothing here can re-read a member's roles - * without their own OAuth token (see apps/www/src/discord.ts, which deliberately holds - * no bot token), so this says what they held when they claimed. Stored as a JSON array - * in the `role` column (migration 0010). + * 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[] } @@ -215,18 +218,56 @@ export async function linkPlatformIdentity( .run() if (res.meta.changes > 0) return true - if (role !== null) { - await db - .prepare( - `UPDATE platform_account SET role = ?4 - WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3` - ) - .bind(accountId, platform, platformId, role) - .run() + 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 { + 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 { + const { results } = await db + .prepare(`${SELECT_LINK} WHERE platform = ?1 ORDER BY linked_at, account_id`) + .bind(platform) + .all() + 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 diff --git a/apps/www/README.md b/apps/www/README.md index 7c1094a..eaa0902 100644 --- a/apps/www/README.md +++ b/apps/www/README.md @@ -134,6 +134,12 @@ IGNORE`, so `linkedAt` keeps the first claim's time — so the page is safe to reload. Nothing revokes Plus: losing the role later leaves the flag set, so it records "held the role once", not "holds it today". +The link also records the member's **roles** (`platform_account.role`, a JSON array of +role ids). The claim writes them from the member record it read; on their own they'd be +a snapshot from claim time, because the claim revokes the player's token the moment it's +done and holds nothing that could ask again. The optional **role refresh** is what keeps +them current — see below. + Four settings configure it, and **all four** are required or the claim stays closed (`/api/config` reports `benefitsEnabled: false`, so the SPA hides the tab, and `/api/benefits/claim` returns 403). A half-configured app is @@ -200,6 +206,54 @@ for byte: `www` derives the redirect URI from the incoming request's own origin oracle for someone else's app), so add `http://localhost:5173/claim` too if you want the flow to work under `pnpm turbo dev`. +### Discord role refresh (cron) + +A daily cron on `www` (`triggers.crons` in `wrangler.jsonc`, 04:30 UTC) re-reads every +claimed member's roles through a **bot token** and rewrites `platform_account.role` — so +the table says what a member holds now, not what they held the day they claimed. It is +the one credential the claim deliberately lacks, and it is optional: without it the cron +logs `discord role sweep: off` once a day and nothing changes. + +It touches **only the role snapshot**. `hasPlus` is never revoked or granted by the +sweep: a member who left the guild is recorded as holding no roles and keeps their +Plus, exactly as `runx admin grant-plus` accounts (which have no link at all) do. Read +the roles and the flag together to decide what to do about a lapsed supporter. + +To turn it on: + +1. On the same Discord application, open **Bot** and create the bot user; **Reset + Token** and copy it. No privileged intents and no permissions are needed — reading + one member's record only requires the bot to be in the guild. +2. Invite the bot to your server (**OAuth2 → URL Generator**, scope `bot`, no + permissions) and accept. +3. Store the token beside the other Discord credentials: + + ```sh + printf '' | + wrangler secrets-store secret create --name DISCORD_BOT_TOKEN --scopes workers --remote + ``` + +4. Redeploy `www`. + +**The binding must exist for the deploy to succeed** — as with `META_APP_SECRET` on +`auth`, an operator who doesn't want the sweep still creates `DISCORD_BOT_TOKEN` with +any placeholder value. With a placeholder the sweep gets a 401 on its first call, logs +`discord role sweep: stopping, discord refused the bot token (401)` and writes +nothing; with no Discord links in the table it never calls Discord at all. + +Each run walks **every** Discord link, one Discord call and one D1 write per link, in +one cron invocation. A Worker invocation has a fixed budget of both (50 subrequests on +the free plan, 1000 on paid), so that is the ceiling on claimed members before the run +would need splitting up. + +A run **stops without writing** when Discord refuses the token (401), the bot may not +read members (403), or the bot is not in the guild (404 `Unknown Guild`) — a bot that +hasn't been invited yet must not blank every snapshot as "left". A member who has +genuinely left (404 `Unknown Member`) is written as holding no roles. Transient errors +skip that one link and leave its snapshot for the next run, and the sweep honors +Discord's rate-limit headers between calls. `wrangler tail www` shows a summary line per +run: `discord role sweep: refreshed N of M links, …`. + ## Development ### Run in dev mode diff --git a/apps/www/src/context.ts b/apps/www/src/context.ts index 043bb67..23b0a18 100644 --- a/apps/www/src/context.ts +++ b/apps/www/src/context.ts @@ -125,6 +125,12 @@ export type Env = SharedHonoEnv & { * all closes the claim, exactly as an unset one does. */ DISCORD_BENEFITS_ROLE_IDS?: string + /** + * The bot user's token for the scheduled role refresh (discord-roles.ts). Optional in + * effect: the binding must exist for the deploy, but a placeholder value just leaves the + * sweep off. The claim never reads it. + */ + DISCORD_BOT_TOKEN: SecretsStoreSecret } /** Variables can be extended */ diff --git a/apps/www/src/discord-roles.ts b/apps/www/src/discord-roles.ts new file mode 100644 index 0000000..7ba2f18 --- /dev/null +++ b/apps/www/src/discord-roles.ts @@ -0,0 +1,283 @@ +import { PlatformType } from '@repo/domain/src/enums' + +import { getLinksForPlatform, setPlatformLinkRoles } from '../../auth/src/platform-db' +import { API_BASE, readSecret } from './discord' + +import type { PlatformLink } from '../../auth/src/platform-db' +import type { Env } from './context' + +/** + * The scheduled Discord role refresh, behind the daily cron in wrangler.jsonc. + * + * The benefits claim (discord.ts, `/api/benefits/claim`) records the roles a member held + * on their `platform_account` link, read with the member's OWN OAuth token — which the + * claim revokes the moment it has read them, so nothing can ask again. That leaves + * `role` a snapshot from claim time: a supporter whose role lapsed, or who was promoted + * to staff since, looks in the table exactly as they did the day they claimed. + * + * This sweep is the credential the claim deliberately lacks: a BOT TOKEN. A bot that has + * been invited to the guild can read any member's record through + * `GET /guilds/{guild}/members/{user}` — no privileged intent, no permission bits; being + * in the guild is enough — so each run walks every Discord link, asks Discord about each, + * and writes the answer back. With a bot configured the snapshot trails the guild by at + * most a day; without one the sweep logs that it's off and the table stays as the claim + * left it. + * + * What it does NOT do is touch `hasPlus`. That flag is "held a qualifying role once", set + * by the claim or by an operator, and accounts-db.ts is explicit that a Discord link's + * state never revokes it. A member who left the guild gets an EMPTY role list and keeps + * their Plus; the roles say what they hold, the flag says what they were granted, and an + * operator reading both can decide what to do about the difference. + * + * Every link, every run, deliberately simple: no stamp column, no batching. Each link + * costs one Discord call and one D1 write, and a cron invocation has a fixed budget of + * both (50 subrequests on the free plan, 1000 paid) — a community whose claimed members + * outgrow that is the point to split the run up, not before. + * + * Logs via `console` rather than the tagged logger: that logger is request-scoped, and a + * cron has no request (the same reason `match`'s presence sweep does). + */ + +/** + * The longest one run will wait out a rate limit before moving on. Discord's per-route + * buckets reset in well under this; a `retry_after` beyond it means something global is + * going on, and the next run is a day away anyway. + */ +const MAX_PAUSE_MS = 10_000 + +/** + * What one member read came to. Four answers, because the sweep does four different things + * with them: + * + * - `member`: in the guild, with these roles. Written back. + * - `gone`: Discord says there is no such member (404 `Unknown Member`, or `Unknown User` + * for a deleted account). Written back as NO roles — leaving the guild is a real change + * in what they hold. + * - `halt`: the token or the guild is the problem (401, 403 `Missing Access`, 404 `Unknown + * Guild`), so every further call this run would fail the same way. The run stops, and + * nothing is written: a bot that isn't in the guild yet must not blank every snapshot. + * - `error`: something transient (5xx, network, an unexpected body). That one link is + * skipped and keeps its old snapshot until the next run. + * + * `pauseMs` rides along on any of them when Discord's rate-limit headers say the bucket is + * spent (`X-RateLimit-Remaining: 0`) or the call was refused for it (429): the sweep sleeps + * that long before its next call. + */ +export type MemberRead = ( + | { kind: 'member'; roles: string[] } + | { kind: 'gone' } + | { kind: 'halt'; reason: string } + | { kind: 'error'; status: number | null } +) & { pauseMs?: number } + +/** + * Discord's JSON error codes on the 404s this endpoint answers with. A 404 here is NOT one + * thing: `Unknown Member` is the ordinary "they left", while `Unknown Guild` means the BOT + * isn't in the guild (Discord hides guilds a caller can't see), and reading that as "every + * member left" would empty every snapshot in the table on a misconfigured deploy. + */ +const UNKNOWN_GUILD = 10004 +const UNKNOWN_MEMBER = 10007 +const UNKNOWN_USER = 10013 + +/** Fetch's signature, so a test can stand in for discord.com. */ +export type FetchLike = (input: string, init?: RequestInit) => Promise + +/** + * One member's roles in the guild, read as the bot. See {@link MemberRead} for the answers. + * + * The rate-limit headers are read on every response, not just a 429, so the sweep backs + * off BEFORE it's refused: a run of dozens against a 5-per-5-seconds bucket would + * otherwise spend most of its time being told no. `X-RateLimit-Reset-After` is seconds, fractional. + */ +export async function fetchMemberRoles( + botToken: string, + guildId: string, + userId: string, + fetchImpl: FetchLike = fetch +): Promise { + let res: Response + try { + res = await fetchImpl(`${API_BASE}/guilds/${guildId}/members/${userId}`, { + headers: { authorization: `Bot ${botToken}` }, + }) + } catch (err) { + console.error(`discord role sweep: could not reach discord: ${String(err)}`) + return { kind: 'error', status: null } + } + + const pauseMs = rateLimitPause(res) + const body = (await res.json().catch(() => null)) as { + roles?: unknown + code?: unknown + retry_after?: unknown + message?: unknown + } | null + + if (res.ok) { + const roles = Array.isArray(body?.roles) + ? body.roles.filter((r): r is string => typeof r === 'string') + : null + if (roles === null) { + console.error('discord role sweep: member record had no roles array') + return { kind: 'error', status: res.status, pauseMs } + } + return { kind: 'member', roles, pauseMs } + } + + const code = typeof body?.code === 'number' ? body.code : null + switch (res.status) { + case 404: + if (code === UNKNOWN_MEMBER || code === UNKNOWN_USER) return { kind: 'gone', pauseMs } + if (code === UNKNOWN_GUILD) { + return { kind: 'halt', reason: 'the bot is not in the guild (Unknown Guild)', pauseMs } + } + return { kind: 'error', status: 404, pauseMs } + case 401: + return { kind: 'halt', reason: 'discord refused the bot token (401)', pauseMs } + case 403: + return { kind: 'halt', reason: 'the bot may not read guild members (403)', pauseMs } + case 429: { + // The body's `retry_after` (seconds) is the authoritative wait on a 429; the header + // may describe a different bucket. + const retryAfter = typeof body?.retry_after === 'number' ? body.retry_after : null + return { + kind: 'error', + status: 429, + pauseMs: retryAfter === null ? pauseMs : Math.ceil(retryAfter * 1000), + } + } + default: + return { kind: 'error', status: res.status, pauseMs } + } +} + +/** Milliseconds to wait before the next call, from the bucket headers; 0 when it has room. */ +function rateLimitPause(res: Response): number { + if (res.headers.get('x-ratelimit-remaining') !== '0') return 0 + const resetAfter = Number(res.headers.get('x-ratelimit-reset-after')) + return Number.isFinite(resetAfter) && resetAfter > 0 ? Math.ceil(resetAfter * 1000) : 0 +} + +/** What a run did, for the log line and the tests. */ +export interface SweepSummary { + /** True when the sweep is not configured (no bot token or no guild) and did nothing. */ + skipped: boolean + /** Links a member read came back for and was written. */ + refreshed: number + /** Of those, how many had roles that differ from the stored snapshot. */ + changed: number + /** Of those, how many are no longer in the guild. */ + gone: number + /** Links skipped for a transient error, left as they were for the next run. */ + failed: number + /** Why the run stopped early, or null when it worked through its batch. */ + halted: string | null +} + +/** The seams a test replaces: the Discord read and the rate-limit sleep. */ +export interface SweepDeps { + fetchMember?: (botToken: string, guildId: string, userId: string) => Promise + sleep?: (ms: number) => Promise +} + +const realSleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * One run of the sweep. Sequential on purpose — Discord's per-route bucket is small, and + * one call at a time with the header-driven pause is what stays inside it. + * + * Order of checks: configuration, then whether there is anything to refresh, then Discord. + * An operator with no Discord links (or none configured) never sends a request, so a + * placeholder bot token on a server that doesn't use the claim costs nothing but the + * one D1 read. + */ +export async function refreshDiscordRoles(env: Env, deps: SweepDeps = {}): Promise { + const summary: SweepSummary = { + skipped: false, + refreshed: 0, + changed: 0, + gone: 0, + failed: 0, + halted: null, + } + const fetchMember = deps.fetchMember ?? fetchMemberRoles + const sleep = deps.sleep ?? realSleep + + // Guild before token: a server with no Discord configured at all shouldn't log a failed + // secret read every hour on top of being off. + const guildId = env.DISCORD_GUILD_ID ?? '' + if (guildId === '') { + console.log('discord role sweep: off (no DISCORD_GUILD_ID)') + return { ...summary, skipped: true } + } + const botToken = await readSecret(env.DISCORD_BOT_TOKEN, 'DISCORD_BOT_TOKEN') + if (botToken === '') { + console.log('discord role sweep: off (no DISCORD_BOT_TOKEN)') + return { ...summary, skipped: true } + } + + const links = await getLinksForPlatform(env.DB, PlatformType.Discord) + if (links.length === 0) { + console.log('discord role sweep: no discord links to refresh') + return summary + } + + for (const link of links) { + const read = await fetchMember(botToken, guildId, link.platformId) + if (read.kind === 'halt') { + summary.halted = read.reason + console.error(`discord role sweep: stopping, ${read.reason}`) + break + } + if (read.kind === 'error') { + summary.failed++ + console.error( + `discord role sweep: could not read account ${link.accountId}'s member (status ${read.status ?? 'none'}), skipping` + ) + } else { + await recordRead(env.DB, link, read.kind === 'gone' ? [] : read.roles, summary) + if (read.kind === 'gone') summary.gone++ + } + if (read.pauseMs !== undefined && read.pauseMs > 0) { + await sleep(Math.min(read.pauseMs, MAX_PAUSE_MS)) + } + } + + console.log( + `discord role sweep: refreshed ${summary.refreshed} of ${links.length} links, ${summary.changed} changed, ${summary.gone} left the guild, ${summary.failed} failed${summary.halted ? `, halted: ${summary.halted}` : ''}` + ) + return summary +} + +/** Write one reading back and count it. */ +async function recordRead( + db: D1Database, + link: PlatformLink, + roles: string[], + summary: SweepSummary +): Promise { + const wrote = await setPlatformLinkRoles( + db, + link.accountId, + link.platform, + link.platformId, + roles + ) + if (!wrote) { + // Read a moment ago and gone now: the link was removed in between. Nothing to do. + console.log(`discord role sweep: account ${link.accountId}'s discord link vanished mid-run`) + return + } + summary.refreshed++ + if (!sameRoles(link.roles, roles)) { + summary.changed++ + console.log( + `discord role sweep: account ${link.accountId} now holds ${roles.length} role(s) (was ${link.roles.length})` + ) + } +} + +/** Same set of role ids, in any order — Discord doesn't promise an order and neither does the row. */ +const sameRoles = (a: readonly string[], b: readonly string[]): boolean => + a.length === b.length && [...a].sort().every((role, i) => role === [...b].sort()[i]) diff --git a/apps/www/src/discord.ts b/apps/www/src/discord.ts index 635e2e4..0c02f1f 100644 --- a/apps/www/src/discord.ts +++ b/apps/www/src/discord.ts @@ -15,8 +15,14 @@ import type { Env } from './context' * * Roles come from `GET /users/@me/guilds/{guild}/member`, which needs no bot: the * `guilds.members.read` scope lets the TOKEN'S OWNER read their own membership. That is - * the whole reason this shape was chosen over a bot token — nothing here has to be in - * the guild, and the worker holds no credential that could read anybody else's roles. + * why the CLAIM takes this shape — nothing here has to be in the guild, and the claim + * holds no credential that could read anybody else's roles. + * + * The one thing that CAN is the optional bot token behind the scheduled role refresh + * (discord-roles.ts): with it the worker re-reads every claimed member's roles on a + * cron; without it the claim's own reading is all `platform_account.role` ever holds. + * The claim never uses the bot token — the two paths share only the API base and the + * guild id. * * The four settings (client id, client secret, guild, one or more roles) are the switch, * exactly as the Turnstile keypair is for signup: with any of them missing the claim is CLOSED @@ -25,7 +31,7 @@ import type { Env } from './context' */ /** Discord's API, pinned to v10 — the version the endpoints below are documented at. */ -const API_BASE = 'https://discord.com/api/v10' +export const API_BASE = 'https://discord.com/api/v10' /** * Where the browser is sent to consent. Deliberately NOT under `/api/v10`: the authorize @@ -132,9 +138,9 @@ export async function discordConfig(env: Env): Promise { * declared in wrangler.jsonc so it's always on `env`; what varies is whether the store * holds the secret — a missing one throws rather than resolving empty. Mirrors * `turnstile.ts`'s reader, and for the same reason: a store this worker can't read must - * close the feature, not 500 the homepage. + * close the feature, not 500 the homepage. Shared with the role sweep's bot token read. */ -async function readSecret(secret: SecretsStoreSecret, name: string): Promise { +export async function readSecret(secret: SecretsStoreSecret, name: string): Promise { try { return (await secret.get()) ?? '' } catch (err) { diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index 93b8d93..a56e698 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -1,7 +1,18 @@ -import { adminSecretsStore, env, SELF } from 'cloudflare:test' +import { + adminSecretsStore, + createExecutionContext, + createScheduledController, + env, + SELF, + waitOnExecutionContext, +} from 'cloudflare:test' import { beforeAll, expect, it } from 'vitest' -import { SCHEMA_DDL as ACCOUNT_SCHEMA_DDL, updateAccount } from '@repo/domain/src/accounts-db' +import { + SCHEMA_DDL as ACCOUNT_SCHEMA_DDL, + getAccount, + updateAccount, +} from '@repo/domain/src/accounts-db' import { AUDIT_LOG_SCHEMA_DDL } from '@repo/domain/src/audit-db' import { PlatformType } from '@repo/domain/src/enums' import { getPendingGifts, RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain/src/gifts-db' @@ -31,6 +42,8 @@ import { createWarning, SCHEMA_DDL as WARNING_SCHEMA_DDL } from '../../../../api import { CACHED_LOGIN_PLATFORMS, countAccountsForPlatformIdentity, + getLinksForAccount, + getLinksForPlatform, isPlatformIdentityLinked, linkPlatformIdentity, PLATFORM_SCHEMA_DDL, @@ -50,12 +63,15 @@ import { ownedCustomAvatarItemIds, } from '../../../../econ/src/inventory-custom-db' import { discordConfig, parseRoleIds, qualifies } from '../../discord' +import { fetchMemberRoles, refreshDiscordRoles } from '../../discord-roles' import { DOCUMENTED_SERVICES } from '../../docs' import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links' import { turnstileKeys } from '../../turnstile' import { postAuthForm, readAuthError } from '../../upstream' +import { scheduled } from '../../www.app' import type { Env } from '../../context' +import type { MemberRead } from '../../discord-roles' declare module 'cloudflare:test' { interface ProvidedEnv extends Env {} @@ -2122,3 +2138,268 @@ it('refuses an XP gift that is not a positive whole number, too large, or to nob await expect(getProgression(env.DB, 8351)).resolves.toEqual({ PlayerId: 8351, Level: 1, XP: 0 }) expect(await auditRows('gift_xp', 8351)).toEqual([]) }) + +// ---- Discord role sweep ------------------------------------------------------ + +// The daily cron behind `platform_account.role`: with a bot token configured it re-reads +// every Discord link's roles and writes them back. Driven through `refreshDiscordRoles` +// with the Discord read replaced, because the real one calls discord.com; the HTTP +// decoding is pinned separately below with a fake fetch. + +const SWEEP_GUILD = '1077000000000000000' +const ROLE_A = '1077000000000000001' +const ROLE_B = '1077000000000000002' + +/** A configured sweep env over the test bindings. */ +const sweepEnv = (overrides: Partial = {}): Env => + ({ + ...env, + DISCORD_GUILD_ID: SWEEP_GUILD, + DISCORD_BOT_TOKEN: { get: async () => 'test-bot-token' } as SecretsStoreSecret, + ...overrides, + }) as Env + +/** A `fetchMember` that answers per Discord user id and records what it was asked. */ +const memberTable = (answers: Record) => { + const asked: string[] = [] + const fetchMember = async (token: string, guild: string, userId: string): Promise => { + expect(token).toBe('test-bot-token') + expect(guild).toBe(SWEEP_GUILD) + asked.push(userId) + return answers[userId] ?? { kind: 'error', status: 500 } + } + return { asked, fetchMember } +} + +/** The one Discord link on an account, as the sweep left it. */ +const discordLink = async (accountId: number) => + (await getLinksForAccount(env.DB, accountId)).find((l) => l.platform === PlatformType.Discord)! + +/** + * The sweep reads EVERY Discord link, and the table is shared across this file, so each + * test starts from only its own rows. + */ +const onlyDiscordLinks = async ( + links: Array<[accountId: number, userId: string, roles: string[]]> +) => { + await env.DB.prepare('DELETE FROM platform_account WHERE platform = ?1') + .bind(PlatformType.Discord) + .run() + for (const [accountId, userId, roles] of links) { + await linkPlatformIdentity(env.DB, accountId, PlatformType.Discord, userId, roles) + } +} + +it('does nothing until a guild and a bot token are configured', async () => { + await onlyDiscordLinks([[9100, '900000000000000100', [ROLE_A]]]) + const { asked, fetchMember } = memberTable({}) + await expect( + refreshDiscordRoles(sweepEnv({ DISCORD_GUILD_ID: '' }), { fetchMember }) + ).resolves.toMatchObject({ skipped: true, refreshed: 0 }) + await expect( + refreshDiscordRoles( + sweepEnv({ DISCORD_BOT_TOKEN: { get: async () => '' } as SecretsStoreSecret }), + { fetchMember } + ) + ).resolves.toMatchObject({ skipped: true }) + // A store that can't be read closes it the same way, rather than throwing out of a cron. + await expect( + refreshDiscordRoles( + sweepEnv({ + DISCORD_BOT_TOKEN: { + get: async () => { + throw new Error('secret not found') + }, + } as unknown as SecretsStoreSecret, + }), + { fetchMember } + ) + ).resolves.toMatchObject({ skipped: true }) + expect(asked).toEqual([]) + expect((await discordLink(9100)).roles).toEqual([ROLE_A]) +}) + +// The deployed test config has no guild, so the real handler takes the "off" path: pinned +// so a misconfigured deploy is a logged no-op and not a daily error. +it('runs the sweep from the scheduled handler and stays off without a guild', async () => { + await onlyDiscordLinks([[9100, '900000000000000100', [ROLE_A]]]) + const before = await discordLink(9100) + const ctx = createExecutionContext() + await scheduled(createScheduledController(), env, ctx) + await waitOnExecutionContext(ctx) + expect(await discordLink(9100)).toEqual(before) +}) + +it('re-reads every discord link and writes the roles back', async () => { + await onlyDiscordLinks([ + [9111, '900000000000000111', [ROLE_A]], + [9112, '900000000000000112', [ROLE_A]], + [9113, '900000000000000113', []], + ]) + // A Steam link on one of them is not a Discord link and is never asked about. + await linkPlatformIdentity(env.DB, 9111, PlatformType.Steam, '76561190000009111') + expect((await getLinksForPlatform(env.DB, PlatformType.Discord)).map((l) => l.accountId)).toEqual( + [9111, 9112, 9113] + ) + + const { asked, fetchMember } = memberTable({ + '900000000000000111': { kind: 'member', roles: [ROLE_A] }, + '900000000000000112': { kind: 'member', roles: [ROLE_B, ROLE_A] }, + '900000000000000113': { kind: 'member', roles: [ROLE_A] }, + }) + const summary = await refreshDiscordRoles(sweepEnv(), { fetchMember }) + expect(asked).toEqual(['900000000000000111', '900000000000000112', '900000000000000113']) + expect(summary).toEqual({ + skipped: false, + refreshed: 3, + changed: 2, + gone: 0, + failed: 0, + halted: null, + }) + expect((await discordLink(9111)).roles).toEqual([ROLE_A]) + expect((await discordLink(9112)).roles).toEqual([ROLE_B, ROLE_A]) + expect((await discordLink(9113)).roles).toEqual([ROLE_A]) + // The Steam link is as it was: no roles, and nothing wrote to it. + const steam = (await getLinksForAccount(env.DB, 9111)).find( + (l) => l.platform === PlatformType.Steam + )! + expect(steam.roles).toEqual([]) +}) + +it('records a member who left the guild as holding no roles, and leaves their Plus alone', async () => { + await updateAccount(env.DB, 9121, { hasPlus: true }) + await onlyDiscordLinks([[9121, '900000000000000121', [ROLE_A]]]) + + const { fetchMember } = memberTable({ '900000000000000121': { kind: 'gone' } }) + const summary = await refreshDiscordRoles(sweepEnv(), { fetchMember }) + expect(summary).toMatchObject({ refreshed: 1, changed: 1, gone: 1 }) + expect((await discordLink(9121)).roles).toEqual([]) + // "Held a qualifying role once" is still true, and nothing here revokes it. + expect((await getAccount(env.DB, 9121))?.hasPlus).toBe(true) + // The link itself stays: it's what keeps the claim once-only per Discord user. + expect( + await isPlatformIdentityLinked(env.DB, 9121, PlatformType.Discord, '900000000000000121') + ).toBe(true) +}) + +it('stops the run without writing when the token or the guild is refused', async () => { + await onlyDiscordLinks([ + [9131, '900000000000000131', [ROLE_A]], + [9132, '900000000000000132', [ROLE_A]], + ]) + const { asked, fetchMember } = memberTable({ + '900000000000000131': { kind: 'halt', reason: 'the bot is not in the guild (Unknown Guild)' }, + '900000000000000132': { kind: 'gone' }, + }) + const summary = await refreshDiscordRoles(sweepEnv(), { fetchMember }) + expect(asked).toEqual(['900000000000000131']) + expect(summary).toMatchObject({ + refreshed: 0, + halted: 'the bot is not in the guild (Unknown Guild)', + }) + // Neither row moved — in particular the second was NOT blanked as "gone". + for (const id of [9131, 9132]) expect((await discordLink(id)).roles).toEqual([ROLE_A]) +}) + +it('skips a link that errored, keeps going, and honors a rate-limit pause', async () => { + await onlyDiscordLinks([ + [9141, '900000000000000141', [ROLE_A]], + [9142, '900000000000000142', [ROLE_A]], + ]) + const slept: number[] = [] + const { asked, fetchMember } = memberTable({ + '900000000000000141': { kind: 'error', status: 502, pauseMs: 1_500 }, + '900000000000000142': { kind: 'member', roles: [ROLE_B], pauseMs: 60_000 }, + }) + const summary = await refreshDiscordRoles(sweepEnv(), { + fetchMember, + sleep: async (ms) => { + slept.push(ms) + }, + }) + expect(asked).toEqual(['900000000000000141', '900000000000000142']) + expect(summary).toMatchObject({ refreshed: 1, changed: 1, failed: 1, halted: null }) + // The pause is honored as asked, but capped: an hour-long retry_after is not slept. + expect(slept).toEqual([1_500, 10_000]) + // The failed one keeps its snapshot. + expect((await discordLink(9141)).roles).toEqual([ROLE_A]) + expect((await discordLink(9142)).roles).toEqual([ROLE_B]) +}) + +// The HTTP half, against a fake discord.com. A 404 is three different things here, and +// telling them apart is what keeps a bot that isn't in the guild yet from emptying every +// snapshot in the table. +it('decodes a bot member read: roles, gone, halt and error', async () => { + const answer = + (status: number, body: unknown, headers: Record = {}) => + async (url: string, init?: RequestInit) => { + expect(url).toBe( + `https://discord.com/api/v10/guilds/${SWEEP_GUILD}/members/900000000000000001` + ) + expect(new Headers(init?.headers).get('authorization')).toBe('Bot test-bot-token') + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }) + } + const read = (status: number, body: unknown, headers?: Record) => + fetchMemberRoles( + 'test-bot-token', + SWEEP_GUILD, + '900000000000000001', + answer(status, body, headers) + ) + + // A member record: roles as snowflake strings, anything else in the array dropped. + await expect( + read(200, { user: { id: '900000000000000001' }, roles: [ROLE_A, 5, ROLE_B] }) + ).resolves.toEqual({ + kind: 'member', + roles: [ROLE_A, ROLE_B], + pauseMs: 0, + }) + // A spent bucket asks for a pause before the next call, on any answer. + await expect( + read(200, { roles: [] }, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset-after': '2.5' }) + ).resolves.toEqual({ kind: 'member', roles: [], pauseMs: 2_500 }) + await expect(read(200, { user: {} })).resolves.toMatchObject({ kind: 'error', status: 200 }) + + // The three 404s. + await expect(read(404, { code: 10007, message: 'Unknown Member' })).resolves.toMatchObject({ + kind: 'gone', + }) + await expect(read(404, { code: 10013, message: 'Unknown User' })).resolves.toMatchObject({ + kind: 'gone', + }) + await expect(read(404, { code: 10004, message: 'Unknown Guild' })).resolves.toMatchObject({ + kind: 'halt', + reason: expect.stringContaining('Unknown Guild'), + }) + await expect(read(404, { code: 0, message: '404: Not Found' })).resolves.toMatchObject({ + kind: 'error', + status: 404, + }) + + // The token and the bot's access: every further call would fail the same way. + await expect(read(401, { code: 0, message: '401: Unauthorized' })).resolves.toMatchObject({ + kind: 'halt', + }) + await expect(read(403, { code: 50001, message: 'Missing Access' })).resolves.toMatchObject({ + kind: 'halt', + }) + + // Rate limited: transient, with the body's retry_after (seconds) as the pause. + await expect(read(429, { retry_after: 1.25, global: false })).resolves.toEqual({ + kind: 'error', + status: 429, + pauseMs: 1_250, + }) + await expect(read(500, {})).resolves.toMatchObject({ kind: 'error', status: 500 }) + // Unreachable: an error with no status, never a throw out of the cron. + await expect( + fetchMemberRoles('test-bot-token', SWEEP_GUILD, '900000000000000001', async () => { + throw new TypeError('fetch failed') + }) + ).resolves.toEqual({ kind: 'error', status: null }) +}) diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts index 702d2ee..17756d6 100644 --- a/apps/www/src/www.app.ts +++ b/apps/www/src/www.app.ts @@ -31,6 +31,7 @@ import { revokeToken, SCOPES, } from './discord' +import { refreshDiscordRoles } from './discord-roles' import { docsPage, fetchSpec } from './docs' import { privacyPage } from './privacy' import { @@ -67,7 +68,7 @@ import { } from './upstream' import type { Context } from 'hono' -import type { App } from './context' +import type { App, Env } from './context' /** * www — the website worker. It serves the React SPA (create account, sign in, change @@ -527,4 +528,15 @@ const app = new Hono() return c.env.ASSETS.fetch(c.req.raw) }) -export default app +export { app } + +// The daily cron (wrangler.jsonc `triggers`): refresh every Discord link's role snapshot +// through the bot token. A no-op, logged once, until the operator configures one — see +// discord-roles.ts. +export const scheduled: ExportedHandlerScheduledHandler = (_controller, env, ctx) => { + ctx.waitUntil(refreshDiscordRoles(env)) +} + +// A Worker only runs `scheduled` when it's on the default export, so www takes the object +// form the runtime requires (as `match` does for its presence sweep). +export default { fetch: app.fetch, scheduled } satisfies ExportedHandler diff --git a/apps/www/wrangler.jsonc b/apps/www/wrangler.jsonc index 4501234..86f382a 100644 --- a/apps/www/wrangler.jsonc +++ b/apps/www/wrangler.jsonc @@ -100,8 +100,33 @@ "binding": "DISCORD_CLIENT_SECRET", "store_id": "local", "secret_name": "DISCORD_CLIENT_SECRET" + }, + // The bot user's token, for the scheduled role refresh (see `triggers` below and + // src/discord-roles.ts). Unlike the pair above it is not part of the claim: it lets + // the worker re-read a claimed member's roles WITHOUT that member's own OAuth token, + // which is the one thing the claim's design cannot do. Add a bot to the same Discord + // application (Bot → Reset Token), invite it to the guild with no permissions — reading + // a member needs none — and: + // + // wrangler secrets-store secret create --name DISCORD_BOT_TOKEN \ + // --scopes workers --remote + // + // The binding has to resolve for the deploy to succeed, so an operator who wants no + // sweep still creates the secret with any placeholder value: the sweep then logs that + // it's off and does nothing, and the claim is unaffected either way. + { + "binding": "DISCORD_BOT_TOKEN", + "store_id": "local", + "secret_name": "DISCORD_BOT_TOKEN" } ], + // The Discord role refresh: once a day, re-reads every claimed member's roles through + // the bot token and rewrites `platform_account.role` (src/discord-roles.ts). One + // Discord call and one D1 write per link, so a guild with more claimed members than a + // cron invocation's subrequest budget (50 free, 1000 paid) is the point to split it up. + "triggers": { + "crons": ["30 4 * * *"] + }, // The `auth` worker, reached directly instead of over its public hostname. This is // about the CLIENT IP, not latency: a Worker subrequest to https://auth. // re-enters the Cloudflare edge, which overwrites CF-Connecting-IP with Cloudflare's