From 355b459dc903244c70bf34d39667446d71f57db1 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 20 Jul 2026 15:32:59 -0400 Subject: [PATCH] beta openapi docs --- apps/auth/README.md | 117 +++- apps/auth/package.json | 7 +- apps/auth/src/auth.app.ts | 755 +++++++++++++-------- apps/auth/src/openapi.ts | 184 +++++ apps/auth/src/test/integration/api.test.ts | 37 + pnpm-lock.yaml | 132 ++++ 6 files changed, 925 insertions(+), 307 deletions(-) create mode 100644 apps/auth/src/openapi.ts diff --git a/apps/auth/README.md b/apps/auth/README.md index 086fa9e..e46be8d 100644 --- a/apps/auth/README.md +++ b/apps/auth/README.md @@ -1,26 +1,105 @@ # auth -Auth Worker served on the `auth` subdomain. A Hono app handling authentication. -Binding-dependent behavior (database queries) is stubbed for now — no real -KV/D1/DO bindings yet. +Auth Worker served on the `auth` subdomain (`auth.recflare.net`) — a Hono app that +authenticates players and issues the JWTs every other worker verifies. ## Routes -| Method | Path | Description | -| ------ | ------------------------------------------ | ---------------------------------- | -| GET | `/eac/challenge` | EAC challenge, served as text | -| GET | `/cachedlogin/forplatformid/:platform/:id` | Cached logins (stubbed → `[]`) | -| POST | `/connect/token` | OAuth token endpoint, issues a JWT | -| GET | `/role/developer/:id` | Developer role lookup (TODO) | +| Method | Path | Description | +| ------ | ------------------------------------------ | ------------------------------------------------------ | +| GET | `/eac/challenge` | EAC handshake; a constant, JSON-quoted, as text | +| GET | `/cachedlogin/forplatformid/:platform/:id` | Accounts linked to a platform id, for the login screen | +| POST | `/cachedlogin/forplatformids` | Bulk cached-login lookup (friends resolution) | +| POST | `/connect/token` | OAuth token endpoint; issues a JWT + refresh token | +| POST | `/account/me/changepassword` | Change the caller's password (auth-gated) | +| GET | `/role/developer/:id` | Developer role lookup; a bare JSON boolean | +| GET | `/role/moderator/:id` | Moderator role lookup; a bare JSON boolean | +| GET | `/openapi.json` | Generated OpenAPI 3.1 spec (see below) | + +## API documentation + +`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit +alongside each handler, with the schemas in `src/openapi.ts`. + +**The spec is descriptive, not enforced.** Nothing validates requests against it. That +is deliberate: this worker serves a protocol reverse-engineered from the Rec Room +client, and the handlers are intentionally lenient — every field is read as +`typeof body.x === 'string' ? body.x : ''`, and missing or malformed input generally +falls through to a graceful path rather than a 400. Which parts of that tolerance the +client actually depends on isn't fully known, so enforcing a schema would risk +rejecting requests that work today. Read a "required" field as _the client always +sends it_, not _the server rejects it if absent_. + +A test asserts that every route the worker serves appears in the spec, so adding a +route without documenting it fails rather than silently shipping an incomplete spec. + +## Grants + +`POST /connect/token` selects behavior from `grant_type`: + +- **`create_account`** — mints an account with an auto-assigned random username and + places the player in the Orientation room (RoomId 13), which the client enters + without matchmaking. A posted `password` becomes the login credential. +- **`cached_login`** — logs into an already-linked account using platform ownership as + the credential; no password. The posted `account_id` must be linked to exactly the + identity the Steam ticket proves. +- **`refresh_token`** — redeems a stored single-use refresh token, rotating it. + 30-day TTL; platform and platform id come from what was stored at issue time. +- **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies + the account by `username` or numeric `account_id` and requires the matching password + (PBKDF2-SHA256, `salt:hash`). An account with no stored hash cannot be logged into at + all, which is what closes id/username-only takeover. + +Access tokens live for 1 hour (`TOKEN_TTL_SECONDS` in `@repo/jwt`) and carry a `role` +claim, so developer/moderator powers refresh on every login and every refresh grant. +Grant those flags with `runx admin grant-developer` / `grant-moderator`. + +### Steam is the only verifiable platform + +`platform_auth` tickets are verified **offline** — `src/steam-ticket.ts` parses the +ticket and checks Steam's signature against Steam's system public key. No publisher +Web API key, no network call. Steam (platform `0`) is therefore the only platform +whose identity can be proven, so any grant that authenticates _by platform identity_ +(`cached_login`, and `create_account` when it asserts a platform) must be Steam. The +verified SteamID64 replaces the client-supplied `platform_id` and is the only value +ever written to an account's `platformId`. + +## Signup caps + +`create_account` is capped on two independent arms, per verified platform id and per +signup IP. The platform arm can't be spoofed or reset by changing networks; the IP arm +is coarse and will produce false positives behind NAT, shared campus and mobile +networks. Both default to 3. + +Override per environment via the root `.env` (`RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID`, +`RECFLARE_MAX_ACCOUNTS_PER_IP`), injected at deploy time so tuning them never means +editing a versioned file. Setting an arm to `0` disables it — worth reaching for on a +small private server, or when a shared network is being locked out. + +## Bindings + +| Binding | Type | Notes | +| -------------------- | ------------- | ------------------------------------------------------ | +| `DB` | D1 | Shared `recflare` database; this worker owns `account` | +| `JWT_SECRET` | Secrets Store | Shared HS256 signing key | +| `MAX_ACCOUNTS_PER_*` | vars | Optional signup caps; read via `intVar` | + +Migrations live in `migrations/` and are tracked in their own `d1_migrations_auth` +table, so they stay independent of the `rooms` worker's migrations on the same +database. Run them with `pnpm -F auth migrate`. ## Signing key -Tokens are signed HS256 with the `JWT_SECRET` binding (see `src/jwt.ts`), resolved -at request time via `await c.env.JWT_SECRET.get()`. The key lives in a single shared -**Cloudflare Secrets Store** that every worker binds (so `auth`-signed tokens verify -in `rooms`, `api`, `match`, etc.). The store id is kept out of source in the root -`.env` as `RECFLARE_SECRETS_STORE` and spliced into `wrangler.jsonc`'s `"local"` -`store_id` placeholder at deploy time (see `packages/tools/bin/run-wrangler-deploy`). +Tokens are signed HS256 with the `JWT_SECRET` binding (see `@repo/jwt`), resolved at +request time via `await c.env.JWT_SECRET.get()`. The key lives in a single shared +**Cloudflare Secrets Store** that every worker binds, so `auth`-signed tokens verify in +`rooms`, `api`, `match`, etc. The store id is kept out of source in the root `.env` as +`RECFLARE_SECRETS_STORE` and spliced into `wrangler.jsonc`'s `"local"` `store_id` +placeholder at deploy time (see `packages/tools/bin/run-wrangler-deploy`). + +If the secret resolves empty, the worker refuses to issue a token at all rather than +sign one with an empty key — every worker validates against that same key, so an +empty-key token would be forgeable by anyone. One-time setup (needs Cloudflare auth): @@ -39,11 +118,3 @@ wrangler secrets-store secret create local --name JWT_SECRET --value - ``` Rotating the store value invalidates all existing tokens (clients re-authenticate). - -## Notes / TODO - -- `/eac/challenge` content is inlined in `src/auth.app.ts` (Workers have no - filesystem) — replace `EAC_CHALLENGE` with the real challenge text. -- `/cachedlogin/...` and the `RoomInstance` cleanup in `/connect/token` need a DB - binding to be implemented. -- `/role/developer/:id` is a stub (`// TODO: implement`). diff --git a/apps/auth/package.json b/apps/auth/package.json index e665603..ec7bdd1 100644 --- a/apps/auth/package.json +++ b/apps/auth/package.json @@ -19,8 +19,13 @@ "@repo/domain": "workspace:*", "@repo/hono-helpers": "workspace:*", "@repo/jwt": "workspace:*", + "@standard-community/standard-json": "0.3.5", + "@standard-community/standard-openapi": "0.2.9", "hono": "4.12.27", - "workers-tagged-logger": "1.0.1" + "hono-openapi": "1.3.1", + "openapi-types": "12.1.3", + "workers-tagged-logger": "1.0.1", + "zod": "4.4.3" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "0.16.20", diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index cab346b..fe1f6fd 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono' +import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { useWorkersLogger } from 'workers-tagged-logger' import { @@ -20,6 +21,18 @@ import { import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers' import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt' +import { + CachedLogin, + ChangePasswordRequest, + ChangePasswordResponse, + form, + json, + OAuthError, + PlatformIdsRequest, + roleLookup, + TokenRequest, + TokenResponse, +} from './openapi' import { consumeRefreshToken, issueRefreshToken } from './refresh-db' import { verifySteamTicket } from './steam-ticket' @@ -211,323 +224,462 @@ const app = new Hono() .notFound(withNotFound()) // EAC challenge — a fresh GUID, JSON-quoted, served as plain text. - .get('/eac/challenge', (c) => c.text(`"AA=="`)) + .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('/cachedlogin/forplatformid/:platform/:id', async (c) => { - const { platform, id } = c.req.param() - logger.info('cached login lookup', { platform, id }) - const platformInt = Number.parseInt(platform, 10) - const accounts = await getAccountsByPlatformId(c.env.DB, id) - // Offer only accounts the `cached_login` grant will actually accept — same check. - return c.json( - accounts - .filter((a) => Number.isNaN(platformInt) || isLinkedToPlatformIdentity(a, platformInt, id)) - .map(toCachedLogin) - ) - }) + .get( + '/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. ' + + 'Filtered to those a `cached_login` grant would actually accept, so an entry here ' + + 'is always redeemable. An unknown id yields `[]` (not a 404) and the client falls ' + + 'back to a fresh login or create_account.', + parameters: [ + { + name: 'platform', + in: 'path', + required: true, + description: 'PlatformType integer. A non-numeric value disables the link filter.', + schema: { type: 'string' }, + }, + { + name: 'id', + in: 'path', + required: true, + description: 'Platform-native id — a SteamID64 for Steam.', + schema: { type: 'string' }, + }, + ], + responses: { 200: json(CachedLogin.array(), 'Matching accounts; `[]` if none') }, + }), + async (c) => { + const { platform, id } = c.req.param() + logger.info('cached login lookup', { platform, id }) + const platformInt = Number.parseInt(platform, 10) + const accounts = await getAccountsByPlatformId(c.env.DB, id) + // Offer only accounts the `cached_login` grant will actually accept — same check. + return c.json( + accounts + .filter( + (a) => Number.isNaN(platformInt) || isLinkedToPlatformIdentity(a, platformInt, id) + ) + .map(toCachedLogin) + ) + } + ) // 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', 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) { - out.push(...(await getAccountsByPlatformId(c.env.DB, pid)).map(toCachedLogin)) + .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`. Unlike the single-id route, results are NOT filtered to " + + 'redeemable accounts. Unknown ids contribute nothing; a body with no `id` yields `[]`.', + 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) { + out.push(...(await getAccountsByPlatformId(c.env.DB, pid)).map(toCachedLogin)) + } + return c.json(out) } - return c.json(out) - }) + ) // OAuth token endpoint — accepts a form-urlencoded body and issues a JWT. - .post('/connect/token', 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 : '' - // `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 : '' - // `platform` is the PlatformType int → its enum name (e.g. 0 → "Steam"). - const platformInt = typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN - let platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[platformInt] ?? '') + .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 four 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. Subject to two independent signup caps,', + 'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /', + '`MAX_ACCOUNTS_PER_IP`; either disabled by setting it to 0). If it asserts a', + '`platform`, that platform must be Steam and `platform_auth` must verify.', + '', + '**`cached_login`** — logs into an already-linked account using platform ownership as', + 'the credential; no password. Requires a Steam `platform_auth` ticket, and the posted', + '`account_id` must be linked to exactly the identity that ticket proves. An account', + 'with no stored platform 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.', + '', + '**`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.', + '', + '**Platform verification.** Steam (platform `0`) is the only platform that can be', + 'verified, via its signed `platform_auth` ticket, so any grant authenticating by', + 'platform identity must be Steam. The verified SteamID64 replaces the client-supplied', + '`platform_id` and is the only value ever written to an account. Password and refresh', + 'grants carry their own credential and are not gated this way.', + '', + '**Roles.** The token embeds a `role` claim from the account, so developer/moderator', + 'powers refresh on every login and every refresh grant.', + ].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 or non-Steam platform, an ' + + 'invalid/expired refresh token, a missing account identifier, or a signup cap reached' + ), + 500: json( + OAuthError, + 'JWT_SECRET is unset — a token is refused rather than signed with an empty key' + ), + }, + }), + 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 : '' + // `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 : '' + // `platform` is the PlatformType int → its enum name (e.g. 0 → "Steam"). + const platformInt = + typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN + let platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[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 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 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 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') ?? '' - // A platform-authenticated login proves who you are with the platform itself, - // and we can ONLY verify Steam (platform 0) — via its Steam-signed platform_auth - // ticket. So those logins must be Steam: - // - cached_login authenticates purely by platform identity → always Steam-only. - // - create_account that asserts a platform is rejected unless it's Steam, since - // we won't bind an identity we can't prove. (create_account with NO platform - // is the password-account path — allowed, but it binds no platformId.) - // The verified SteamID64 replaces the unauthenticated `platform_id` field and is - // the ONLY value ever written to an account's `platformId`. Credential (password) - // and refresh_token grants carry their own credential and aren't gated here. - let verifiedSteamId: string | null = null - const platformAsserted = !Number.isNaN(platformInt) - if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) { - if (platformInt !== 0) { + // A platform-authenticated login proves who you are with the platform itself, + // and we can ONLY verify Steam (platform 0) — via its Steam-signed platform_auth + // ticket. So those logins must be Steam: + // - cached_login authenticates purely by platform identity → always Steam-only. + // - create_account that asserts a platform is rejected unless it's Steam, since + // we won't bind an identity we can't prove. (create_account with NO platform + // is the password-account path — allowed, but it binds no platformId.) + // The verified SteamID64 replaces the unauthenticated `platform_id` field and is + // the ONLY value ever written to an account's `platformId`. Credential (password) + // and refresh_token grants carry their own credential and aren't gated here. + let verifiedSteamId: string | null = null + const platformAsserted = !Number.isNaN(platformInt) + if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) { + if (platformInt !== 0) { + return c.json( + { + error: 'invalid_grant', + error_description: 'unsupported platform; only Steam can be verified', + }, + 400 + ) + } + const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : '' + const verified = platformAuth ? await verifySteamTicket(platformAuth) : null + if (!verified) { + return c.json( + { + error: 'invalid_grant', + error_description: 'invalid or missing platform_auth ticket', + }, + 400 + ) + } + verifiedSteamId = verified.steamId + platformId = verified.steamId + } + + // 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') { + // 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) — 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) + if ( + maxPerPlatformId > 0 && + verifiedSteamId !== null && + (await countAccountsByPlatformId(c.env.DB, verifiedSteamId)) >= maxPerPlatformId + ) { + logger.info('signup rejected: platform account limit', { platformId: verifiedSteamId }) + 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 + ) + } + + // Bind the platform identity ONLY when a Steam ticket proved it. That bound + // `platformId` (the SteamID64) is what a later cached login is checked against, + // so only this Steam user can log back into the account. A password/anonymous + // create_account (no platform) binds no platformId. + const account = await createAccount(c.env.DB, { + platforms: platformInt || 0, + platform: verifiedSteamId !== null ? 0 : undefined, + platformId: verifiedSteamId ?? undefined, + lastLoginTime: new Date().toISOString(), + deviceId: deviceId || undefined, + deviceClass: deviceId ? deviceClass : undefined, + signupIp: clientIp || undefined, + lastLoginIp: clientIp || undefined, + }) + accountId = String(account.accountId) + // 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 + ) + } + accountId = String(refreshed.accountId) + platform = refreshed.platform + platformId = refreshed.platformId + } 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 ticket vouches for. Authorize ONLY when that + // account is linked to exactly this platform identity — this is the check that + // keeps anyone but platform user `platform_id` out of the account (platform + // ownership is the credential; no password needed). An account with no stored + // platform identity can't be cached-logged-into and must use a fresh login. + // + // NB: `platform_id` here is the Steam-verified SteamID64 (set from the ticket + // above), never the client-supplied field. See steam-ticket.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 + if (!account || !isLinkedToPlatformIdentity(account, platformInt, platformId)) { + 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) + await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString()) + await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp }) + } + + // 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: 'invalid_grant', - error_description: 'unsupported platform; only Steam can be verified', - }, - 400 + { error: 'server_error', error_description: 'token signing is not configured' }, + 500 ) } - const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : '' - const verified = platformAuth ? await verifySteamTicket(platformAuth) : null - if (!verified) { - return c.json( - { error: 'invalid_grant', error_description: 'invalid or missing platform_auth ticket' }, - 400 - ) - } - verifiedSteamId = verified.steamId - platformId = verified.steamId - } - // 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') { - // 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) — 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 + // 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). One read of the just-resolved account; roles thus refresh on + // every login and every refresh_token grant. + const roleAccount = await getAccount(c.env.DB, Number(accountId)) + const accessToken = await generateToken( + accountId, + platformId, + platform, + jwtSecret, + accountRoles(roleAccount) ) - const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP) - if ( - maxPerPlatformId > 0 && - verifiedSteamId !== null && - (await countAccountsByPlatformId(c.env.DB, verifiedSteamId)) >= maxPerPlatformId - ) { - logger.info('signup rejected: platform account limit', { platformId: verifiedSteamId }) - 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 - ) - } + // 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, { + accountId: Number(accountId), + platform, + platformId, + }) - // Bind the platform identity ONLY when a Steam ticket proved it. That bound - // `platformId` (the SteamID64) is what a later cached login is checked against, - // so only this Steam user can log back into the account. A password/anonymous - // create_account (no platform) binds no platformId. - const account = await createAccount(c.env.DB, { - platforms: platformInt || 0, - platform: verifiedSteamId !== null ? 0 : undefined, - platformId: verifiedSteamId ?? undefined, - lastLoginTime: new Date().toISOString(), - deviceId: deviceId || undefined, - deviceClass: deviceId ? deviceClass : undefined, - signupIp: clientIp || undefined, - lastLoginIp: clientIp || undefined, + 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=', }) - accountId = String(account.accountId) - // 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 - ) - } - accountId = String(refreshed.accountId) - platform = refreshed.platform - platformId = refreshed.platformId - } 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 ticket vouches for. Authorize ONLY when that - // account is linked to exactly this platform identity — this is the check that - // keeps anyone but platform user `platform_id` out of the account (platform - // ownership is the credential; no password needed). An account with no stored - // platform identity can't be cached-logged-into and must use a fresh login. - // - // NB: `platform_id` here is the Steam-verified SteamID64 (set from the ticket - // above), never the client-supplied field. See steam-ticket.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 - if (!account || !isLinkedToPlatformIdentity(account, platformInt, platformId)) { - 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) - await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString()) - await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp }) } - - // 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). One read of the just-resolved account; roles thus refresh on - // every login and every refresh_token grant. - const roleAccount = await getAccount(c.env.DB, Number(accountId)) - const accessToken = await generateToken( - accountId, - platformId, - platform, - jwtSecret, - accountRoles(roleAccount) - ) - // 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, { - accountId: Number(accountId), - platform, - platformId, - }) - - 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', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) + .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.', + 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 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 }) } - - 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 }) - }) + ) // 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', async (c) => { + .get('/role/developer/:id', describeRoute(roleLookup('developer')), async (c) => { const { id } = c.req.param() logger.info('developer role lookup', { id }) const accountId = Number.parseInt(id, 10) @@ -539,7 +691,7 @@ const app = new Hono() // 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', async (c) => { + .get('/role/moderator/:id', describeRoute(roleLookup('moderator')), async (c) => { const { id } = c.req.param() logger.info('moderator role lookup', { id }) const accountId = Number.parseInt(id, 10) @@ -548,4 +700,41 @@ const app = new Hono() return c.json(account.isModerator === true) }) +// 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 }), + 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.', + '', + 'The shapes here are **reverse-engineered from the game client**, which is the only', + 'real consumer. They record observed behaviour rather than a designed contract, and', + 'the handlers are deliberately lenient: missing or malformed fields generally fall', + 'through to a graceful path instead of erroring. Nothing in this spec is enforced at', + 'runtime, so treat a field marked required as "the client always sends it", not "the', + 'server rejects it if absent".', + ].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 diff --git a/apps/auth/src/openapi.ts b/apps/auth/src/openapi.ts new file mode 100644 index 0000000..7d29f12 --- /dev/null +++ b/apps/auth/src/openapi.ts @@ -0,0 +1,184 @@ +import { resolver } from 'hono-openapi' +import { z } from 'zod' + +import type { OpenAPIV3_1 } from 'openapi-types' + +/** + * OpenAPI schemas for the auth worker. + * + * IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to + * generate the spec and are never wired into `hono-openapi`'s `validator()`. + * + * That is deliberate, not an oversight. This worker serves a reverse-engineered + * protocol: the Rec Room client is the only real consumer, and the handlers are + * intentionally lenient — every field is read as + * `typeof body.x === 'string' ? body.x : ''` and missing/malformed input falls + * through to a graceful path rather than a 400. Which parts of that tolerance the + * client actually depends on is not fully known, so enforcing a schema would risk + * rejecting requests that work today, for a client that is hard to debug against. + * + * So: these schemas record what the client is *observed* to send and what we send + * back. If you want to enforce one, do it per-route and land a test with it. + */ + +/** Emit a zod schema as an `application/json` response body. */ +export function json(schema: z.ZodType, description: string) { + return { description, content: { 'application/json': { schema: resolver(schema) } } } +} + +/** + * Emit a zod schema as an `application/x-www-form-urlencoded` request body. + * + * Unlike `responses`, `describeRoute`'s `requestBody` takes a plain OpenAPI schema + * and won't accept a `resolver()`, so convert here. zod's `$schema` key is dropped + * (not meaningful in an OpenAPI schema position), as is `additionalProperties: false` + * — these handlers read the fields they know and ignore the rest, so claiming a + * closed object would misreport the server as stricter than it is. + */ +export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject { + const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema) + return { + description, + content: { + // zod's JSONSchema type is far wider than OpenAPI's SchemaObject (it carries + // `~standard` and every draft keyword), so the two never match structurally + // even though the emitted value is valid OpenAPI 3.1. Cast at the boundary. + 'application/x-www-form-urlencoded': { schema: jsonSchema as OpenAPIV3_1.SchemaObject }, + }, + } +} + +/** + * PlatformType, by value. The `platform` form field is posted as the integer; the + * token's `platform` claim carries the name. Only Steam (0) can actually be + * verified — see the platform-auth notes on `POST /connect/token`. + */ +export const PlatformType = z + .union([z.literal(-1), z.int().min(0).max(8)]) + .describe( + '-1 All, 0 Steam, 1 Oculus, 2 PlayStation, 3 Xbox, 4 RecNet, 5 IOS, 6 GooglePlay, 7 Standalone, 8 Pico' + ) + +/** One entry on the client's login screen, from `toCachedLogin`. */ +export const CachedLogin = z + .object({ + platform: PlatformType, + platformId: z.string().describe('Platform-native id (a SteamID64 for Steam); "" if unlinked'), + accountId: z.int().describe('Post this back as `account_id` on a cached_login grant'), + lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"), + requirePassword: z + .literal(false) + .describe('Always false — platform ownership is the credential for a cached login'), + }) + .meta({ id: 'CachedLogin' }) + +/** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */ +export const OAuthError = z + .object({ + error: z.enum(['invalid_grant', 'invalid_request', 'server_error']), + error_description: z.string(), + }) + .meta({ id: 'OAuthError' }) + +/** Successful `POST /connect/token` body. */ +export const TokenResponse = z + .object({ + access_token: z.string().describe('Signed JWT; `sub` is the account id'), + expires_in: z.int().describe('Access-token lifetime in seconds (TOKEN_TTL_SECONDS)'), + token_type: z.literal('Bearer'), + refresh_token: z + .string() + .describe('Single-use; redeem via grant_type=refresh_token, which rotates it'), + scope: z.string().describe('Space-separated granted scopes'), + key: z.string().describe('@kludge Constant the client appears to require. Purpose unknown.'), + }) + .meta({ id: 'TokenResponse' }) + +/** + * `POST /connect/token` form body — the union of every grant's fields, since + * OpenAPI cannot express "these fields iff grant_type=X" without splitting the + * endpoint. Per-grant requirements are spelled out in the route description. + */ +export const TokenRequest = z + .object({ + grant_type: z + .enum(['create_account', 'cached_login', 'refresh_token', 'password']) + .describe('Anything unrecognised (including absent) is treated as a password grant'), + account_id: z.string().optional().describe('Numeric account id, as a string'), + username: z + .string() + .optional() + .describe('Password grant alternative to account_id; case-insensitive, trimmed'), + password: z + .string() + .optional() + .describe('Required on a password grant. On create_account, sets the initial password'), + platform: z.string().optional().describe('PlatformType as an integer string'), + platform_id: z + .string() + .optional() + .describe( + 'Unverified; ignored in favour of the Steam-verified id where a ticket is required' + ), + platform_auth: z + .string() + .optional() + .describe('Steam session ticket. Required for cached_login and platform create_account'), + refresh_token: z.string().optional().describe('Required on a refresh_token grant'), + device_id: z + .string() + .optional() + .describe('Client-chosen, unverified. Recorded on the account, never trusted'), + device_class: z.string().optional().describe('Integer string; defaults to 0'), + }) + .meta({ id: 'TokenRequest' }) + +/** `POST /account/me/changepassword` form body. */ +export const ChangePasswordRequest = z + .object({ + newPassword: z.string().describe('Required; empty is rejected'), + oldPassword: z + .string() + .optional() + .describe('Must match when the account already has a password; empty when first setting it'), + }) + .meta({ id: 'ChangePasswordRequest' }) + +/** `POST /account/me/changepassword` response body. */ +export const ChangePasswordResponse = z + .object({ success: z.boolean(), error: z.string().optional() }) + .meta({ id: 'ChangePasswordResponse' }) + +/** + * Spec for the `/role/:role/:id` lookups, which are identical apart from the role. + * Both return a BARE JSON boolean rather than an object — the client reads the whole + * body as a bool — and 404 an unknown player, mirroring the reference API. + */ +export function roleLookup(role: 'developer' | 'moderator') { + return { + tags: ['Roles'], + summary: `Whether a player has the ${role} role`, + description: + `Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` + + `granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` + + `in the access token's \`role\` claim, so the client rarely needs this route.`, + parameters: [ + { + name: 'id', + in: 'path' as const, + required: true, + description: 'Account id. A non-numeric value is treated as unknown (404).', + schema: { type: 'string' as const }, + }, + ], + responses: { + 200: json(z.boolean(), `\`true\` if the player has the ${role} role`), + 404: { description: 'No such player (empty body)' }, + }, + } +} + +/** Bulk cached-login lookup form body: repeated `id=` fields. */ +export const PlatformIdsRequest = z + .object({ id: z.union([z.string(), z.array(z.string())]).describe('Repeated `id=` form fields') }) + .meta({ id: 'PlatformIdsRequest' }) diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 790c406..18bf6c6 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -624,4 +624,41 @@ describe('auth worker routes', () => { const res = await exports.default.fetch(`${ORIGIN}/nope`) expect(res.status).toBe(404) }) + + test('GET /openapi.json documents every route', async () => { + const res = await exports.default.fetch(`${ORIGIN}/openapi.json`) + expect(res.status).toBe(200) + const spec = (await res.json()) as { + openapi: string + paths: Record> + } + expect(spec.openapi).toMatch(/^3\.1/) + + // The spec route hides itself. + expect(spec.paths['/openapi.json']).toBeUndefined() + + // Every route the worker serves is described. This is the drift guard: adding a + // route without a describeRoute() block fails here rather than silently shipping + // an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`. + const documented = new Set( + Object.entries(spec.paths).flatMap(([path, ops]) => + Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`) + ) + ) + expect([...documented].sort()).toEqual([ + 'GET /cachedlogin/forplatformid/{platform}/{id}', + 'GET /eac/challenge', + 'GET /role/developer/{id}', + 'GET /role/moderator/{id}', + 'POST /account/me/changepassword', + 'POST /cachedlogin/forplatformids', + 'POST /connect/token', + ]) + + // Every operation carries a summary — a path present but undescribed is not + // documentation. + for (const ops of Object.values(spec.paths)) { + for (const op of Object.values(ops)) expect(op.summary).toBeTruthy() + } + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f069e0c..60c0ab3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,12 +136,27 @@ importers: '@repo/jwt': specifier: workspace:* version: link:../../packages/jwt + '@standard-community/standard-json': + specifier: ^0.3.5 + version: 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3) + '@standard-community/standard-openapi': + specifier: ^0.2.9 + version: 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3) hono: specifier: 4.12.27 version: 4.12.27 + hono-openapi: + specifier: 1.3.1 + version: 1.3.1(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3) + openapi-types: + specifier: ^12.1.3 + version: 12.1.3 workers-tagged-logger: specifier: 1.0.1 version: 1.0.1 + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@cloudflare/vitest-pool-workers': specifier: 0.16.20 @@ -1951,6 +1966,67 @@ packages: '@speed-highlight/core@1.2.7': resolution: {integrity: sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==} + '@standard-community/standard-json@0.3.5': + resolution: {integrity: sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA==} + peerDependencies: + '@standard-schema/spec': ^1.0.0 + '@types/json-schema': ^7.0.15 + '@valibot/to-json-schema': ^1.3.0 + arktype: ^2.1.20 + effect: ^3.16.8 + quansync: ^0.2.11 + sury: ^10.0.0 + typebox: ^1.0.17 + valibot: ^1.1.0 + zod: ^3.25.0 || ^4.0.0 + zod-to-json-schema: ^3.24.5 + peerDependenciesMeta: + '@valibot/to-json-schema': + optional: true + arktype: + optional: true + effect: + optional: true + sury: + optional: true + typebox: + optional: true + valibot: + optional: true + zod: + optional: true + zod-to-json-schema: + optional: true + + '@standard-community/standard-openapi@0.2.9': + resolution: {integrity: sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg==} + peerDependencies: + '@standard-community/standard-json': ^0.3.5 + '@standard-schema/spec': ^1.0.0 + arktype: ^2.1.20 + effect: ^3.17.14 + openapi-types: ^12.1.3 + sury: ^10.0.0 + typebox: ^1.0.0 + valibot: ^1.1.0 + zod: ^3.25.0 || ^4.0.0 + zod-openapi: ^4 + peerDependenciesMeta: + arktype: + optional: true + effect: + optional: true + sury: + optional: true + typebox: + optional: true + valibot: + optional: true + zod: + optional: true + zod-openapi: + optional: true + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2021,6 +2097,9 @@ packages: '@types/fs-extra@11.0.4': resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonfile@6.1.4': resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} @@ -2266,6 +2345,21 @@ packages: git-hooks-list@4.2.1: resolution: {integrity: sha512-WNvqJjOxxs/8ZP9+DWdwWJ7cDsd60NHf39XnD82pDVrKO5q7xfPqpkK6hwEAmBa/ZSEE4IOoR75EzbbIuwGlMw==} + hono-openapi@1.3.1: + resolution: {integrity: sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw==} + peerDependencies: + '@hono/standard-validator': ^0.2.0 + '@standard-community/standard-json': ^0.3.5 + '@standard-community/standard-openapi': ^0.2.9 + '@types/json-schema': ^7.0.15 + hono: ^4.11.2 + openapi-types: ^12.1.3 + peerDependenciesMeta: + '@hono/standard-validator': + optional: true + hono: + optional: true + hono@4.12.27: resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} engines: {node: '>=16.9.0'} @@ -2409,6 +2503,9 @@ packages: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + oxlint-tsgolint@0.23.0: resolution: {integrity: sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==} hasBin: true @@ -2466,6 +2563,9 @@ packages: engines: {node: '>=14'} hasBin: true + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + react-dom@19.2.7: resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: @@ -3610,6 +3710,22 @@ snapshots: '@speed-highlight/core@1.2.7': {} + '@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/json-schema': 7.0.15 + quansync: 0.2.11 + optionalDependencies: + zod: 4.4.3 + + '@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3)': + dependencies: + '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3) + '@standard-schema/spec': 1.1.0 + openapi-types: 12.1.3 + optionalDependencies: + zod: 4.4.3 + '@standard-schema/spec@1.1.0': {} '@taplo/core@0.2.0': {} @@ -3682,6 +3798,8 @@ snapshots: '@types/jsonfile': 6.1.4 '@types/node': 26.0.1 + '@types/json-schema@7.0.15': {} + '@types/jsonfile@6.1.4': dependencies: '@types/node': 26.0.1 @@ -3933,6 +4051,16 @@ snapshots: git-hooks-list@4.2.1: {} + hono-openapi@1.3.1(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3): + dependencies: + '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3) + '@standard-community/standard-openapi': 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3) + '@types/json-schema': 7.0.15 + openapi-types: 12.1.3 + optionalDependencies: + '@hono/standard-validator': 0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27) + hono: 4.12.27 + hono@4.12.27: {} http-codex@0.6.7: {} @@ -4046,6 +4174,8 @@ snapshots: obug@2.1.3: {} + openapi-types@12.1.3: {} + oxlint-tsgolint@0.23.0: optionalDependencies: '@oxlint-tsgolint/darwin-arm64': 0.23.0 @@ -4107,6 +4237,8 @@ snapshots: prettier@3.9.4: {} + quansync@0.2.11: {} + react-dom@19.2.7(react@19.2.7): dependencies: react: 19.2.7