[www] discord role sync

This commit is contained in:
Devin Zuczek
2026-09-25 17:17:58 -04:00
parent 4d4d0b1cd2
commit 3037043a00
9 changed files with 741 additions and 22 deletions
+11
View File
@@ -202,3 +202,14 @@ RECFLARE_DOMAIN=rec.example.com
# Also add https://<your domain>/claim to the app's Redirects in the Discord developer # Also add https://<your domain>/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 # 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. # 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 <store-id> --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.
+51 -10
View File
@@ -31,7 +31,8 @@
* That entitlement is also the one thing a link carries beyond the identity itself: the * 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 * `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 * 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' 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 * 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. * 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 * A SNAPSHOT, not a live read. The benefits claim writes it from the member record the
* without their own OAuth token (see apps/www/src/discord.ts, which deliberately holds * player's own OAuth token served, and the `www` worker's daily cron re-reads every
* no bot token), so this says what they held when they claimed. Stored as a JSON array * Discord link through a bot token when the operator has configured one
* in the `role` column (migration 0010). * (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[] roles: string[]
} }
@@ -215,16 +218,54 @@ export async function linkPlatformIdentity(
.run() .run()
if (res.meta.changes > 0) return true if (res.meta.changes > 0) return true
if (role !== null) { if (roles !== undefined) {
await db 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( .prepare(
`UPDATE platform_account SET role = ?4 `UPDATE platform_account SET role = ?4
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3` WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3`
) )
.bind(accountId, platform, platformId, role) .bind(accountId, platform, platformId, JSON.stringify([...roles]))
.run() .run()
} return res.meta.changes > 0
return false }
/**
* 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)
} }
/** /**
+54
View File
@@ -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 reload. Nothing revokes Plus: losing the role later leaves the flag set, so it
records "held the role once", not "holds it today". 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 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, closed (`/api/config` reports `benefitsEnabled: false`, so the SPA hides the tab,
and `/api/benefits/claim` returns 403). A half-configured app is 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 oracle for someone else's app), so add `http://localhost:5173/claim` too if you
want the flow to work under `pnpm turbo dev`. 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 '<bot token>' |
wrangler secrets-store secret create <store-id> --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 ## Development
### Run in dev mode ### Run in dev mode
+6
View File
@@ -125,6 +125,12 @@ export type Env = SharedHonoEnv & {
* all closes the claim, exactly as an unset one does. * all closes the claim, exactly as an unset one does.
*/ */
DISCORD_BENEFITS_ROLE_IDS?: string 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 */ /** Variables can be extended */
+283
View File
@@ -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<Response>
/**
* 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<MemberRead> {
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<MemberRead>
sleep?: (ms: number) => Promise<void>
}
const realSleep = (ms: number): Promise<void> => 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<SweepSummary> {
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<void> {
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])
+11 -5
View File
@@ -15,8 +15,14 @@ import type { Env } from './context'
* *
* Roles come from `GET /users/@me/guilds/{guild}/member`, which needs no bot: the * 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 * `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 * why the CLAIM takes this shape — nothing here has to be in the guild, and the claim
* the guild, and the worker holds no credential that could read anybody else's roles. * 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, * 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 * 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. */ /** 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 * 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<DiscordConfig | null> {
* declared in wrangler.jsonc so it's always on `env`; what varies is whether the store * 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 * 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 * `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<string> { export async function readSecret(secret: SecretsStoreSecret, name: string): Promise<string> {
try { try {
return (await secret.get()) ?? '' return (await secret.get()) ?? ''
} catch (err) { } catch (err) {
+283 -2
View File
@@ -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 { 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 { AUDIT_LOG_SCHEMA_DDL } from '@repo/domain/src/audit-db'
import { PlatformType } from '@repo/domain/src/enums' import { PlatformType } from '@repo/domain/src/enums'
import { getPendingGifts, RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain/src/gifts-db' 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 { import {
CACHED_LOGIN_PLATFORMS, CACHED_LOGIN_PLATFORMS,
countAccountsForPlatformIdentity, countAccountsForPlatformIdentity,
getLinksForAccount,
getLinksForPlatform,
isPlatformIdentityLinked, isPlatformIdentityLinked,
linkPlatformIdentity, linkPlatformIdentity,
PLATFORM_SCHEMA_DDL, PLATFORM_SCHEMA_DDL,
@@ -50,12 +63,15 @@ import {
ownedCustomAvatarItemIds, ownedCustomAvatarItemIds,
} from '../../../../econ/src/inventory-custom-db' } from '../../../../econ/src/inventory-custom-db'
import { discordConfig, parseRoleIds, qualifies } from '../../discord' import { discordConfig, parseRoleIds, qualifies } from '../../discord'
import { fetchMemberRoles, refreshDiscordRoles } from '../../discord-roles'
import { DOCUMENTED_SERVICES } from '../../docs' import { DOCUMENTED_SERVICES } from '../../docs'
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links' import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
import { turnstileKeys } from '../../turnstile' import { turnstileKeys } from '../../turnstile'
import { postAuthForm, readAuthError } from '../../upstream' import { postAuthForm, readAuthError } from '../../upstream'
import { scheduled } from '../../www.app'
import type { Env } from '../../context' import type { Env } from '../../context'
import type { MemberRead } from '../../discord-roles'
declare module 'cloudflare:test' { declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {} 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 }) await expect(getProgression(env.DB, 8351)).resolves.toEqual({ PlayerId: 8351, Level: 1, XP: 0 })
expect(await auditRows('gift_xp', 8351)).toEqual([]) 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 =>
({
...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<string, MemberRead>) => {
const asked: string[] = []
const fetchMember = async (token: string, guild: string, userId: string): Promise<MemberRead> => {
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<string, string> = {}) =>
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<string, string>) =>
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 })
})
+14 -2
View File
@@ -31,6 +31,7 @@ import {
revokeToken, revokeToken,
SCOPES, SCOPES,
} from './discord' } from './discord'
import { refreshDiscordRoles } from './discord-roles'
import { docsPage, fetchSpec } from './docs' import { docsPage, fetchSpec } from './docs'
import { privacyPage } from './privacy' import { privacyPage } from './privacy'
import { import {
@@ -67,7 +68,7 @@ import {
} from './upstream' } from './upstream'
import type { Context } from 'hono' 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 * www — the website worker. It serves the React SPA (create account, sign in, change
@@ -527,4 +528,15 @@ const app = new Hono<App>()
return c.env.ASSETS.fetch(c.req.raw) 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<Env> = (_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<Env>
+25
View File
@@ -100,8 +100,33 @@
"binding": "DISCORD_CLIENT_SECRET", "binding": "DISCORD_CLIENT_SECRET",
"store_id": "local", "store_id": "local",
"secret_name": "DISCORD_CLIENT_SECRET" "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 <store-id> --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 // 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.<DOMAIN> // about the CLIENT IP, not latency: a Worker subrequest to https://auth.<DOMAIN>
// re-enters the Cloudflare edge, which overwrites CF-Connecting-IP with Cloudflare's // re-enters the Cloudflare edge, which overwrites CF-Connecting-IP with Cloudflare's