import { Hono } from 'hono' import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { useWorkersLogger } from 'workers-tagged-logger' import { z } from 'zod' import { beginEmailCode, completeEmailCode, countAccountsByDeviceId, countAccountsBySignupIp, createAccount, EMAIL_CODE_TTL_SECONDS, emailSender, GAME_VERSION, getAccount, getAccountByEmail, getAccountByUsername, getAccountsByIds, getPasswordHash, getRoomById, hashPassword, isRolloutGameVersion, isSupportedGameVersion, isValidEmail, rolloutGameBuilds, RoomInstanceType, setLastLoginTime, setLoginContext, setPasswordHash, setPresence, subRoomDataBlob, supportedGameBuilds, takeRateLimit, updateAccount, verifyPassword, writeAuditLog, } from '@repo/domain' import { flagVar, intVar, logger, withCleanSpec, withDefaultCors, withNotFound, withOnError, } from '@repo/hono-helpers' import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId, validateIdentityAssertion, } from '@repo/jwt' // The account-wide ban lives on a `report` row, whose table the api worker owns; its db // module is plain D1 queries with no runtime deps, so it imports cleanly here. import { banEvasionMatch, resolveBan } from '../../api/src/bans-db' import { buildEndpoints } from '../../ns/src/endpoints' import { verifyMetaNonce } from './meta-nonce' import { CachedLogin, ChangePasswordRequest, ChangePasswordResponse, DeviceAuthorizationRequest, DeviceAuthorizationResponse, DeviceDecisionRequest, DeviceDecisionResponse, FakeCachedLogin, form, json, OAuthError, PlatformIdsRequest, PlatformType, RecoverPasswordRequest, RecoverPasswordResponse, ResetPasswordRequest, RestrictionDto, roleFilter, roleLookup, TokenRequest, TokenResponse, } from './openapi' import { accountHoldsDiscordRole, countAccountsForPlatformIdentity, getLinksForIdentity, getLinksForPlatformId, getLinksForPlatformIdentity, hasStudioAccess, isPlatformIdentityLinked, linkPlatformIdentity, parseRoleIdList, } from './platform-db' import { recoveryEmail } from './recovery-email' import { consumeRefreshToken, issueRefreshToken, revokeRefreshTokens } from './refresh-db' import { verifySteamTicket } from './steam-ticket' import { approveDeviceGrant, beginDeviceGrant, denyDeviceGrant, DEVICE_CODE_GRANT, pollDeviceGrant, studioClientMatches, } from './studio-device' import type { Context } from 'hono' import type { Account } from '@repo/domain' import type { App, Env } from './context' import type { PlatformLink } from './platform-db' /** OAuth scopes granted by `/connect/token`. */ const TOKEN_SCOPE = 'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage' /** * The `error_description` a SIGNUP is refused with when it shares an identity with a banned * account (see bans-db's linked arms). A fixed * sentence, because `www`'s shared auth-messages table keys on this exact string to put a * real sentence in front of a player — anything varying would fall through to the generic * "you could not be signed in". Keep the two in sync. Deliberately vague: the account * being refused may be an innocent housemate of a banned player, so telling them "this * account is banned" would be a lie, and naming the account we matched them to would hand * out somebody else's moderation record. * * Only `create_account` is refused this way. SIGNING IN is never refused for a ban, its own * or a linked one — see the token grant: the client needs a token to reach the block screen, * and `match` is what holds a blocked player to their dorm. */ const BLOCKED_DESCRIPTION = 'this device or network is blocked' /** * Password-recovery requests one IP may make per hour (`POST /account/recoverpassword`), * whatever addresses they name and whether or not any is on an account. The route is * unauthenticated and answers every address the same, so this is what stops one source * from working through a list of addresses — the per-account daily cap on codes * (@repo/domain's email-code-db.ts) is what protects each mailbox. Past the cap a request * is answered as if it had succeeded and does nothing; generous enough that a household * behind one address is never caught by it. A request with no `CF-Connecting-IP` (not a * thing at the edge; tests) is not counted. */ const RECOVERY_REQUESTS_PER_IP = 10 const RECOVERY_REQUESTS_PER_IP_WINDOW_SECONDS = 60 * 60 /** * The website's "Sign in with Discord". `www` completes the Discord OAuth2 exchange (it * alone holds the client secret), then posts an `assertion` — an HS256 token signed with * the shared JWT_SECRET naming the platform identity it just proved (see `@repo/jwt`'s * `generateIdentityAssertion`). This grant trusts the assertion the way `cached_login` * trusts a Steam ticket, resolves it through the link table, and issues the ordinary * session. No account linked to the identity means a NEW one is made and linked, so a * Discord account is a way to join without Steam or Meta; the player then sets a password * on the website and signs in with it anywhere — a sideloaded Quest included. */ const IDENTITY_ASSERTION_GRANT = 'identity_assertion' /** What the grant answers when one identity opens several accounts; never true today. */ const AMBIGUOUS_IDENTITY_DESCRIPTION = 'platform identity is linked to more than one account' /** * The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded * build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded * headset reports this same value. Two things follow, and both are enforced below: * - it is never verifiable (`verifyPlatformProof` refuses it outright), and * - it is therefore never LINKED to an account. A link is a password-free way in, so * one link on a shared id would open that account to every sideloaded build. * It exists only to get such a client onto the username/password login screen. */ const SIDELOAD_PLATFORM_ID = '1' /** * The canned entry served for the one Oculus cached-login lookup below — the sideloaded * APK's way onto the password login screen. Not backed by a link, an account or a * platform proof, hence `requirePassword: true`. */ const FAKE_OCULUS_CACHED_LOGIN = { platform: PlatformType.Oculus, platformId: SIDELOAD_PLATFORM_ID, accountId: 1, lastLoginTime: '2026-07-19T17:13:29.225Z', requirePassword: true, } as const /** * Signup caps, enforced on create_account only (never on login — an existing account * always stays reachable, however many accounts its owner has since accumulated). * * Three independent arms, because they fail in different ways: * - Per verified platform id (a Steam-proven SteamID64). The sharp one: it can't be * spoofed and can't be reset by changing networks. Only binds on a platform * create_account — the password/anonymous path has no platform identity to count, * so the other two arms are all that stands between it and bulk signup. * - Per signup IP. Coarse: households, NAT and shared campus/mobile networks put many * legitimate players behind one address, so this WILL be the arm that produces false * positives. It counts `signupIp` (immutable), so an abuser can't reset their own * count by hopping networks — but a VPN gives them a fresh counter per exit, which * is exactly how one player made 150 accounts, six per address, in a weekend. * - Per client device id. Narrower than an IP (a household shares an address, not an * install) and stable across VPN hops, which is the hole in the IP arm it covers. It * counts the last-seen `deviceId`, which a login moves; see the field's notes in * accounts-db for why an immutable copy isn't worth keeping. Client-chosen and * unverified, so it catches a stock client, not a script that fabricates ids. Skipped * when the signup posts no `device_id` (the website's don't). * * These are the defaults. An operator overrides any arm with the matching worker var * (`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` / `MAX_ACCOUNTS_PER_DEVICE_ID` * in wrangler.jsonc `vars`), and setting one to 0 disables that arm entirely — which a * small private server that trusts everyone it invites will want, and which the IP arm in * particular is worth reaching for if a shared network is being locked out. */ const DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID = 3 const DEFAULT_MAX_ACCOUNTS_PER_IP = 3 const DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID = 3 /** * Whether a `create_account` with no platform — a password account, which is what the * website mints — is accepted at all (`PASSWORD_SIGNUP`; see context.ts). Off by default: * such an account has no identity but the address it came from, so the caps above are * the only thing limiting it, and a VPN or Tor exit resets the IP arm every N accounts. * That is how one player made 150 accounts in a weekend. A game-client signup always * carries a Steam or Meta identity and is untouched by this switch. * * Refused as `invalid_grant` with this description, which `www` translates for the form * (see its auth-messages.ts) — keep the two in step. */ const DEFAULT_PASSWORD_SIGNUP = false const PASSWORD_SIGNUP_DISABLED_DESCRIPTION = 'password signup is disabled' /** * The build gates on `/connect/token`, both keyed on the grant's posted `ver` and both * driven by the operator's game-version vars (`SUPPORTED_GAME_VERSIONS`, * `ROLLOUT_GAME_VERSIONS` — see context.ts and `supportedGameBuilds`/`rolloutGameBuilds` * in @repo/domain, which `api` and `match` read the same way). A grant posting no `ver` * (a refresh, the website) is untouched by either. * * 1. The SUPPORT gate: a build outside `supportedGameBuilds` is refused as `invalid_grant` * with UNSUPPORTED_BUILD_DESCRIPTION, for every grant and every account — staff * included. `api`'s `/api/versioncheck/v4` tells the same builds to update; this is what * makes the update prompt stick, since a client that got a token anyway would play on. * Dropping a build from the list is how its players are made to upgrade. * 2. The ROLLOUT gate: a build under staged rollout is refused unless the account is STAFF * (developer or moderator) or its Discord link holds a role named in * `DISCORD_ROLLOUT_ROLE_IDS` (see `accountMayUseRolloutBuild`), as `invalid_grant` with * ROLLOUT_GATED_DESCRIPTION. `create_account` is refused before anything is minted: a * brand-new account has no Discord link to hold a role, so the player makes their * account on a released build, links Discord on the website, and comes back. Ending the * rollout (`ROLLOUT_GAME_VERSIONS=off`) opens the build to everyone. * * `ver` is client-reported and unverified, like everything else about the build. That is * fine for what these are: rollout and upgrade gates, not a security boundary. A player * who lies about their build gets a token the surfaces of the build they claim would have * handed them anyway, and then runs a client the server isn't answering for. */ const ROLLOUT_GATED_DESCRIPTION = 'this build is limited to Discord supporters; link your Discord on the website first' const UNSUPPORTED_BUILD_DESCRIPTION = 'this game version is not supported by this server; update your game' /** * `DISCORD_ROLLOUT_ROLE_IDS` as a list of role ids: snowflakes separated by commas and/or * whitespace (`parseRoleIdList`, the grammar `www`'s parseRoleIds reads * DISCORD_BENEFITS_ROLE_IDS with). Unset or blank parses to an empty list, which * `accountMayUseRolloutBuild` treats as a gate closed to everyone but staff. */ const rolloutRoleIds = parseRoleIdList /** * Whether an account may sign in on a rollout-gated build. STAFF — a developer or moderator * flag on the account, the same two `staff_login` audits — always may, configured roles or * not: they are who tests a build before it reaches supporters, so a closed gate stays open * to them. Anyone else needs a Discord link (platform 101, written by the website's benefits * claim and refreshed by `www`'s daily sweep) recording at least one of the configured * roles. The roles are a SNAPSHOT (see PlatformLink.roles), so a role granted in Discord * today opens the gate once the player re-claims or the sweep runs — not instantly. * * An empty role list skips the link read: with no supporter role named, nobody is a * supporter. */ async function accountMayUseRolloutBuild( db: D1Database, accountId: number, roleIds: readonly string[] ): Promise { const account = await getAccount(db, accountId) if (account && (holdsRole(account, 'developer') || holdsRole(account, 'moderator'))) return true return accountHoldsDiscordRole(db, accountId, roleIds) } /** New players start in the Orientation room (RoomId 13) — the new-user flow. */ const ORIENTATION_ROOM_ID = 13 /** * The client loads Orientation locally (no matchmake) and tags its instance with * the sentinel id -2. The heartbeat must echo that exact `roomInstanceId` or the * client treats presence as out-of-sync and bounces the player to the dorm. */ const ORIENTATION_INSTANCE_ID = -2 /** * Seed a freshly created account's match presence to the Orientation room. The * client is placed into Orientation by its new-user flow without a matchmake * call, so the match heartbeat would otherwise report no/stale (dorm) presence * and bounce the player out. We write the Orientation instance (built from the * shared rooms D1, matching the match worker's `roomInstanceFromRoom` shape) into * the shared `presence` table (see @repo/domain) so the heartbeat keeps them there. */ async function placeNewPlayerInOrientation( env: App['Bindings'], accountId: number, deviceClass: number ): Promise { // getRoomById hydrates the room's SubRooms from the subroom table (they no longer // live in the room blob), so the Orientation scene resolves the same way match does. const room = await getRoomById(env.DB, ORIENTATION_ROOM_ID) if (!room) return const subRooms = room.SubRooms const sub = (Array.isArray(subRooms) ? subRooms[0] : undefined) as Record | undefined const str = (v: unknown, fallback = '') => (typeof v === 'string' ? v : fallback) const num = (v: unknown, fallback: number) => (typeof v === 'number' ? v : fallback) const roomInstance = { roomInstanceId: ORIENTATION_INSTANCE_ID, roomId: ORIENTATION_ROOM_ID, subRoomId: num(sub?.SubRoomId, 1), roomInstanceType: RoomInstanceType.Public, location: str(sub?.UnitySceneId), dataBlob: subRoomDataBlob(sub), eventId: 0, clubId: 0, roomCode: '', photonRegion: 'us', photonRegionId: 'us', photonRoomId: `rec.${ORIENTATION_ROOM_ID}`, name: `^${str(room.Name, 'Orientation')}`, maxCapacity: num(sub?.MaxPlayers, 4), isFull: false, isPrivate: false, isInProgress: false, EncryptVoiceChat: false, } await setPresence(env.DB, { accountId, roomInstance, statusVisibility: 0, deviceClass, vrMovementMode: 1, platform: 0, appVersion: GAME_VERSION, }) } /** The Bearer token's account id (`sub`), or null when there's no valid token. */ async function authedId(c: Context): Promise { return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) } /** Whether one account row holds a role. Absent/false both mean no role. */ function holdsRole(account: Account, role: 'developer' | 'moderator' | 'screenshare'): boolean { // Match the same staff override used when minting account tokens. if (role === 'screenshare') return account.hasScreenshare === true return (role === 'developer' ? account.isDeveloper : account.isModerator) === true } /** * The ONE-player lookup (`/role/{role}/{id}`): a BARE JSON boolean — the client reads the body * as a bool, not as an object — or an empty 404 for a player this server has no account for, * mirroring the reference API. An id that is not a number is answered as unknown (404) rather * than as a 400: `/role/developer/nonsense` is just an id that matches nothing. */ async function roleAnswer( c: Context, role: 'developer' | 'moderator' | 'screenshare', id: string ) { logger.info(`${role} role lookup`, { id }) const accountId = Number.parseInt(id, 10) const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId) if (!account) return c.body(null, 404) return c.json(holdsRole(account, role)) } /** * The BULK form (`/role/{role}?id=1&id=2`): WHICH of the players asked about hold the role, as * an array of ints. A different question from the single lookup and so a different shape — * never that route's boolean. * * A FILTER, so nothing here is an error: an id that names no account, or names one without the * role, is left out, and a query naming no ids answers `[]`. A 404 would be wrong — the * question "which of these are developers" has the answer "none of them", not "no such thing". * * Answered in the order the query asked, de-duplicated, so a caller can read the result * against its own list. One round trip however many ids are named: `getAccountsByIds` splits * the read across statements when it exceeds D1's bound-parameter limit. */ async function roleFilterAnswer(c: Context, role: 'developer' | 'moderator') { // `queries` and not `query`: the ids arrive as a REPEATED parameter, and `query` would // collapse them to the first and silently answer about one player out of the set. const asked = c.req.queries('id') ?? [] const ids = [...new Set(asked.map((v) => Number.parseInt(v, 10)).filter(Number.isInteger))] logger.info(`${role} role filter`, { asked: asked.length, ids: ids.length }) if (ids.length === 0) return c.json([]) const accounts = await getAccountsByIds(c.env.DB, ids) const holders = new Set(accounts.filter((a) => holdsRole(a, role)).map((a) => a.accountId)) return c.json(ids.filter((id) => holders.has(id))) } /** * The role names beyond `gameClient` for an account's token `role` claim. Base roles * (gameClient) are added by generateToken. `screenshare` requires an explicit staff * grant via `hasScreenshare`. The other roles come from account flags and Studio access. * * `betastudio` rides on `studio` — the account's Studio access as `hasStudioAccess` * resolves it: the `hasStudio` flag staff set from the website, OR a Discord link holding * a role in the `STUDIO_ROLLOUT` var — and developer does not imply it. RecFlare Studio * treats that exact claim as Full access, which is what lets the editor upload. The * caller resolves it (it costs a link read) so this stays a pure projection of the * account. `influencer` rides on `isInfluencer` (`runx admin grant-influencer`) and lives * only in the token — no `/role/*` lookup answers it. Order is stable so tokens are * deterministic: screenshare, developer, moderator, junior, betastudio, then influencer. */ function accountRoles( account: Pick< Account, 'isDeveloper' | 'isModerator' | 'isJunior' | 'isInfluencer' | 'hasScreenshare' > | null, studio: boolean ): string[] { const roles: string[] = account?.hasScreenshare === true ? ['screenshare'] : [] if (!account) return roles if (account.isDeveloper) roles.push('developer') if (account.isModerator) roles.push('moderator') if (account.isJunior) roles.push('junior') if (studio) roles.push('betastudio') if (account.isInfluencer) roles.push('influencer') return roles } /** * The account's token `rn.privilege` claim. Despite the scope-shaped name it is a CLAIM, * read out of the same claims dictionary as `role` — it never belongs in `scope`. The * client knows exactly two values, both chat restrictions, and both ride on a junior * account: `BanVChat` (voice) and `BanRmChat` (room chat). Empty for everyone else, which * drops the claim rather than sending a blank one. */ function accountPrivileges(account: Pick | null): string[] { return account?.isJunior ? ['BanVChat', 'BanRmChat'] : [] } /** * The platform an account's `platformId` belongs to. Nothing defaults the `platform` * field (see defaultAccount), so an account can carry a platform identity with no * platform recorded — and until Meta verification landed Steam was the only identity * we could prove, so an unset one *is* Steam. Every account bound since records its * platform explicitly; this default only covers those older rows. */ function accountPlatform(account: Pick): number { return account.platform ?? 0 } /** * The browser page Studio opens lives on WWW, not on this worker. `ns` is what * told the editor where WWW is, so the URI has to be built from the same domain * and the same subdomain override or the tab opens on a host the player doesn't have. */ function studioVerificationUri(env: Env, userCode: string): { uri: string; complete: string } { const www = buildEndpoints(env.DOMAIN, env.SUBDOMAINS).WWW const uri = `${www}/device` return { uri, complete: `${uri}?user_code=${encodeURIComponent(userCode)}` } } function oauthError( c: Context, error: string, error_description: string, status: 400 | 500 = 400 ) { return c.json({ error, error_description }, status) } /** * Issue the same token body every other grant returns, for an account that has * already been authenticated — here, because the player approved a Studio code * on the website. Studio reads `expires_in` as seconds (`TimeSpan.FromSeconds`). */ async function issueSession( c: Context, accountId: number, grantType: string, /** Extra top-level fields on the token body — `created: true` on an identity signup. */ extra: Record = {} ) { const clientIp = c.req.header('cf-connecting-ip') ?? '' const ban = await resolveBan(c.env.DB, accountId, { identity: { ip: clientIp }, arms: banEvasionMatch(c.env.BAN_EVASION_MATCH), }) if (ban) { logger.info('token issued to a blocked account', { accountId, grantType, via: ban.via, bannedAccountId: ban.bannedAccountId, reportId: ban.ban.id, }) } const jwtSecret = await c.env.JWT_SECRET.get() if (jwtSecret === '') { logger.error('refusing to issue token: JWT_SECRET is empty') return oauthError(c, 'server_error', 'token signing is not configured', 500) } const roleAccount = await getAccount(c.env.DB, accountId) if (!roleAccount) return oauthError(c, 'invalid_grant', 'account no longer exists') const accessToken = await generateToken( String(accountId), roleAccount.platformId ?? '', accountPlatform(roleAccount), jwtSecret, accountRoles(roleAccount, await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT)), accountPrivileges(roleAccount), GAME_VERSION, roleAccount.hasPlus === true ) const refreshToken = await issueRefreshToken(c.env.DB, accountId) await setLastLoginTime(c.env.DB, accountId, new Date().toISOString()) await setLoginContext(c.env.DB, accountId, { ip: clientIp }) const staffRoles = (['developer', 'moderator'] as const).filter((role) => holdsRole(roleAccount, role) ) if (staffRoles.length > 0) { try { await writeAuditLog(c.env.DB, { playerId: accountId, action: 'staff_login', data: { ip: clientIp, roles: staffRoles, grantType, username: roleAccount.username ?? null, deviceId: '', deviceClass: 0, version: null, }, }) } catch (err) { logger.error('could not write a staff_login audit log row', { accountId, grantType, error: err instanceof Error ? err.message : String(err), }) } } return c.json({ access_token: accessToken, expires_in: TOKEN_TTL_SECONDS, token_type: 'Bearer', refresh_token: refreshToken, scope: TOKEN_SCOPE, key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=', ...extra, }) } /** * The `identity_assertion` grant: sign into — or create — the account behind a platform * identity another worker has verified and signed for. See IDENTITY_ASSERTION_GRANT. * * Handled before the password fallback and outside the version/rollout gates, like the * Studio device grant: nothing here posts a `ver`, and the credential is the assertion * alone. The assertion names an IDENTITY, not an account; which account that is (or that * there is none yet) is decided here, from the same table `cached_login` reads, so the * website can never choose an account id for itself. * * Creating an account when the identity opens none is deliberate — it is the Discord * sign-up — and takes the same guards `create_account` does for a verified platform * identity: the evasion arms (a banned player's next move is a new account), the per-IP * cap (`www` forwards the browser's real address over the service binding) and the * per-identity cap. NOT the PASSWORD_SIGNUP switch: that exists because a password account * has no identity but its address, and this one has a Discord user Discord itself vouched * for, one account per user. The new account carries no `platform`/`platformId` on its * blob — only the link — so its DTO and token read like a password account's and the * Discord snowflake stays off the wire, exactly as the benefits claim keeps it. */ async function identityAssertionGrant(c: Context, body: Record) { const assertion = typeof body.assertion === 'string' ? body.assertion : '' const jwtSecret = await c.env.JWT_SECRET.get() if (jwtSecret === '') { logger.error('refusing an identity assertion: JWT_SECRET is empty') return oauthError(c, 'server_error', 'token signing is not configured', 500) } const identity = assertion ? await validateIdentityAssertion(assertion, jwtSecret) : null if (identity === null) { return oauthError(c, 'invalid_grant', 'invalid or expired identity assertion') } const links = await getLinksForIdentity(c.env.DB, identity.platform, identity.platformId) if (links.length > 1) { // The benefits claim keeps a Discord identity on one account, so this is a data // problem rather than a player's choice to make; refused rather than guessed. logger.error('identity assertion names an identity linked to several accounts', { platform: identity.platform, accountIds: links.map((l) => l.accountId), }) return oauthError(c, 'invalid_grant', AMBIGUOUS_IDENTITY_DESCRIPTION) } if (links.length === 1) { return issueSession(c, links[0].accountId, IDENTITY_ASSERTION_GRANT) } // Nobody yet: the sign-up. Everything below mirrors create_account's platform path. const clientIp = c.req.header('cf-connecting-ip') ?? '' const blocked = await resolveBan(c.env.DB, null, { identity: { ip: clientIp, platform: identity.platform, platformId: identity.platformId }, arms: banEvasionMatch(c.env.BAN_EVASION_MATCH), }) if (blocked) { logger.info('identity signup refused: player banned', { via: blocked.via, bannedAccountId: blocked.bannedAccountId, ip: clientIp, platform: identity.platform, }) return oauthError(c, 'invalid_grant', BLOCKED_DESCRIPTION) } const maxPerPlatformId = intVar( c.env.MAX_ACCOUNTS_PER_PLATFORM_ID, DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID ) if ( maxPerPlatformId > 0 && (await countAccountsForPlatformIdentity(c.env.DB, identity.platform, identity.platformId)) >= maxPerPlatformId ) { logger.info('identity signup rejected: platform account limit', { platform: identity.platform }) return oauthError(c, 'invalid_grant', 'account limit reached for this platform account') } const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP) if ( maxPerIp > 0 && clientIp !== '' && (await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp ) { logger.info('identity signup rejected: per-IP account limit', { ip: clientIp }) return oauthError(c, 'invalid_grant', 'too many accounts created from this network') } const account = await createAccount(c.env.DB, { platforms: 0, lastLoginTime: new Date().toISOString(), signupIp: clientIp || undefined, lastLoginIp: clientIp || undefined, }) await linkPlatformIdentity(c.env.DB, account.accountId, identity.platform, identity.platformId) await placeNewPlayerInOrientation(c.env, account.accountId, 0) logger.info('created an account from an identity assertion', { accountId: account.accountId, platform: identity.platform, ip: clientIp, }) return issueSession(c, account.accountId, IDENTITY_ASSERTION_GRANT, { created: true }) } async function decideDeviceCode(c: Context, decide: typeof approveDeviceGrant) { const id = await authedId(c) if (id === null) return c.body(null, 401) const body = await c.req.parseBody().catch(() => ({}) as Record) const userCode = typeof body.user_code === 'string' ? body.user_code : '' const result = await decide(c.env.DB, userCode, id) if (result.kind === 'ok') return c.json({ ok: true as const }) if (result.kind === 'expired') return oauthError(c, 'expired_token', 'that code has expired. Start the login in Studio again.') if (result.kind === 'used') return oauthError(c, 'invalid_grant', 'that code was already used.') return oauthError(c, 'invalid_grant', 'that code was not found.') } /** * Project a linked account into the client's CachedLogin DTO — the account-picker * entry on the login screen. The client posts the chosen `accountId` back as a * `grant_type=cached_login`. `requirePassword` is false because platform ownership * (the verified `platform_auth`) is the credential for a cached login — no prompt. * * The platform and id come from the LINK, not from the account: an account linked to * both a Steam and a Meta identity appears in both pickers, and each has to report the * identity that picker was asked about — that's what the client posts back, and what * the grant then checks the link against. */ function toCachedLogin(account: Account, link: PlatformLink) { return { platform: link.platform, platformId: link.platformId, accountId: account.accountId, lastLoginTime: account.lastLoginTime ?? account.createdAt, requirePassword: false, } } /** * Project a set of links into picker entries, dropping any whose account no longer * exists. One batched account read rather than one per link. * * Order follows the links (oldest first), so the picker is stable between launches. */ async function toCachedLogins(db: D1Database, links: PlatformLink[]) { if (links.length === 0) return [] const accounts = await getAccountsByIds(db, [...new Set(links.map((l) => l.accountId))]) const byId = new Map(accounts.map((a) => [a.accountId, a])) return links.flatMap((link) => { const account = byId.get(link.accountId) return account ? [toCachedLogin(account, link)] : [] }) } /** * Link the platform identity a password login proved to the account it logged into, * so the next launch on that device is a cached login. Called only with a VERIFIED * identity — a link is a password-free way into the account. * * Already linked is the common case (every subsequent login on that device) and costs * one read and nothing else. * * The per-identity cap applies here as well as at signup, or it wouldn't be a cap: * an identity could otherwise sit at the limit, have accounts created for it with a * password, and link its way into all of them. Reaching it does NOT fail the login — * the password was valid — it just leaves the account without a cached login, so the * player types their password each time rather than being locked out. * * The first identity linked also becomes the account's primary (the blob's * `platform`/`platformId`), which is what the account DTO and the refresh grant's * claims report. Later platforms link without disturbing it. */ async function linkLoginIdentity( db: D1Database, accountId: number, platform: number, platformId: string, maxAccountsPerIdentity: number ): Promise { if (await isPlatformIdentityLinked(db, accountId, platform, platformId)) return if ( maxAccountsPerIdentity > 0 && (await countAccountsForPlatformIdentity(db, platform, platformId)) >= maxAccountsPerIdentity ) { logger.info('platform link refused: account limit reached for this platform identity', { accountId, platform, platformId, }) return } if (!(await linkPlatformIdentity(db, accountId, platform, platformId))) return logger.info('linked platform identity to account', { accountId, platform, platformId }) const account = await getAccount(db, accountId) if (account && !account.platformId) { await updateAccount(db, accountId, { platform, platformId }) } } /** * What a login's `platform_auth` proved, if anything. Failures are split because the * callers act on them differently: a grant that authenticates BY platform identity has * to refuse, while a password grant — which has already proven who it is — carries on * and just doesn't link. * * `unconfigured` is an operator problem (no META_APP_SECRET), not a bad credential, * and is the one case that warrants a 5xx. */ type PlatformProof = /** Nothing was checked — the login offered no proof, so there is nothing to report. */ | { status: 'none' } | { status: 'verified'; platform: number; platformId: string } | { status: 'unsupported' } | { status: 'unconfigured' } | { status: 'rejected'; reason: string } /** * Verify a login's `platform_auth` and return the identity it proves. * * The two verifiable platforms prove the id in opposite directions, which is why they * can't share a code path: Steam's ticket *carries* a SteamID64 we read out and trust, * so the posted `platform_id` is discarded. Meta's nonce carries nothing — it is * validated *against* the posted `platform_id`, so that field is an input, and a * spoofed one fails validation rather than being ignored. Either way the id that comes * back is proven, never the raw client-supplied field, and only a proven id is ever * written to an account or linked to one. */ async function verifyPlatformProof( env: App['Bindings'], platform: number, platformAuth: string, postedPlatformId: string ): Promise { // A sideloaded APK reports the placeholder id (see SIDELOAD_PLATFORM_ID) because it // has no Meta SDK behind it. Refuse it here, before anything is asked of Meta, so no // caller downstream can treat it as an identity — above all `linkLoginIdentity` on the // password grant, which is the path such a client actually takes. Linking it would // hand every sideloaded headset a password-free login into that account, since they // all report this same id. // // Refusing costs a sideloaded player nothing: their password login still succeeds (a // password grant carries its own credential and only *links* on a verified proof), it // just never gets a cached login, so they type their password each launch. That is // the intended shape of the sideload flow. if (platform === PlatformType.Oculus && postedPlatformId === SIDELOAD_PLATFORM_ID) { return { status: 'rejected', reason: 'sideload placeholder platform id is never an identity' } } if (platform === PlatformType.Steam) { const verified = platformAuth ? await verifySteamTicket(platformAuth) : null if (!verified) return { status: 'rejected', reason: 'invalid or missing Steam ticket' } return { status: 'verified', platform: PlatformType.Steam, platformId: verified.steamId } } if (platform === PlatformType.Oculus) { // `.get()` throws when the secret doesn't exist in the store at all (as opposed to // holding an empty/placeholder value) — the same misconfiguration from the player's // side, so it takes the same branch. const appSecret = await env.META_APP_SECRET.get().catch(() => '') if (appSecret === '') return { status: 'unconfigured' } const verified = await verifyMetaNonce(platformAuth, postedPlatformId, appSecret) if (!verified.ok) return { status: 'rejected', reason: verified.reason } return { status: 'verified', platform: PlatformType.Oculus, platformId: verified.identity.userId, } } return { status: 'unsupported' } } const app = new Hono() .use( '*', // middleware (c, next) => useWorkersLogger(c.env.NAME, { environment: c.env.ENVIRONMENT, release: c.env.SENTRY_RELEASE, })(c, next) ) // The website (`www`) is a browser origin calling these endpoints directly, the way // rec.net's own site called the game's API — so the responses need CORS headers or // the browser discards them. `origin: '*'` is deliberate and safe HERE because these // endpoints authenticate with a bearer token in the `Authorization` header, never a // cookie: a hostile page can't read another origin's stored token, so there is no // ambient credential for `*` to expose. Do not add cookie auth without narrowing it. .use('*', withDefaultCors()) .onError(withOnError()) .notFound(withNotFound()) // EAC challenge — a fresh GUID, JSON-quoted, served as plain text. .get( '/eac/challenge', describeRoute({ tags: ['EAC'], summary: 'Easy Anti-Cheat challenge', description: 'Returns a constant JSON-quoted string (`"AA=="`) as `text/plain`. Anti-cheat is not implemented; this exists so the client\'s EAC handshake succeeds.', responses: { 200: { description: 'The challenge, JSON-quoted, as text/plain', content: { 'text/plain': { schema: { type: 'string', example: '"AA=="' } } }, }, }, }), (c) => c.text(`"AA=="`) ) // Cached logins for a platform id — the accounts linked to this platform-native // id, so the client can offer them on the login screen (and post one back as a // cached_login grant). No linked account → [], and the client falls back to a // fresh login / create_account. // // GET or POST: the 2023 build asks with a GET, the 2025 build (20250424.01) POSTs // the same path with a form body — `deviceId`, `platformAuth` (a JSON blob holding // the platform's session ticket and app id) and `time`. The body is READ BY NOTHING // here; both methods answer the same list off the path params, so a newer client // gets its picker. Verifying that ticket is the eventual point of the POST. .on( ['GET', 'POST'], '/cachedlogin/forplatformid/:platform/:id', describeRoute({ tags: ['Cached login'], summary: 'Accounts linked to a platform id', description: [ 'Accounts the client may offer on its login screen for this platform identity —', 'the links this identity has, so an entry here is always redeemable by a', '`cached_login` grant (both read the same table). An account linked to several', 'platforms appears in each of their pickers. An unknown id yields `[]` (not a 404)', 'and the client falls back to a fresh login or create_account.', 'EXCEPT the exact identity `1/1` (Oculus, id `1`), which is stubbed for SIDELOADED', 'APKs: with no Meta SDK they have no real identity to ask about and stall on an', 'empty picker. It consults nothing and returns one canned, non-redeemable entry', 'with `requirePassword: true`, sending the build to username/password login.', 'Older clients GET this; the 20250424.01 build POSTs it with a', '`deviceId` / `platformAuth` / `time` form body attesting the platform session.', 'That body is accepted and ignored — both methods answer identically from the', 'path params.', ].join(' '), parameters: [ { name: 'platform', in: 'path', required: true, description: 'PlatformType integer. A non-numeric value matches the id on any platform.', schema: { type: 'string' }, }, { name: 'id', in: 'path', required: true, description: 'Platform-native id — a SteamID64 for Steam, a user id for Meta.', schema: { type: 'string' }, }, ], responses: { 200: json( CachedLogin.or(FakeCachedLogin).array(), 'Matching accounts; `[]` if none. The canned entry for `1/1`.' ), }, }), async (c) => { const { platform, id } = c.req.param() logger.info('cached login lookup', { platform, id }) const platformInt = Number.parseInt(platform, 10) // SIDELOADED APKs ONLY. A sideloaded build has no Meta SDK behind it, so it can't // produce a real Meta identity or a nonce to prove one with — it asks about the // placeholder identity `1/1`, and an empty picker leaves it stuck on the platform // login screen with nothing to do. Hand back one canned entry to push it onto the // username/password login instead, which is the only flow such a build can finish. // `requirePassword` is true for exactly that reason: there's no platform proof here, // and the `cached_login` grant would (correctly) refuse this entry. // // Scoped to that ONE identity rather than to all of platform 1 — store builds do // real Meta logins, and shadowing the whole platform would hide genuine links from // their pickers. if (platformInt === PlatformType.Oculus && id === SIDELOAD_PLATFORM_ID) { return c.json([FAKE_OCULUS_CACHED_LOGIN]) } // Listed straight from the link table, which is also what the `cached_login` // grant authorizes against — so the picker can't offer an account the grant // then refuses. const links = Number.isNaN(platformInt) ? await getLinksForPlatformId(c.env.DB, id) : await getLinksForPlatformIdentity(c.env.DB, platformInt, id) return c.json(await toCachedLogins(c.env.DB, links)) } ) // Bulk cached-login lookup by platform id (friends resolution). The client POSTs // repeated `id=` params on the auth host; resolve each to its linked accounts. .post( '/cachedlogin/forplatformids', describeRoute({ tags: ['Cached login'], summary: 'Bulk cached-login lookup (friends resolution)', description: [ 'Resolves many platform ids at once. Results are flattened across all ids, so the', 'response cannot be mapped back to a specific input id — the client uses each', 'entry’s own `platformId`. No platform accompanies these ids, so each matches on', 'any platform. Unknown ids contribute nothing; a body with no `id` yields `[]`.', ].join(' '), requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'), responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') }, }), async (c) => { const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record) const raw = body.id const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String) const out: Array> = [] for (const pid of ids) { // No platform accompanies these ids, so they match on any platform. out.push(...(await toCachedLogins(c.env.DB, await getLinksForPlatformId(c.env.DB, pid)))) } return c.json(out) } ) // Studio editor device login. The POST the editor makes is // `https://auth./connect/deviceauthorization` — Auth is service enum 0, // and the path is the literal `connect/deviceauthorization`. The browser half // of the handshake is the verification_uri, which points at WWW `/device`. .post( '/connect/deviceauthorization', describeRoute({ tags: ['Token'], summary: 'Start a Rec Room Studio device login', description: [ 'RFC 8628 device authorization, called by Rec Room Studio (not the game).', 'The editor POSTs `client_id=recroom.studio` and its embedded client secret', 'to the Auth host. The response `verification_uri` is the website (`WWW /device`);', '`verification_uri_complete` is that page with `?user_code=` and is what Studio', 'opens in the browser. Studio then polls `POST /connect/token` with', '`grant_type=urn:ietf:params:oauth:grant-type:device_code`.', ].join(' '), requestBody: form(DeviceAuthorizationRequest, '`client_id` and `client_secret`'), responses: { 200: json(DeviceAuthorizationResponse, 'Codes and the WWW page to open'), 400: json(OAuthError, 'client_id or client_secret was not the Studio editor'), }, }), async (c) => { const body = await c.req.parseBody().catch(() => ({}) as Record) if (!studioClientMatches(body)) { return oauthError(c, 'invalid_client', 'unknown studio client') } const grant = await beginDeviceGrant(c.env.DB) const { uri, complete } = studioVerificationUri(c.env, grant.userCode) return c.json({ device_code: grant.deviceCode, user_code: grant.userCode, verification_uri: uri, verification_uri_complete: complete, expires_in: grant.expiresIn, interval: grant.interval, }) } ) .post( '/connect/device/approve', describeRoute({ tags: ['Token'], summary: 'Approve a Studio device code', description: 'Called by the website (`WWW /device`) with the signed-in player’s bearer token and the `user_code` Studio is showing. The editor’s next poll of `/connect/token` then receives that account’s token.', security: [{ bearerAuth: [] }], requestBody: form(DeviceDecisionRequest, 'The user_code from the editor'), responses: { 200: json(DeviceDecisionResponse, 'The code is approved'), 400: json(OAuthError, 'Unknown, expired, or already used code'), 401: { description: 'Missing or invalid bearer token (empty body)' }, }, }), (c) => decideDeviceCode(c, approveDeviceGrant) ) .post( '/connect/device/deny', describeRoute({ tags: ['Token'], summary: 'Deny a Studio device code', description: 'Same caller as approve. Studio’s next poll fails with `access_denied` instead of signing in.', security: [{ bearerAuth: [] }], requestBody: form(DeviceDecisionRequest, 'The user_code from the editor'), responses: { 200: json(DeviceDecisionResponse, 'The code is denied'), 400: json(OAuthError, 'Unknown, expired, or already used code'), 401: { description: 'Missing or invalid bearer token (empty body)' }, }, }), (c) => decideDeviceCode(c, denyDeviceGrant) ) // OAuth token endpoint — accepts a form-urlencoded body and issues a JWT. .post( '/connect/token', describeRoute({ tags: ['Token'], summary: 'OAuth token endpoint — issues a JWT', description: [ 'Issues an access token (plus a single-use refresh token) for one of six grants,', 'selected by `grant_type`. Every grant returns the same body on success.', '', '**`create_account`** — mints a new account with an auto-assigned random username', '(players do not pick one initially) and places it in the Orientation room. A posted', '`password` becomes the login credential. Without a `platform` this is a PASSWORD', 'account (what the website makes), accepted only while `PASSWORD_SIGNUP` is on —', 'off by default, refused as `invalid_grant` "password signup is disabled". Subject', 'to three independent signup caps,', 'per verified platform id, per signup IP and per client `device_id`', '(`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` / `MAX_ACCOUNTS_PER_DEVICE_ID`;', 'any disabled by setting it to 0). If it asserts a', '`platform`, that platform must be verifiable (Steam or Meta) and its `platform_auth`', 'must verify.', '', '**`cached_login`** — logs into an already-linked account using platform ownership as', 'the credential; no password. Requires a verifying `platform_auth`, and the posted', '`account_id` must be LINKED to exactly the identity it proves. An account with no', 'link for that identity cannot be cached-logged-into.', '', '**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The', 'platform and platform id come from what was stored at issue time, not the body.', '', '**`identity_assertion`** — the website’s "Sign in with Discord". `www` completes the', 'Discord OAuth2 code exchange (it alone holds the client secret) and posts an', '`assertion`: an HS256 token signed with the shared JWT_SECRET naming the platform', '(`101`) and platform id it proved, good for a minute. The identity is resolved through', 'the same link table as `cached_login`: one linked account is signed into; none means a', 'NEW account is created and linked (the body then carries `created: true`), under the', 'evasion check and the per-IP and per-identity signup caps but NOT the PASSWORD_SIGNUP', 'switch — the Discord user is the identity a password account lacks. The new account', 'has no password until its owner sets one on the website; with one, it signs in', 'anywhere the `password` grant does. Several linked accounts is refused.', '', '**`password`** (the fallback for any unrecognised or absent `grant_type`) —', 'identifies the account by `username` or numeric `account_id` and requires the', 'matching `password`. An account with no stored hash cannot be logged into at all,', 'which is what closes id/username-only takeover. When it also posts a `platform_auth`', 'that verifies, that identity is LINKED to the account — this is how a player who', 'signed up on one platform gets a cached login on a second device. The login is', 'never failed over the link: an unverifiable proof (or one over the per-identity', 'cap) just leaves the account without a cached login there.', '', '**Platform identity.** An account can be reached from several platform identities;', 'the links are the one thing both the picker and `cached_login` consult, and only a', 'VERIFIED identity is ever linked. Two platforms can be verified. Steam (`0`) posts a', 'Steam-signed `platform_auth` ticket, checked offline; the SteamID64 it carries', 'replaces the client-supplied `platform_id`. Meta/Oculus (`1`) posts `platform_auth`', 'as `{"Nonce":…,"AppId":…}`, which recflare sends to Meta together with the posted', '`platform_id` — validation is what binds the nonce to that user id, so a spoofed id', 'fails. Meta logins therefore need the app secret (`META_APP_SECRET`) and answer 500', 'when it is unset. The first identity linked also becomes the account’s primary', '(what the account DTO and a refreshed token report); later ones only link.', '', 'The one platform id that is never verified and never linked is `1` on platform `1`', '— what a SIDELOADED Oculus APK reports, having no Meta SDK to ask. Every such', 'build reports it, so it identifies nobody. A password login that carries it still', 'succeeds; it simply links nothing, and the player types their password each launch.', '', '**Roles.** The token embeds a `role` claim from the account, so developer/moderator', 'powers refresh on every login and every refresh grant. `junior` rides along for an', 'account flagged `isJunior`. `screenshare` requires a staff grant on the Studio Access', 'page. `betastudio` is stamped', 'from the account’s `hasStudio` flag (staff set it on the website) or from a Discord', 'link holding a role in the `STUDIO_ROLLOUT` var. RecFlare Studio', 'treats that claim as permission to upload; `developer` does not confer it. A junior', 'also carries', 'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the', 'client reads it as a claim beside `role`, and it is absent for everyone else.', '', '**Bans.** A BLOCKED account still gets a token — every grant, including a refresh,', 'and whether the ban is its own or reaches it through the evasion arms.', 'A ban is a `report` row with `banned` set (the `api` worker owns that table); it', 'lifts on its own when `ban_expires` passes, and never if that is null. The token', 'is what lets the client reach `api`’s `/api/PlayerReporting/v1/moderationBlockDetails`', 'and show the player the block screen that explains the ban; the ban itself is', 'enforced by `match`, which refuses every matchmake but the caller’s own dorm, so a', 'token gets them as far as that screen and no further.', '', 'Ban EVASION — an account sharing a PROVEN platform identity (a `platform_account`', 'link) or an IP (`signupIp`/`lastLoginIp`, or the address this request came from)', 'with a banned one — signs in the same way, and is stopped at matchmaking like any', 'other blocked account. Those two arms are the operator’s `BAN_EVASION_MATCH` knob', '(`ip`, `platform`, or `off`).', '', 'What IS refused here (`invalid_grant`) is a `create_account` carrying either link,', 'refused BEFORE it mints anything: making a NEW account to evade with is a different', 'act from signing into one. The description is deliberately vague — the signup', 'refused may belong to a housemate of the banned player rather than to them.', '', '**Support gate.** A grant whose `ver` names a build this server does not serve —', 'outside the operator’s `SUPPORTED_GAME_VERSIONS` (default: the list in `@repo/domain`)', 'and not under rollout — is refused as `invalid_grant` "this game version is not', 'supported by this server; update your game", for every grant and every account.', '`api`’s `/api/versioncheck/v4` flags the same builds, so dropping a build from the', 'list is how its players are made to upgrade. Grants posting no `ver` are untouched.', '', '**Rollout gate.** A grant whose `ver` names a build under staged rollout', '(`ROLLOUT_GAME_VERSIONS`, by default `20260323` — every point release of it; `off` ends', 'the rollout and opens the build to everyone) is refused as `invalid_grant` "this build is limited to', 'Discord supporters" unless the account is staff (developer or moderator) or its', 'Discord link (made by the website’s benefits claim) holds one of the roles in the', 'operator’s `DISCORD_ROLLOUT_ROLE_IDS`. With none configured the gate is closed to', 'everyone but staff. `create_account` on such a', 'build is refused before it mints anything, since a new account has no Discord link.', 'Grants posting no `ver` (a refresh, the website) and every other build are untouched.', ].join('\n'), requestBody: form( TokenRequest, 'Union of all grants; see the description for per-grant requirements' ), responses: { 200: json(TokenResponse, 'Access token, refresh token and granted scopes'), 400: json( OAuthError, [ 'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an', 'invalid/expired refresh token, a missing account identifier, a signup cap reached,', 'or an account sharing a banned one’s device or network', ].join(' ') ), 500: json( OAuthError, [ 'The server is missing a secret it cannot proceed without: JWT_SECRET (a token is', 'refused rather than signed with an empty key) or, on a Meta login, META_APP_SECRET', '(no nonce can be validated without it).', ].join(' ') ), }, }), async (c) => { // Reads `grant_type`, `account_id`, `platform_id` and `platform` from the // form body. const body = await c.req.parseBody().catch(() => ({}) as Record) const grantType = typeof body.grant_type === 'string' ? body.grant_type : '' // Studio's poll. Handled before the password fallback, which would otherwise // treat this grant_type as a username/password login and 400 it. if (grantType === DEVICE_CODE_GRANT) { if (!studioClientMatches(body)) { return oauthError(c, 'invalid_client', 'unknown studio client') } const deviceCode = typeof body.device_code === 'string' ? body.device_code : '' const polled = await pollDeviceGrant(c.env.DB, deviceCode) if (polled.kind === 'pending') { return oauthError( c, 'authorization_pending', 'the player has not approved this login yet' ) } if (polled.kind === 'denied') { return oauthError(c, 'access_denied', 'the player denied this login') } if (polled.kind === 'expired') { return oauthError(c, 'expired_token', 'the device code expired') } if (polled.kind !== 'approved') { return oauthError(c, 'invalid_grant', 'unknown device code') } return issueSession(c, polled.accountId, DEVICE_CODE_GRANT) } // The website's Discord sign-in, likewise before the fallback — and before the // version gates, which it never posts a `ver` for. See identityAssertionGrant. if (grantType === IDENTITY_ASSERTION_GRANT) return identityAssertionGrant(c, body) // `platform`/`platform_id` come from the body for a fresh login; a refresh // grant overrides them below with what was stored when the token was issued. let platformId = typeof body.platform_id === 'string' ? body.platform_id : '' const platformInt = typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN // The token's `platform` claim is the PlatformType int. A grant that asserts no // platform (a password login) falls back to Steam/0, the same default the account // itself carries — see `accountPlatform`. let platform = Number.isNaN(platformInt) ? PlatformType.Steam : platformInt // The device this login came from. The client posts both on every grant; they're // unverified (client-picked) so they're recorded on the account, never trusted as // a credential. Stored on account creation AND refreshed on each successful login, // so the account's device tracks the player across devices — the raw material for // linking accounts that share a device later. const deviceId = typeof body.device_id === 'string' ? body.device_id : '' const deviceClassInt = typeof body.device_class === 'string' ? Number.parseInt(body.device_class, 10) : NaN const deviceClass = Number.isNaN(deviceClassInt) ? 0 : deviceClassInt // The client's own build (`ver`, e.g. `20250718.01`), stamped into the token's // `rn.ver` claim so everything downstream reports the build the player is ACTUALLY // running rather than this server's GAME_VERSION — `match` reads it back off the // token when it writes presence. Unverified like the device fields: echoed, and // gated (below, and the rollout gate further down), but never trusted as a // credential. // // A grant that posts none — a refresh, or a caller that isn't the game — leaves it // undefined and generateToken falls back to GAME_VERSION. An empty string is // treated as absent for the same reason: presence must never carry an empty // version, which breaks the client's handling of it. const version = typeof body.ver === 'string' && body.ver !== '' ? body.ver : undefined // The client's real IP, per Cloudflare (the client can't spoof CF-Connecting-IP — // the edge sets it — unlike X-Forwarded-For, which is why we don't read that). // Recorded as the immutable `signupIp` at creation and as `lastLoginIp` on every // login; both feed the per-IP signup cap. Absent (empty) outside the CF edge. const clientIp = c.req.header('cf-connecting-ip') ?? '' // The support gate (see UNSUPPORTED_BUILD_DESCRIPTION): a posted build this server // doesn't serve gets no token, whatever the grant and whoever the account. Decided // before any credential is read — it depends on nothing but the operator's list, so // there is nothing about an account to probe here, and a closed door costs no D1 // round trip. The same list `api`'s `/api/versioncheck/v4` flags for an update. if (version !== undefined && !isSupportedGameVersion(version, supportedGameBuilds(c.env))) { logger.info('token refused: game version is not supported', { grantType, version, ip: clientIp, }) return c.json( { error: 'invalid_grant', error_description: UNSUPPORTED_BUILD_DESCRIPTION }, 400 ) } // A platform-authenticated login proves who you are with the platform itself, and // we can verify exactly two: Steam (0), from its Steam-signed platform_auth ticket, // and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth (see // verifyPlatformProof). Only a verified identity is ever bound or linked. // // Two grants are GATED on it — they have no other credential, so an unverifiable // platform is fatal: // - cached_login authenticates purely by platform identity. // - create_account that asserts a platform: we won't bind an identity we can't // prove. (create_account with NO platform is the password-account path — // binds no platformId, and is open only while PASSWORD_SIGNUP says so.) // // A password grant is NOT gated: the password already proved who it is. It posts // its platform proof too, and if that verifies we LINK the identity to the account // (see below), which is how a player who created an account on Steam gets a cached // login on their headset. If it doesn't verify, the login still succeeds — it just // links nothing, because a link is a password-free way into the account and must // never rest on an unproven id. const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : '' const platformAsserted = !Number.isNaN(platformInt) const gatedOnPlatform = grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted) // The password grant only spends a verification when the client actually offered // one; the rest of the time there is nothing to link. const proof: PlatformProof = gatedOnPlatform || (platformAsserted && platformAuth !== '') ? await verifyPlatformProof(c.env, platformInt, platformAuth, platformId) : { status: 'none' } let verifiedPlatformId: string | null = null let verifiedPlatform: number | null = null if (proof.status === 'verified') { verifiedPlatform = proof.platform verifiedPlatformId = proof.platformId } else if (proof.status !== 'none') { // Log every failure, including the ones a password grant shrugs off: a player // who silently never gets a cached login on their headset has no other symptom, // and this line is where "Meta rejected the nonce" becomes visible. logger.info('platform_auth not verified', { platform: platformInt, platformId, grantType, status: proof.status, reason: proof.status === 'rejected' ? proof.reason : undefined, }) } if (gatedOnPlatform && proof.status !== 'verified') { if (proof.status === 'unsupported') { return c.json( { error: 'invalid_grant', error_description: 'unsupported platform; only Steam and Meta can be verified', }, 400 ) } if (proof.status === 'unconfigured') { // An operator misconfiguration, not the client's fault: without the app secret // every Meta player is locked out, so it answers 500 the way an unset // JWT_SECRET does below rather than blaming the credential. (We never fall // back to trusting the posted id — that would let anyone log into any // Meta-linked account by naming its user id.) logger.error('refusing a Meta login: META_APP_SECRET is empty') return c.json( { error: 'server_error', error_description: 'Meta platform verification is not configured', }, 500 ) } // The reason is for the operator; the client is told only that it was rejected. // A wrong app secret and a stale nonce look identical from the client side. return c.json( { error: 'invalid_grant', error_description: 'invalid or missing platform_auth' }, 400 ) } // From here on `platformId` is the PROVEN identity wherever there is one — the // SteamID64 out of the ticket or the Meta user id the nonce validated against, // never the raw client-supplied field. if (verifiedPlatformId !== null) platformId = verifiedPlatformId // Resolve the account this token is for: // - create_account: mint + persist a brand-new account (auto-assigned random // username — players don't pick one initially); the token's `sub` is its id. // A `password` may be posted to establish the account's login credential. // - refresh_token: redeem a stored (single-use) refresh token for its account + // platform, so an expiring session renews without re-login. // - otherwise: a credential login. The request identifies the account by // `username` (RecRoom's password grant posts the username, not the id) or a // numeric `account_id`, and MUST post the account's correct `password`. An // account with no password set can't be logged into (no credential to verify) // — closing the id/username-only takeover. New accounts establish a password // via create_account or /account/me/changepassword. let accountId: string if (grantType === 'create_account') { // A rollout-gated build can't sign UP: the account it would mint has no Discord // link, so it could never hold the role the gate wants. Refused before anything // is read or minted, like the switch below — no row left behind, no cap slot // burned. if (isRolloutGameVersion(version, rolloutGameBuilds(c.env))) { logger.info('signup refused: build is limited to Discord supporters', { version, ip: clientIp, }) return c.json( { error: 'invalid_grant', error_description: ROLLOUT_GATED_DESCRIPTION }, 400 ) } // The password-account path (no platform) is a switch, off by default: see // DEFAULT_PASSWORD_SIGNUP. Checked first, before anything is read or minted — // a closed door costs no D1 round trip and burns no cap slot. if (!platformAsserted && !flagVar(c.env.PASSWORD_SIGNUP, DEFAULT_PASSWORD_SIGNUP)) { logger.info('signup refused: password signup is disabled', { ip: clientIp }) return c.json( { error: 'invalid_grant', error_description: PASSWORD_SIGNUP_DISABLED_DESCRIPTION }, 400 ) } // A banned player's next move is a new account, so the evasion arms are checked // BEFORE one is minted — against the only identity a signup has, the IP it came // from and the platform identity it just proved. Refusing after the fact (as the // shared check below would) still refuses the token, but leaves the account // row behind and burns a slot off both signup caps, so the evader gets to keep // making them. // // Nothing here can match the account arm (there is no account yet), so this is // purely the linked matching, and BAN_EVASION_MATCH=off leaves signup open — // which is the honest default position: a server that won't accept the IP arm's // false positives is choosing to let evaders re-register. const blocked = await resolveBan(c.env.DB, null, { identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId, }, arms: banEvasionMatch(c.env.BAN_EVASION_MATCH), }) if (blocked) { logger.info('signup refused: player banned', { via: blocked.via, bannedAccountId: blocked.bannedAccountId, ip: clientIp, platformId: verifiedPlatformId, }) return c.json({ error: 'invalid_grant', error_description: BLOCKED_DESCRIPTION }, 400) } // Signup caps. Checked before minting anything, so a rejected signup leaves no // account behind. Each arm is skipped when it's disabled (var <= 0) or when its // identity is unknown (no verified platform id / no client IP / no device id) — an unattributable // signup can't be counted against anyone, and lumping them together would lock out // real players. The disabled check comes first so a disabled arm costs no D1 read. const maxPerPlatformId = intVar( c.env.MAX_ACCOUNTS_PER_PLATFORM_ID, DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID ) const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP) const maxPerDeviceId = intVar( c.env.MAX_ACCOUNTS_PER_DEVICE_ID, DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID ) if ( maxPerPlatformId > 0 && verifiedPlatformId !== null && (await countAccountsForPlatformIdentity( c.env.DB, verifiedPlatform ?? 0, verifiedPlatformId )) >= maxPerPlatformId ) { logger.info('signup rejected: platform account limit', { platformId: verifiedPlatformId, }) return c.json( { error: 'invalid_grant', error_description: 'account limit reached for this platform account', }, 400 ) } if ( maxPerIp > 0 && clientIp !== '' && (await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp ) { logger.info('signup rejected: per-IP account limit', { ip: clientIp }) return c.json( { error: 'invalid_grant', error_description: 'too many accounts created from this network', }, 400 ) } if ( maxPerDeviceId > 0 && deviceId !== '' && (await countAccountsByDeviceId(c.env.DB, deviceId)) >= maxPerDeviceId ) { logger.info('signup rejected: per-device account limit', { deviceId, ip: clientIp }) return c.json( { error: 'invalid_grant', error_description: 'too many accounts created from this device', }, 400 ) } // Bind the platform identity ONLY when the platform proved it (a Steam ticket or // a Meta-validated nonce). A password/anonymous create_account (no platform) // binds nothing. The account blob keeps this first identity as its PRIMARY one // (for the account DTO and the refresh grant's claims); the link written just // below is what a later cached login is actually authorized against. const account = await createAccount(c.env.DB, { platforms: platformInt || 0, platform: verifiedPlatform ?? undefined, platformId: verifiedPlatformId ?? undefined, lastLoginTime: new Date().toISOString(), deviceId: deviceId || undefined, deviceClass: deviceId ? deviceClass : undefined, signupIp: clientIp || undefined, lastLoginIp: clientIp || undefined, }) accountId = String(account.accountId) if (verifiedPlatformId !== null) { await linkPlatformIdentity( c.env.DB, account.accountId, verifiedPlatform ?? 0, verifiedPlatformId ) } // Establish the login password when one is posted (raw password never stored). const password = typeof body.password === 'string' ? body.password : '' if (password !== '') { await setPasswordHash(c.env.DB, account.accountId, await hashPassword(password)) } // Place the new player in Orientation (they don't explicitly matchmake into it). await placeNewPlayerInOrientation(c.env, account.accountId, deviceClass) } else if (grantType === 'refresh_token') { const presented = typeof body.refresh_token === 'string' ? body.refresh_token : '' const refreshed = presented ? await consumeRefreshToken(c.env.DB, presented) : null if (!refreshed) { return c.json( { error: 'invalid_grant', error_description: 'refresh_token is invalid or expired' }, 400 ) } // `platform`/`platform_id` aren't stored with the token — they're taken from // the account below, so a refreshed token always reflects the identity the // account is bound to now. accountId = String(refreshed) } else if (grantType === 'cached_login') { // Platform-authenticated login into an already-linked account. The client posts // the `account_id` it got from /cachedlogin/forplatformid together with the // `platform_id` its platform_auth vouches for. Authorize ONLY when the link // table says that account is linked to exactly this platform identity — this is // the check that keeps anyone but that platform user out of the account // (platform ownership is the credential; no password needed). An account with no // link for the presented identity must use a password. // // The picker lists straight from the same table, so it can only offer accounts // this check accepts. // // NB: `platform_id` here is the verified identity set above — the SteamID64 from // the ticket, or the Meta user id the nonce validated against — never the raw // client-supplied field. See steam-ticket.ts and meta-nonce.ts. // const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : '' const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null const linked = account !== null && (await isPlatformIdentityLinked(c.env.DB, account.accountId, platformInt, platformId)) if (!account || !linked) { return c.json( { error: 'invalid_grant', error_description: 'no linked account for this platform identity', }, 400 ) } accountId = String(account.accountId) await setLastLoginTime(c.env.DB, account.accountId, new Date().toISOString()) await setLoginContext(c.env.DB, account.accountId, { deviceId, deviceClass, ip: clientIp }) } else { // Resolve the account from a posted numeric `account_id` or, as RecRoom's // password grant sends, a `username` (case-insensitive; trailing whitespace // is trimmed off the posted value). const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : '' const postedUsername = typeof body.username === 'string' ? body.username.trim() : '' let resolvedId: number | null = null if (/^\d+$/.test(postedId)) { resolvedId = Number(postedId) } else if (postedUsername !== '') { resolvedId = (await getAccountByUsername(c.env.DB, postedUsername))?.accountId ?? null } if (resolvedId === null) { return c.json( { error: 'invalid_request', error_description: 'account_id or username is required' }, 400 ) } // The account's password MUST be presented and match. An account with no // stored hash has no credential to authenticate against, so login is refused // — this closes the id/username-only takeover. const storedHash = await getPasswordHash(c.env.DB, resolvedId) const password = typeof body.password === 'string' ? body.password : '' if (!storedHash || !(await verifyPassword(password, storedHash))) { return c.json( { error: 'invalid_grant', error_description: 'invalid account_id or password' }, 400 ) } accountId = String(resolvedId) // The password proved the account; the platform proof (when the client sent one // and it verified) proves the device's platform identity. Linking the two is // what gives a player who signed up on Steam a cached login on their headset — // they type their password once there, and never again. if (verifiedPlatformId !== null) { await linkLoginIdentity( c.env.DB, resolvedId, verifiedPlatform ?? 0, verifiedPlatformId, intVar(c.env.MAX_ACCOUNTS_PER_PLATFORM_ID, DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID) ) } await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString()) await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp }) } // The rollout gate: a build under staged rollout (`rolloutGameBuilds`) is for staff // and Discord supporters only, so the account signing in on it must be staff or hold // one of the configured roles. // Deliberately AFTER the credential checks, like the ban lookup below — a wrong // password on a gated build is still "invalid account_id or password", so the // gate can't be used to probe whether an account exists. Unlike a ban, this IS a // refusal: there is no block screen to reach, and the client surfaces the // description on its sign-in failure. if (isRolloutGameVersion(version, rolloutGameBuilds(c.env))) { const roleIds = rolloutRoleIds(c.env.DISCORD_ROLLOUT_ROLE_IDS) if (!(await accountMayUseRolloutBuild(c.env.DB, Number(accountId), roleIds))) { logger.info('login refused: build is limited to Discord supporters', { accountId, grantType, version, // The COUNT, not the ids: zero here means the gate is closed because // nothing is configured, which reads very differently from "not a // supporter". roleIdCount: roleIds.length, }) return c.json( { error: 'invalid_grant', error_description: ROLLOUT_GATED_DESCRIPTION }, 400 ) } } // EVERY account gets its token, banned or not — a ban of its own, or a link to // somebody else's through the evasion arms. The client needs one to reach `api`'s // moderationBlockDetails, which is where a banned player is TOLD they are banned // (category, time left, "Rule violation"); refused here they would only ever see a // sign-in that fails, with nothing saying why. The ban is enforced by `match`, which // refuses every matchmake but the caller's own dorm, so a token gets them as far as // the block screen and no further. // // That includes ban EVASION, which this used to refuse. An evader signing in lands // in their dorm and can enter no room — the same place the ban leaves them — and // refusing the token instead only ever produced a client stuck on a failed login. // `create_account` is still refused before it mints anything (above): that is making // a NEW account to evade with, which is a different act from signing into one. // // Logged either way, and the arm is logged with it: "banned" and "shares a network // with somebody banned" are very different things to be reading in a log. // // Deliberately AFTER the credential checks — a wrong password is still "invalid // account_id or password", so this can't be used to probe whether an account exists // or is banned without knowing it. // // The request's own IP and proven identity are passed alongside the account, so a // ban also reaches an old, clean account logged into from the banned player's device // or network — the stored ips alone would only catch that on the SECOND login. const ban = await resolveBan(c.env.DB, Number(accountId), { identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId }, arms: banEvasionMatch(c.env.BAN_EVASION_MATCH), }) if (ban) { logger.info('token issued to a blocked account', { accountId, grantType, via: ban.via, bannedAccountId: ban.bannedAccountId, reportId: ban.ban.id, banExpires: ban.ban.ban_expires, }) } // Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing // binding) would still yield a well-formed token — but one signed with an empty // key, which every worker validates against, so anyone could forge it. Refuse to // issue a token at all rather than complete the login with a forgeable credential. const jwtSecret = await c.env.JWT_SECRET.get() if (jwtSecret === '') { logger.error('refusing to issue token: JWT_SECRET is empty') return c.json( { error: 'server_error', error_description: 'token signing is not configured' }, 500 ) } // Stamp the account's elevated roles into the token's `role` claim so the client // authorizes developer/moderator powers from the token itself (not just the // /role/* lookups), and its Plus flag into `rn.plus`. One read of the just-resolved // account serves both; they thus refresh on every login and every refresh_token // grant. const roleAccount = await getAccount(c.env.DB, Number(accountId)) // A refresh grant posts no platform of its own, so the identity comes off the // account — the same read, and the only place the bound identity is authoritative. if (grantType === 'refresh_token' && roleAccount) { platform = accountPlatform(roleAccount) platformId = roleAccount.platformId ?? '' } const accessToken = await generateToken( accountId, platformId, platform, jwtSecret, accountRoles( roleAccount, await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT) ), accountPrivileges(roleAccount), version, // Rec Room Plus, off the same account read as the roles above — `econ` decides the // CampusCard and the subscriber discount from this claim alone, so it never has to // load the account. It therefore refreshes on every login and every refresh_token // grant, and only then: a player who claims Plus on the website keeps a token that // says otherwise until they sign in again. roleAccount?.hasPlus === true ) // Issue a fresh, persisted refresh token (single-use; the client redeems it via // grant_type=refresh_token). A refresh grant thus rotates its token. const refreshToken = await issueRefreshToken(c.env.DB, Number(accountId)) // A STAFF sign-in is an audit event in its own right. What these accounts then do is // recorded against them on the `audit_log` table (`notify` files every `/internal/*` // call there); this is the row saying the session those actions came from began — and // from what device, build and IP, which is the first thing anyone asks when an action // further down the log looks wrong. The flags themselves are the trigger, so an account // granted the role between logins starts being recorded the next time it signs in. // // A real sign-in only: `refresh_token` renews the SAME session every TOKEN_TTL_SECONDS, // and recording those would bury the logins under hourly repeats that say nothing new // about who came in or from where. // // Written after the token is minted, so the row means a login that actually happened, // and caught rather than awaited into the response: a failed audit insert must not turn // a staffer's successful sign-in into a 500. It leaves a loud line in the worker log // instead — the one thing a gap in an audit trail should never do quietly. const staffRoles = roleAccount && grantType !== 'refresh_token' ? (['developer', 'moderator'] as const).filter((role) => holdsRole(roleAccount, role)) : [] if (staffRoles.length > 0) { try { await writeAuditLog(c.env.DB, { playerId: Number(accountId), action: 'staff_login', // The IP leads: it is the first thing anyone asks of a staff sign-in, and the one // field here that says WHERE the session came from rather than what it could do. // Empty outside the Cloudflare edge — recorded as the empty string it is rather // than omitted, so a row can't be read as "we didn't record one". // // Then: which powers the session carries, HOW they signed in, and the same // unverified-but-recorded device/build context the account row keeps — kept here // too so the row stands on its own months later, after the account's // `lastLoginIp` and device have been overwritten by every login since. data: { ip: clientIp, roles: staffRoles, grantType, username: roleAccount?.username ?? null, deviceId, deviceClass, version: version ?? null, }, }) } catch (err) { logger.error('could not write a staff_login audit log row', { accountId, grantType, error: err instanceof Error ? err.message : String(err), }) } } return c.json({ access_token: accessToken, expires_in: TOKEN_TTL_SECONDS, token_type: 'Bearer', refresh_token: refreshToken, scope: TOKEN_SCOPE, // @kludge Why is this necessary? Who knows. key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=', }) } ) // Change the caller's password. Auth-gated. Stores a PBKDF2 hash on the account // row (the raw password is never persisted). When the account already has a // password, `oldPassword` must match; the first time it's set, `oldPassword` is // empty (as the client sends). .post( '/account/me/changepassword', describeRoute({ tags: ['Account'], summary: "Change the caller's password", description: [ 'Stores a PBKDF2 hash on the account row; the raw password is never persisted.', 'When the account already has a password, `oldPassword` must match. The first time', 'a password is set, `oldPassword` is empty — which is what the client sends.', ].join(' '), security: [{ bearerAuth: [] }], requestBody: form(ChangePasswordRequest, 'New password, plus the old one when one is set'), responses: { 200: json(ChangePasswordResponse, 'Password changed'), 400: json(ChangePasswordResponse, '`newPassword` was empty, or `oldPassword` was wrong'), 401: { description: 'Missing or invalid bearer token (empty body)' }, 404: { description: 'The account no longer exists (empty body)' }, }, }), async (c) => { const id = await authedId(c) if (id === null) return c.body(null, 401) const body = await c.req.parseBody().catch(() => ({}) as Record) const oldPassword = typeof body.oldPassword === 'string' ? body.oldPassword : '' const newPassword = typeof body.newPassword === 'string' ? body.newPassword : '' if (newPassword === '') { return c.json({ success: false, error: 'You must enter a new password.' }, 400) } const currentHash = await getPasswordHash(c.env.DB, id) if (currentHash && !(await verifyPassword(oldPassword, currentHash))) { return c.json({ success: false, error: 'Your old password is incorrect.' }, 400) } const ok = await setPasswordHash(c.env.DB, id, await hashPassword(newPassword)) if (!ok) return c.body(null, 404) return c.json({ success: true }) } ) // "Forgot password" — the game posts the address on the account and nothing else, so the // address is how the account is found (which is why it is unique across accounts). A // six-digit code is emailed, typed out and prefilled into a link to the website's // `/reset-password` page, where the new password is chosen (`/account/resetpassword` // below): the game has no screen to finish a recovery on. // // Unauthenticated, and so answered the same way whether or not the address is on an // account: `Success: true`, "we've sent an email if there is one". The route is otherwise // a way to ask which addresses have accounts here. The refusals it does voice are the // ones the player can act on — no address, a malformed one, the mail not going out — // in the client's PascalCase `{ Success, Error, error_id }` envelope, which is the one // shape it has been seen to read for this route. See @repo/domain's email-code-db.ts for the code's // rules (one attempt, a cooldown on resends, a 30-minute life). .post( '/account/recoverpassword', describeRoute({ tags: ['Account'], summary: 'Email a password-recovery code', description: [ 'Emails a six-digit code to the address, with a link to the website’s reset page', '(`/reset-password?email=…&code=…`) where the new password is chosen via', '`POST /account/resetpassword`. Answers `Success: true` whether or not the address', 'is on an account, so the route can’t be used to find out which addresses are.', 'A new request replaces the pending code; one is sent per address at most every', 'two minutes. The code works once: a wrong guess voids it and the recovery has to', 'be requested again. Refusals — no address, a malformed one, the email failing to', 'send — are `Success: false` with the reason in `Error`.', ].join(' '), requestBody: form(RecoverPasswordRequest, 'The address on the account'), responses: { 200: json(RecoverPasswordResponse, 'Sent, or nothing to send to; `Error` says if not'), }, }), async (c) => { const body = await c.req.parseBody().catch(() => ({}) as Record) const email = typeof body.email === 'string' ? body.email.trim() : '' const refuse = (error: string) => c.json({ Success: false, Error: error, error_id: null }) if (email === '') return refuse('Enter the email address on your account.') if (!isValidEmail(email)) return refuse("That doesn't look like an email address.") // Counted before the lookup, so a run through unknown addresses spends it too. const clientIp = c.req.header('cf-connecting-ip') ?? '' if (clientIp !== '') { const ip = await takeRateLimit( c.env.DB, `recoverpassword:ip:${clientIp}`, RECOVERY_REQUESTS_PER_IP, RECOVERY_REQUESTS_PER_IP_WINDOW_SECONDS ) if (!ip.allowed) { logger.warn('password recovery rate-limited by ip; not sent', { count: ip.count }) return c.json({ Success: true, Error: null, error_id: null }) } } const account = await getAccountByEmail(c.env.DB, email) if (account === null) { // Answered exactly like a sent email, see above. Logged, since a run of these // from one place is somebody probing. logger.info('password recovery requested for an address on no account') return c.json({ Success: true, Error: null, error_id: null }) } const issue = await beginEmailCode(c.env.DB, 'password_reset', account.accountId) if (issue.refused) { // Within the cooldown, or the account's codes for the day are spent: answered // like a sent email either way, so a loop learns nothing from the refusal. logger.info('password recovery not resent', { accountId: account.accountId, refused: issue.refused, }) return c.json({ Success: true, Error: null, error_id: null }) } const code = issue.code const message = recoveryEmail({ // The stored address, not the posted one: same account either way (the lookup // is case-insensitive), and the stored one is the one the player chose to be // reached at. to: account.email ?? email, from: emailSender(c.env), domain: c.env.DOMAIN, www: buildEndpoints(c.env.DOMAIN, c.env.SUBDOMAINS).WWW, code, validMinutes: Math.round(EMAIL_CODE_TTL_SECONDS / 60), }) try { await c.env.EMAIL.send(message) } catch (err) { // Told to the player: they would otherwise wait on an email that is never // coming. The usual cause is the sender's domain not being enabled for Email // Sending (see DEPLOYING.md), which is an operator's problem, hence the log. logger.error('could not send the password recovery email', { accountId: account.accountId, error: err instanceof Error ? err.message : String(err), }) return refuse('The recovery email could not be sent. Please try again later.') } logger.info('password recovery email sent', { accountId: account.accountId }) return c.json({ Success: true, Error: null, error_id: null }) } ) // The other half: the website's `/reset-password` page posts the address, the code from // the email and the new password. Unauthenticated — the code IS the credential. A wrong // code voids the pending recovery (one guess per email), and the refusal reads the same // whether the code was wrong, lapsed or never issued, so nothing here says whether a // recovery is in flight for an address. Success sets the password and signs the account // out everywhere else by revoking its refresh tokens. This is our own route, not the // client's: the game never calls it, so it answers in the `{ success, error }` shape the // website's other forms read. .post( '/account/resetpassword', describeRoute({ tags: ['Account'], summary: 'Finish a password recovery with the emailed code', description: [ 'Sets the password of the account under `email` when `code` is the one its recovery', 'email carried. Unauthenticated; the code is the credential, and it works ONCE — a', 'wrong code voids the recovery, and `POST /account/recoverpassword` has to be posted', 'again for a new one. The refusal is the same sentence whether the code was wrong,', 'has lapsed (30 minutes) or was never issued. On success the account’s refresh', 'tokens are revoked, so other sessions sign out. Posted by the website’s', '`/reset-password` page, never by the game.', ].join(' '), requestBody: form(ResetPasswordRequest, 'Address, emailed code and the new password'), responses: { 200: json(ChangePasswordResponse, 'Password set'), 400: json( ChangePasswordResponse, 'Missing field, or the code is wrong, lapsed or not issued (`error` says which, if the player can act on it)' ), }, }), async (c) => { const body = await c.req.parseBody().catch(() => ({}) as Record) const email = typeof body.email === 'string' ? body.email.trim() : '' const code = typeof body.code === 'string' ? body.code.trim() : '' const newPassword = typeof body.newPassword === 'string' ? body.newPassword : '' const refuse = (error: string) => c.json({ success: false, error }, 400) if (email === '') return refuse('Enter the email address the code was sent to.') if (code === '') return refuse('Enter the code from the email.') if (newPassword === '') return refuse('You must enter a new password.') const WRONG_CODE = 'That code is wrong or has expired. Request a new recovery email and try again.' const account = await getAccountByEmail(c.env.DB, email) if (account === null) { logger.info('password reset posted for an address on no account') return refuse(WRONG_CODE) } const { outcome } = await completeEmailCode( c.env.DB, 'password_reset', account.accountId, code ) if (outcome !== 'ok') { logger.info('password reset refused', { accountId: account.accountId, outcome }) return refuse(WRONG_CODE) } const ok = await setPasswordHash(c.env.DB, account.accountId, await hashPassword(newPassword)) if (!ok) return c.body(null, 404) const revoked = await revokeRefreshTokens(c.env.DB, account.accountId) logger.info('password reset by email', { accountId: account.accountId, revoked }) return c.json({ success: true }) } ) // The moderation restrictions on the caller's account — chat mutes and the like. STUB: // nothing here issues restrictions, so every caller is unrestricted and the list is // empty. An EMPTY ARRAY is the normal unrestricted answer, not null and not a 404: the // client clears and refills its list from this, and acts on a record simply being // present (plus its `EndDate`), so `[]` is a complete answer rather than a placeholder. // // When this is wired up, the display strings are free text — the client matches none of // them — so only `EndDate` and the record's presence need to be right. See // `RestrictionDto`. .get( '/privileges/me/restrictions', describeRoute({ tags: ['Account'], summary: 'The caller’s moderation restrictions', description: [ 'The restrictions in force on the caller’s account (a chat mute, say), as a bare array.', 'Always EMPTY here — nothing on this server issues restrictions — and an empty array is', 'the normal unrestricted answer, not null. The client refills its list from this and', 'acts on a record being present and its `EndDate`; the `Name`/`Description`/', '`DisplayReason` strings are display text it matches nothing against.', ].join(' '), security: [{ bearerAuth: [] }], responses: { 200: json(RestrictionDto.array(), 'The caller’s restrictions — always empty'), 401: { description: 'Missing or invalid bearer token (empty body)' }, }, }), async (c) => { const id = await authedId(c) if (id === null) return c.body(null, 401) return c.json([]) } ) // Developer role lookup. Returns a bare JSON boolean (the client reads the body as // a bool), and 404s for an unknown player — mirroring the reference API. The role // is off by default and only an operator grants it (via `runx admin grant-developer`, // which sets the account's isDeveloper flag); it also rides in the token's `role` // claim (see accountRoles). .get('/role/developer/:id', describeRoute(roleLookup('developer')), (c) => roleAnswer(c, 'developer', c.req.param('id')) ) // The BULK form — `/role/developer?id=2&id=3` answers WHICH of those players are developers, // as an array of ints. A route of its own rather than an optional `:id?` segment, and a // different answer shape from the single lookup above: both are kept because the client asks // both ways (the path form is the 2023 one). .get('/role/developer', describeRoute(roleFilter('developer')), (c) => roleFilterAnswer(c, 'developer') ) // Moderator role lookup, mirroring developer (bare boolean, 404 for unknown player). // Operator-granted only (via `runx admin grant-moderator`); the flag also rides in // the token's `role` claim. .get('/role/moderator/:id', describeRoute(roleLookup('moderator')), (c) => roleAnswer(c, 'moderator', c.req.param('id')) ) // Moderator's bulk form, kept in step with developer's above. .get('/role/moderator', describeRoute(roleFilter('moderator')), (c) => roleFilterAnswer(c, 'moderator') ) // Studio queries each room owner's screenshare role before validating video players. .get('/role/screenshare/:id', describeRoute(roleLookup('screenshare')), (c) => roleAnswer(c, 'screenshare', c.req.param('id')) ) // @guess Oculus nonce. The client asks for this before a Meta login; the exact shape // it expects hasn't been observed, so this mints a fresh 64-char hex nonce (the length // Meta's own `GetUserProof` nonces have) and answers it as a bare JSON string. Nothing // is stored — Meta's nonce validation (meta-nonce.ts) is what actually proves a login, // so this value is not security-relevant to the server. Revisit once the client's use // of it is seen. .get( '/oculus/nonce', describeRoute({ tags: ['Account'], summary: 'A fresh nonce for the Oculus login flow', description: [ 'Mints a random 64-char hex nonce and returns it as a bare JSON string. Not stored', 'and not verified later — a best guess at the shape the client wants.', ].join(' '), responses: { 200: json(z.string(), 'The nonce') }, }), (c) => { const bytes = crypto.getRandomValues(new Uint8Array(32)) const nonce = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') logger.info('oculus nonce issued') return c.json(nonce) } ) // The generated spec. Documentation only — no request is validated against it (see // openapi.ts). `hide: true` keeps this route out of its own output. app.get( '/openapi.json', describeRoute({ hide: true }), withCleanSpec( openAPIRouteHandler(app, { documentation: { info: { title: 'recflare auth', version: '1.0.0', description: [ 'Authentication and token issuance for recflare, a private-server reimplementation', 'of the Rec Room backend.', ].join('\n'), }, servers: [{ url: 'https://auth.recflare.net', description: 'Production' }], components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'An `access_token` from `POST /connect/token`.', }, }, }, }, }) ) ) export default app