import { logger } from '@repo/hono-helpers' import type { Env } from './context' /** * Discord OAuth2, the identity check behind the website's benefits claim. * * A player proves they hold one of the qualifying roles in the community Discord, and * the claim grants them Rec Room Plus (`account.hasPlus`). The proof is a real OAuth2 * AUTHORIZATION CODE exchange, not a token the browser hands us: the SPA sends only the * short-lived `code` Discord redirected it back with, and this worker swaps that for an * access token using the client SECRET, which — like the Turnstile secret next door — * can never ship to a page. The access token therefore never exists in the browser at * all, and it is revoked here the moment the roles have been read. * * 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. * * 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 * (`/api/config` says so and `/api/benefits/claim` refuses) rather than open and * unverified. Nothing is ever inferred from the environment. */ /** Discord's API, pinned to v10 — the version the endpoints below are documented at. */ const API_BASE = 'https://discord.com/api/v10' /** * Where the browser is sent to consent. Deliberately NOT under `/api/v10`: the authorize * page is a human-facing page on the main site, and the versioned path serves a redirect * to it at best. */ export const AUTHORIZE_URL = 'https://discord.com/oauth2/authorize' /** * The scopes the claim asks for, in the order Discord shows them on the consent screen. * * - `identify` — the user's own id, which the claim stores as a `PlatformType.Discord` * link on the account to keep itself once-only. * - `guilds.members.read` — their member record (and so their ROLES) in one guild they * are in. Narrower than `guilds`, which lists every server they belong to and is not * needed: the claim asks about exactly one guild. * * A space-joined string because that is how the authorize URL takes them. */ export const SCOPES = 'identify guilds.members.read' /** Everything the claim needs configured. Resolved per request; see `discordConfig`. */ export interface DiscordConfig { /** The application's client id. PUBLIC — it ships to the browser in the authorize URL. */ clientId: string /** The application's client secret. Never leaves this worker. */ clientSecret: string /** The guild (server) whose membership is checked. */ guildId: string /** * The role ids within that guild that entitle a player to the benefits — Discord * snowflakes, all digits. ANY one of them qualifies: they're alternatives (a supporter * role, a booster role, staff…), not requirements, so this is a set to test membership * against and never an ordered list. Always at least one entry — an empty list closes * the claim (see `discordConfig`). */ roleIds: string[] } /** * Parse the configured role ids — Discord snowflakes, so each one is ALL DIGITS (a role's * display name is not an id and will never match anything). They stay strings rather than * becoming numbers: a snowflake exceeds 2^53, and they are only ever compared, never done * arithmetic on. * * Separated by commas and/or whitespace, so a value pasted out of Discord one id per line * works as well as `1077000000000000001,1077000000000000002` does; blank entries are * dropped, which is what makes a trailing comma harmless rather than a role id of `''` * that nothing can ever match. * * The digits are not ENFORCED here, deliberately. A typo'd snowflake is indistinguishable * from a real role nobody holds, and both correctly result in a claim being refused, so a * format rule would buy nothing but a way to reject a valid id if Discord ever widens the * format. Misconfiguration shows up as "nobody can claim", which is the safe direction. */ export const parseRoleIds = (raw: string): string[] => raw .split(/[\s,]+/) .map((id) => id.trim()) .filter((id) => id !== '') /** * The Discord settings, or null when the claim isn't configured — which is what CLOSES * it. All four must be present, and the role list must parse to at least ONE id: a client * id with no roles would authenticate a player and then have no question to ask about * them, and treating that as "configured" would hand Plus to anyone with a Discord * account. * * Which of the four is missing is logged (never their values) because a half-configured * app is otherwise indistinguishable from an operator deliberately leaving benefits off. * * The id and secret come from the account-level Secrets Store the whole monorepo shares, * so they're read per request rather than off `env` as strings; `.get()` caches per * isolate, so changing either needs a `www` redeploy to take effect on a warm worker — * the same caveat TURNSTILE_* and JWT_SECRET carry. The guild and roles are plain vars: * they're server ids visible to every member, not credentials. */ export async function discordConfig(env: Env): Promise { const [clientId, clientSecret] = await Promise.all([ readSecret(env.DISCORD_CLIENT_ID, 'DISCORD_CLIENT_ID'), readSecret(env.DISCORD_CLIENT_SECRET, 'DISCORD_CLIENT_SECRET'), ]) const guildId = env.DISCORD_GUILD_ID ?? '' const roleIds = parseRoleIds(env.DISCORD_BENEFITS_ROLE_IDS ?? '') if (clientId !== '' && clientSecret !== '' && guildId !== '' && roleIds.length > 0) { return { clientId, clientSecret, guildId, roleIds } } if (clientId !== '' || clientSecret !== '' || guildId !== '' || roleIds.length > 0) { logger.error('discord is half-configured, so benefit claims are closed', { hasClientId: clientId !== '', hasClientSecret: clientSecret !== '', hasGuildId: guildId !== '', // The COUNT, not the ids: a value that parsed to nothing (say, a stray comma) is // indistinguishable from an unset one without it. roleIdCount: roleIds.length, }) } return null } /** * One Secrets Store value as a string, or '' when it can't be read. The binding is * 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. */ async function readSecret(secret: SecretsStoreSecret, name: string): Promise { try { return (await secret.get()) ?? '' } catch (err) { logger.error('failed to read a discord credential from the secrets store', { secret: name, error: String(err), }) return '' } } /** * The URI Discord redirects back to after consent, derived from the request rather than * configured. * * It must be byte-identical in three places — the authorize URL the browser opens, the * token exchange below, and the app's registered redirect list — or Discord refuses the * exchange. Deriving it from the incoming request's own origin is what keeps the first * two in step across every environment (localhost in dev, the real domain in * production) with nothing to configure, and it is also why the SPA does NOT get to * supply it in the request body: an attacker-supplied redirect would turn this worker's * client secret into a redemption oracle for codes issued to somebody else's app page. * * `/claim` is the SPA route that handles the return; see App.tsx. */ export const redirectUri = (request: Request): string => new URL('/claim', request.url).toString() /** * Swap an authorization code for an access token. Returns null on any refusal — a code * that was already spent, expired (they live ~1 minute), issued to another app, or paired * with a different redirect URI all land here, and none of them is worth telling the * browser apart: the answer is the same, start the flow again. * * The credentials go in the BODY rather than a Basic auth header. Both are legal and * Discord documents the body form. */ export async function exchangeCode( config: DiscordConfig, code: string, redirect: string ): Promise { try { const res = await fetch(`${API_BASE}/oauth2/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: config.clientId, client_secret: config.clientSecret, grant_type: 'authorization_code', code, redirect_uri: redirect, }).toString(), }) if (!res.ok) { // The body carries an OAuth error code (`invalid_grant`, `invalid_client`) — the // last of which is a misconfiguration, not a player mistake, and this line is the // only place it surfaces. Logged, never relayed: it tells a caller nothing. logger.info('discord refused a code exchange', { status: res.status, body: await res.text().catch(() => ''), }) return null } const token = (await res.json()) as { access_token?: unknown } return typeof token.access_token === 'string' ? token.access_token : null } catch (err) { logger.error('could not reach discord to exchange a code', { error: String(err) }) return null } } /** * Whether a member holds ANY of the qualifying roles. Both sides are snowflake id * strings, compared exactly — Discord reports a member's roles as ids, never as names. * * The roles are alternatives (a supporter, a booster and a staff member all qualify), so * this is an intersection test and not a subset one: requiring all of them would mean * nobody ever claimed. */ export const qualifies = (memberRoles: string[], roleIds: string[]): boolean => memberRoles.some((role) => roleIds.includes(role)) /** Who claimed, and what they hold in the guild. */ export interface GuildMembership { /** The Discord user's id (a snowflake, kept as a string — it exceeds 2^53). */ userId: string /** Their Discord username, for the confirmation line. Display only, never stored. */ username: string /** Their role ids in the guild. */ roles: string[] } /** * The token owner's membership in the configured guild, or null when they aren't in it * (Discord answers 404) or the call fails. * * `null` deliberately conflates "not a member" with "we couldn't ask". Both mean the same * thing to the claim — no proof was obtained — and a claim that granted benefits when * Discord was unreachable would be worse than one that asks the player to retry. */ export async function fetchGuildMembership( accessToken: string, guildId: string ): Promise { try { const res = await fetch(`${API_BASE}/users/@me/guilds/${guildId}/member`, { headers: { authorization: `Bearer ${accessToken}` }, }) if (!res.ok) { // 404 is the ordinary "they aren't in the server" answer, so it's info, not error. logger.info('discord did not return a guild membership', { status: res.status }) return null } const member = (await res.json()) as { user?: { id?: unknown; username?: unknown } roles?: unknown } const userId = typeof member.user?.id === 'string' ? member.user.id : '' if (userId === '') { logger.error('discord returned a guild member with no user id') return null } return { userId, username: typeof member.user?.username === 'string' ? member.user.username : '', roles: Array.isArray(member.roles) ? member.roles.filter((r): r is string => typeof r === 'string') : [], } } catch (err) { logger.error('could not reach discord to read a guild membership', { error: String(err) }) return null } } /** * Hand the access token back to Discord once the roles have been read. * * Best-effort and deliberately un-awaited-on by the caller's success path: the claim has * already been decided by this point, so a failed revoke must not fail it. It's here * because the token is useless to us after one read and a live token is a liability for * however long it would otherwise last (a week) — this keeps the credential's lifetime * about as long as the request that needed it. */ export async function revokeToken(config: DiscordConfig, accessToken: string): Promise { try { await fetch(`${API_BASE}/oauth2/token/revoke`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: config.clientId, client_secret: config.clientSecret, token: accessToken, token_type_hint: 'access_token', }).toString(), }) } catch (err) { logger.info('could not revoke a discord access token', { error: String(err) }) } }