beta openapi docs

This commit is contained in:
Devin Zuczek
2026-07-20 15:32:59 -04:00
parent 3319a5d91a
commit 355b459dc9
6 changed files with 925 additions and 307 deletions
+93 -22
View File
@@ -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) |
| ------ | ------------------------------------------ | ------------------------------------------------------ |
| 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 <dev-key> -
```
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`).
+6 -1
View File
@@ -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",
+203 -14
View File
@@ -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,13 +224,56 @@ const app = new Hono<App>()
.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) => {
.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)
@@ -225,14 +281,30 @@ const app = new Hono<App>()
// 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))
.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) => {
.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<string, unknown>)
const raw = body.id
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
@@ -241,10 +313,66 @@ const app = new Hono<App>()
out.push(...(await getAccountsByPlatformId(c.env.DB, pid)).map(toCachedLogin))
}
return c.json(out)
})
}
)
// OAuth token endpoint — accepts a form-urlencoded body and issues a JWT.
.post('/connect/token', async (c) => {
.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<string, unknown>)
@@ -253,7 +381,8 @@ const app = new Hono<App>()
// 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
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
@@ -298,7 +427,10 @@ const app = new Hono<App>()
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
if (!verified) {
return c.json(
{ error: 'invalid_grant', error_description: 'invalid or missing platform_auth ticket' },
{
error: 'invalid_grant',
error_description: 'invalid or missing platform_auth ticket',
},
400
)
}
@@ -495,13 +627,32 @@ const app = new Hono<App>()
// @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) => {
.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)
@@ -520,14 +671,15 @@ const app = new Hono<App>()
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<App>()
// 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<App>()
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
+184
View File
@@ -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' })
@@ -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<string, Record<string, { summary?: string }>>
}
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()
}
})
})
+132
View File
@@ -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