mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[www] discord role sync
This commit is contained in:
@@ -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<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
|
||||
|
||||
@@ -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 '<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
|
||||
|
||||
### Run in dev mode
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
@@ -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<DiscordConfig | null> {
|
||||
* 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<string> {
|
||||
export async function readSecret(secret: SecretsStoreSecret, name: string): Promise<string> {
|
||||
try {
|
||||
return (await secret.get()) ?? ''
|
||||
} catch (err) {
|
||||
|
||||
@@ -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 =>
|
||||
({
|
||||
...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
@@ -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<App>()
|
||||
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>
|
||||
|
||||
@@ -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 <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
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user