diff --git a/apps/accounts/README.md b/apps/accounts/README.md index 8abd145..ce06846 100644 --- a/apps/accounts/README.md +++ b/apps/accounts/README.md @@ -1,37 +1,99 @@ # accounts -Accounts Worker served on the `accounts` subdomain. A Hono app for accounts. -Database queries are stubbed for now — no real bindings yet. +Accounts Worker served on the `accounts` subdomain (`accounts.recflare.net`) — a Hono +app for account reads, profile mutations and lookups. Accounts live in the shared +`recflare` D1 database, whose `account` schema and migrations are owned by the `auth` +worker; this worker binds it read/write. -## Behavior +## Routes -- **Auth-gated routes** validate the Bearer JWT issued by the `auth` worker - (same dev secret, see `src/jwt.ts`) and 401 when it's missing/invalid. -- **DB-backed reads** return synthesized default accounts. Every column gets a - fallback (`Player{id}`, `DefaultProfileImage.jpg`, etc.), so the stubs return - those defaults rather than 404ing on a missing row. -- **DB-backed writes** (`create`, the `PUT /account/me/*` mutations) accept the - request and ack without persisting. `create` mints a random account id and - returns it wrapped in the RecNet result envelope `{ success, value }`. +| Method | Path | Auth | Description | +| ------ | ------------------------------ | ---- | ------------------------------------------------ | +| GET | `/` | | Health check | +| GET | `/account/me` | ✓ | The caller's own account (private self DTO) | +| GET | `/account/search?name=` | | Prefix-search accounts by username | +| GET | `/account/bulk?id=1&id=2,3` | | Look up many accounts by id | +| GET | `/account/:id` | | A single public account | +| GET | `/account/:id/bio` | | A player's bio | +| POST | `/account/create` | | Create an account → `{ success, value }` | +| GET | `/parentalcontrol/me` | ✓ | The caller's parental-control flags | +| GET | `/accountprivacysettings/:id` | | An account's privacy settings | +| PUT | `/account/me/displayname` | ✓ | Set display name | +| PUT | `/account/me/username` | ✓ | Change username (unique + change remaining) | +| POST | `/account/me/email` | ✓ | Set email | +| POST | `/account/me/phone` | ✓ | Set phone number | +| PUT | `/account/me/identityflags` | ✓ | Set identity flags bitmask | +| PUT | `/account/me/personalpronouns` | ✓ | Set personal pronouns (posted as `pronounFlags`) | +| PUT | `/account/me/bio` | ✓ | Set bio | +| PUT | `/account/me/profileimage` | ✓ | Set avatar object key | +| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) | -## Endpoints +Auth-gated routes validate the Bearer JWT issued by the `auth` worker and return an +empty-body 401 when it's missing or invalid. -- `GET /` — health check -- `GET /account/me` — authed self account (`SelfAccount`) -- `GET /account/bulk?id=1&id=2` — accounts for the requested ids -- `GET /account/:id` — single account -- `GET /account/:id/bio` — player bio -- `POST /account/create` — create an account → `{ success, value }` -- `GET /parentalcontrol/me` — authed parental-control flags -- `PUT /account/me/displayname` — authed, body `displayName` -- `PUT /account/me/username` — authed, body `username` -- `PUT /account/me/bio` — authed, body `bio` -- `PUT /account/me/profileimage` — authed, body `imageName` +## API documentation -## TODO before production +`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit +alongside each handler, with the schemas in `src/openapi.ts`. -- Wire a DB binding (D1/DO) for `Accounts`, `CachedLogins`, `PlayerBios`, - `Rooms`/`SubRooms` (the dorm room created on signup). -- Make reads 404 on missing rows once real data exists. -- Persist the `PUT /account/me/*` mutations. -- Move the JWT secret to a shared secret binding (shared with `auth`). +**The spec is descriptive, not enforced.** Nothing validates requests against it — same +rationale as the `auth` worker: this serves a protocol reverse-engineered from the Rec +Room client, the handlers are lenient (form fields are read as +`typeof value === 'string' ? value : ''`), and reads fall back to a synthesized default +account rather than 404. 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. + +## Account shapes + +Two DTOs, both camelCase: + +- **Public** (`toAccountDto`) — returned for any account. Excludes private fields. +- **Self** (`toSelfAccountDto`, the `/account/me` shape) — the public DTO plus + owner-only `email`, `birthday` and `availableUsernameChanges`. + +Two client-deserializer quirks are load-bearing and deliberate: + +- `juniorState` / `parentAccountId` are **omitted entirely** when unset — emitting + `null` makes the client's enum parser throw. `email` / `birthday` aren't enums, so + they're kept as `null`. +- `GET /accountprivacysettings/:id` never returns a bare `{}` — that fails the client's + deserializer ("Deserialization returned null"), so the id is echoed back with recent + history reported visible. Nothing stores per-player privacy yet. + +## Missing rows fall back to defaults + +Account reads (`/account/me`, `/account/:id`, `/account/bulk`) never 404 on an unknown +id — they synthesize a default account (`defaultAccount`) so every requested id is +present in the response. `bulk` in particular guarantees one entry per requested id. + +## Notifications + +Profile mutations persist to the account row and then push through the shared +notifications hub (a single global Durable Object owned by the `notify` worker): the +owner receives `SelfAccountUpdate` + `AccountUpdate`, and every connected client +receives an `AccountUpdate` broadcast. Hub failures are logged and swallowed — the +write has already committed, so a hub hiccup must not fail the request. + +This matters most for the mutations whose HTTP response carries no account body +(`personalpronouns`, `identityflags`): the client only learns the new value from the +pushed update, and since those fields are in the _public_ DTO, every other client needs +the broadcast too. `email` and `phone` are private, so they persist without a push. + +## Bindings + +| Binding | Type | Notes | +| ---------------------------- | -------------- | ------------------------------------------------------------ | +| `DB` | D1 | Shared `recflare` database; `account` schema owned by `auth` | +| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) | +| `RECFLARE_NOTIFICATIONS_HUB` | Durable Object | Cross-worker RPC to the `notify` worker's hub | + +This worker has no migrations of its own — the `account` table is created and migrated +by `auth` (`apps/auth/migrations/`). + +## Known gaps + +- `POST /account/create` parses `platformId` but doesn't yet persist it, and doesn't + create the dorm Room/SubRoom a new account should get. diff --git a/apps/accounts/package.json b/apps/accounts/package.json index d914286..46ffae2 100644 --- a/apps/accounts/package.json +++ b/apps/accounts/package.json @@ -18,8 +18,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/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index f20e173..63c9db6 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono' +import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { useWorkersLogger } from 'workers-tagged-logger' import { @@ -13,6 +14,29 @@ import { import { logger, withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' +import { + AccountDto, + BioRequest, + BioResponse, + CreateAccountRequest, + CreateAccountResult, + DisplayNameRequest, + EmailRequest, + form, + HealthResponse, + IdentityFlagsRequest, + json, + ParentalControl, + PhoneRequest, + PrivacySettings, + ProfileImageRequest, + PronounsRequest, + SelfAccountDto, + SuccessResponse, + UsernameRequest, + UsernameResult, +} from './openapi' + import type { Context } from 'hono' import type { Account } from '@repo/domain' import type { App } from './context' @@ -21,9 +45,10 @@ import type { App } from './context' * Account reads/writes are backed by the shared `accounts` table in D1 (schema * owned by the `auth` worker). Accounts not in the table fall back to a * synthesized default (every column has a fallback anyway). Profile mutations - * still accept-and-ack (marked `TODO`). + * persist to the account row and push an AccountUpdate through the notifications + * hub (see `pushAccountUpdate`). * - * Auth-gated routes still validate the Bearer JWT issued by the `auth` worker. + * Auth-gated routes validate the Bearer JWT issued by the `auth` worker. */ /** @@ -117,6 +142,12 @@ async function pushAccountUpdate(c: Context, account: Account): Promise() .use( '*', @@ -132,203 +163,511 @@ const app = new Hono() .notFound(withNotFound()) // Root health check. - .get('/', (c) => c.json({ service: 'accounts', status: 'ok' })) + .get( + '/', + describeRoute({ + tags: ['Meta'], + summary: 'Health check', + responses: { 200: json(HealthResponse, 'Service is up') }, + }), + (c) => c.json({ service: 'accounts', status: 'ok' }) + ) // ---- Self account -------------------------------------------------------- - .get('/account/me', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - // Load the stored account, falling back to a synthesized default. - const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id) - return c.json(toSelfAccountDto(account)) - }) + .get( + '/account/me', + describeRoute({ + tags: ['Self'], + summary: 'The caller’s own account', + description: + 'The private self DTO, including owner-only fields (email, remaining username ' + + 'changes). An account with no stored row falls back to a synthesized default.', + security: AUTHED, + responses: { + 200: json(SelfAccountDto, 'The caller’s account'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + // Load the stored account, falling back to a synthesized default. + const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id) + return c.json(toSelfAccountDto(account)) + } + ) // ---- Search -------------------------------------------------------------- // Prefix-search accounts by username (`?name=`). Returns a bare array of public // account DTOs, ordered alphabetically. Registered before `/account/:id` so the // static `search` path wins over the param route. - .get('/account/search', async (c) => { - const name = c.req.query('name') ?? '' - const accounts = await searchAccounts(c.env.DB, name) - return c.json(accounts.map(toAccountDto)) - }) + .get( + '/account/search', + describeRoute({ + tags: ['Lookup'], + summary: 'Prefix-search accounts by username', + description: 'Case-insensitive prefix match on username, ordered alphabetically.', + parameters: [ + { + name: 'name', + in: 'query', + required: false, + description: 'Username prefix; empty matches nothing meaningful', + schema: { type: 'string' }, + }, + ], + responses: { 200: json(AccountDto.array(), 'Matching public accounts') }, + }), + async (c) => { + const name = c.req.query('name') ?? '' + const accounts = await searchAccounts(c.env.DB, name) + return c.json(accounts.map(toAccountDto)) + } + ) // ---- Bulk / single lookup ------------------------------------------------ // Register the static `bulk` path before the `/account/:id` param route. - .get('/account/bulk', async (c) => { - // Reads repeated `id` query params; also accept a comma-separated list. - const ids = - c.req - .queries('id') - ?.flatMap((v) => v.split(',')) - .map((s) => Number.parseInt(s.trim(), 10)) - .filter((n) => !Number.isNaN(n)) ?? [] - // Resolve stored accounts, synthesizing a default for any id not in the DB - // so every requested id is present in the response. - const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.accountId, a])) - return c.json(ids.map((id) => toAccountDto(stored.get(id) ?? defaultAccount(id)))) - }) + .get( + '/account/bulk', + describeRoute({ + tags: ['Lookup'], + summary: 'Look up many accounts by id', + description: + 'Accepts repeated `id` query params and/or comma-separated lists. Every requested ' + + 'id appears in the response — ids with no stored row get a synthesized default.', + parameters: [ + { + name: 'id', + in: 'query', + required: false, + description: 'Repeatable; each value may be a comma-separated list of ids', + schema: { type: 'array', items: { type: 'string' } }, + }, + ], + responses: { 200: json(AccountDto.array(), 'One public account per requested id') }, + }), + async (c) => { + // Reads repeated `id` query params; also accept a comma-separated list. + const ids = + c.req + .queries('id') + ?.flatMap((v) => v.split(',')) + .map((s) => Number.parseInt(s.trim(), 10)) + .filter((n) => !Number.isNaN(n)) ?? [] + // Resolve stored accounts, synthesizing a default for any id not in the DB + // so every requested id is present in the response. + const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.accountId, a])) + return c.json(ids.map((id) => toAccountDto(stored.get(id) ?? defaultAccount(id)))) + } + ) - .get('/account/:id/bio', async (c) => { - const accountId = Number.parseInt(c.req.param('id'), 10) - if (Number.isNaN(accountId)) return c.body(null, 400) - // Bio is stored on the account JSON (set via PUT /account/me/bio). - const account = await getAccount(c.env.DB, accountId) - return c.json({ accountId, bio: account?.bio ?? '' }) - }) + .get( + '/account/:id/bio', + describeRoute({ + tags: ['Lookup'], + summary: 'A player’s bio', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + description: 'Account id; non-numeric is 400', + schema: { type: 'string' }, + }, + ], + responses: { + 200: json(BioResponse, 'The bio (empty string when unset)'), + 400: { description: 'Non-numeric id (empty body)' }, + }, + }), + async (c) => { + const accountId = Number.parseInt(c.req.param('id'), 10) + if (Number.isNaN(accountId)) return c.body(null, 400) + // Bio is stored on the account JSON (set via PUT /account/me/bio). + const account = await getAccount(c.env.DB, accountId) + return c.json({ accountId, bio: account?.bio ?? '' }) + } + ) - .get('/account/:id', async (c) => { - const accountId = Number.parseInt(c.req.param('id'), 10) - if (Number.isNaN(accountId)) return c.body(null, 400) - // Load the stored account, falling back to a synthesized default. - return c.json( - toAccountDto((await getAccount(c.env.DB, accountId)) ?? defaultAccount(accountId)) - ) - }) + .get( + '/account/:id', + describeRoute({ + tags: ['Lookup'], + summary: 'A single public account', + description: 'An id with no stored row falls back to a synthesized default account.', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + description: 'Account id; non-numeric is 400', + schema: { type: 'string' }, + }, + ], + responses: { + 200: json(AccountDto, 'The public account'), + 400: { description: 'Non-numeric id (empty body)' }, + }, + }), + async (c) => { + const accountId = Number.parseInt(c.req.param('id'), 10) + if (Number.isNaN(accountId)) return c.body(null, 400) + // Load the stored account, falling back to a synthesized default. + return c.json( + toAccountDto((await getAccount(c.env.DB, accountId)) ?? defaultAccount(accountId)) + ) + } + ) // ---- Create -------------------------------------------------------------- - .post('/account/create', async (c) => { - // Parsed for fidelity; unused until there's a DB to persist CachedLogins. - const platform = await formField(c, 'platform') - await formField(c, 'platformId') + .post( + '/account/create', + describeRoute({ + tags: ['Self'], + summary: 'Create an account', + description: + 'Mints a new account with an auto-assigned random username (players don’t choose ' + + 'one initially). Not auth-gated. `platformId` is parsed but not yet persisted.', + requestBody: form(CreateAccountRequest, 'Platform fields'), + responses: { 200: json(CreateAccountResult, 'The created account, in a result envelope') }, + }), + async (c) => { + // Parsed for fidelity; unused until there's a DB to persist CachedLogins. + const platform = await formField(c, 'platform') + await formField(c, 'platformId') - // Persist a new account with an auto-assigned random username (players - // don't choose one initially). - const platforms = Number.parseInt(platform, 10) - const account = await createAccount(c.env.DB, { - platforms: Number.isNaN(platforms) ? 0 : platforms, - }) - // TODO: also create a dorm Room/SubRoom for the new account. - return c.json({ success: true, value: toAccountDto(account) }) - }) + // Persist a new account with an auto-assigned random username (players + // don't choose one initially). + const platforms = Number.parseInt(platform, 10) + const account = await createAccount(c.env.DB, { + platforms: Number.isNaN(platforms) ? 0 : platforms, + }) + // TODO: also create a dorm Room/SubRoom for the new account. + return c.json({ success: true, value: toAccountDto(account) }) + } + ) // ---- Parental control ---------------------------------------------------- - .get('/parentalcontrol/me', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - return c.json({ accountId: id, disallowInAppPurchases: false }) - }) + .get( + '/parentalcontrol/me', + describeRoute({ + tags: ['Self'], + summary: 'The caller’s parental-control flags', + description: 'Nothing stores parental controls yet; purchases are always allowed.', + security: AUTHED, + responses: { + 200: json(ParentalControl, 'Parental-control flags'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json({ accountId: id, disallowInAppPurchases: false }) + } + ) // Privacy settings for an account. A bare `{}` fails the client's deserializer // ("Deserialization returned null") — it needs the fields, so echo the id back and // report recent history as visible. Nothing stores per-player privacy yet. - .get('/accountprivacysettings/:id{[0-9]+}', (c) => - c.json({ - accountId: Number.parseInt(c.req.param('id'), 10), - isRecentHistoryVisible: true, - }) + .get( + '/accountprivacysettings/:id{[0-9]+}', + describeRoute({ + tags: ['Lookup'], + summary: 'An account’s privacy settings', + description: + 'Nothing stores per-player privacy yet; the id is echoed and recent history is ' + + 'reported visible (a bare `{}` fails the client’s deserializer).', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + description: 'Account id (digits only)', + schema: { type: 'string', pattern: '^[0-9]+$' }, + }, + ], + responses: { 200: json(PrivacySettings, 'Privacy settings') }, + }), + (c) => + c.json({ + accountId: Number.parseInt(c.req.param('id'), 10), + isRecentHistoryVisible: true, + }) ) // ---- Profile mutations --------------------------------------------------- // Set the player's display name (persisted on the account row). - .put('/account/me/displayname', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const displayName = (await formField(c, 'displayName')).trim() - if (displayName === '') return c.body(null, 400) - const account = await updateAccount(c.env.DB, id, { displayName }) - await pushAccountUpdate(c, account) - return c.json({ success: true }) - }) + .put( + '/account/me/displayname', + describeRoute({ + tags: ['Profile'], + summary: 'Set display name', + description: 'Persisted and broadcast via an AccountUpdate notification.', + security: AUTHED, + requestBody: form(DisplayNameRequest, 'The new display name'), + responses: { + 200: json(SuccessResponse, 'Updated'), + 400: { description: 'Empty display name (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const displayName = (await formField(c, 'displayName')).trim() + if (displayName === '') return c.body(null, 400) + const account = await updateAccount(c.env.DB, id, { displayName }) + await pushAccountUpdate(c, account) + return c.json({ success: true }) + } + ) // Change the caller's username. Rejects a name already taken by another account, // and requires the account to have username changes remaining. On success the // new name is persisted and the remaining-changes counter is decremented. - .put('/account/me/username', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) + .put( + '/account/me/username', + describeRoute({ + tags: ['Profile'], + summary: 'Change username', + description: + 'Rejects a name taken by another account and requires a remaining change; on ' + + 'success the name is persisted and the counter decremented. Always HTTP 200 — ' + + 'failures carry a message in `error` (see the UsernameResult envelope).', + security: AUTHED, + requestBody: form(UsernameRequest, 'The desired username'), + responses: { + 200: json(UsernameResult, 'Result envelope (success or a validation error)'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) - const username = (await formField(c, 'username')).trim() - if (username === '') return usernameResult(c, 'You must enter a username.') + const username = (await formField(c, 'username')).trim() + if (username === '') return usernameResult(c, 'You must enter a username.') - // Duplicate check first (case-insensitive); keeping your own name is allowed. - const existing = await getAccountByUsername(c.env.DB, username) - if (existing && existing.accountId !== id) { - return usernameResult(c, 'That username is already taken.') + // Duplicate check first (case-insensitive); keeping your own name is allowed. + const existing = await getAccountByUsername(c.env.DB, username) + if (existing && existing.accountId !== id) { + return usernameResult(c, 'That username is already taken.') + } + + // Then require a remaining change. + const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id) + const remaining = account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES + if (remaining <= 0) { + return usernameResult(c, 'You have no username changes remaining.') + } + + const updated = await updateAccount(c.env.DB, id, { + username, + availableUsernameChanges: remaining - 1, + }) + await pushAccountUpdate(c, updated) + return usernameResult(c, '', toAccountDto(updated)) } - - // Then require a remaining change. - const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id) - const remaining = account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES - if (remaining <= 0) { - return usernameResult(c, 'You have no username changes remaining.') - } - - const updated = await updateAccount(c.env.DB, id, { - username, - availableUsernameChanges: remaining - 1, - }) - await pushAccountUpdate(c, updated) - return usernameResult(c, '', toAccountDto(updated)) - }) + ) // Set the player's email (persisted on the account row; surfaced by /account/me). - .post('/account/me/email', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const email = (await formField(c, 'email')).trim() - if (!email.includes('@')) return c.body(null, 400) - await updateAccount(c.env.DB, id, { email }) - return c.json({ success: true }) - }) + .post( + '/account/me/email', + describeRoute({ + tags: ['Profile'], + summary: 'Set email', + description: 'Persisted; surfaced only by `/account/me`. Not broadcast.', + security: AUTHED, + requestBody: form(EmailRequest, 'The new email'), + responses: { + 200: json(SuccessResponse, 'Updated'), + 400: { description: 'Email without an “@” (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const email = (await formField(c, 'email')).trim() + if (!email.includes('@')) return c.body(null, 400) + await updateAccount(c.env.DB, id, { email }) + return c.json({ success: true }) + } + ) // Set the player's phone (persisted on the account row). - .post('/account/me/phone', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const phone = (await formField(c, 'phone')).trim() - if (phone === '') return c.body(null, 400) - await updateAccount(c.env.DB, id, { phone }) - return c.json({ success: true }) - }) + .post( + '/account/me/phone', + describeRoute({ + tags: ['Profile'], + summary: 'Set phone number', + description: 'Persisted on the account row. Not broadcast.', + security: AUTHED, + requestBody: form(PhoneRequest, 'The new phone number'), + responses: { + 200: json(SuccessResponse, 'Updated'), + 400: { description: 'Empty phone (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const phone = (await formField(c, 'phone')).trim() + if (phone === '') return c.body(null, 400) + await updateAccount(c.env.DB, id, { phone }) + return c.json({ success: true }) + } + ) // Set the player's identityFlags bitmask (persisted; surfaced by /account/me). // `identityFlags` is part of the public account DTO, so the update has to be pushed // — see the note on personalpronouns below. - .put('/account/me/identityflags', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const identityFlags = Number.parseInt((await formField(c, 'identityFlags')).trim(), 10) - if (Number.isNaN(identityFlags)) return c.body(null, 400) - const account = await updateAccount(c.env.DB, id, { identityFlags }) - await pushAccountUpdate(c, account) - return c.json({ success: true }) - }) + .put( + '/account/me/identityflags', + describeRoute({ + tags: ['Profile'], + summary: 'Set identity flags', + description: + '`identityFlags` bitmask. In the public DTO, so the update is broadcast via ' + + 'AccountUpdate.', + security: AUTHED, + requestBody: form(IdentityFlagsRequest, 'The identityFlags bitmask'), + responses: { + 200: json(SuccessResponse, 'Updated'), + 400: { description: 'Non-numeric identityFlags (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const identityFlags = Number.parseInt((await formField(c, 'identityFlags')).trim(), 10) + if (Number.isNaN(identityFlags)) return c.body(null, 400) + const account = await updateAccount(c.env.DB, id, { identityFlags }) + await pushAccountUpdate(c, account) + return c.json({ success: true }) + } + ) // Set the player's personalPronouns (posted as `pronounFlags`; persisted). // The response body carries no account, so the client only learns the new value from // the `SelfAccountUpdate`/`AccountUpdate` the hub pushes — without it the player's own // UI (and every other client, since personalPronouns is in the public DTO) keeps // showing the old pronouns until something else refetches the account. - .put('/account/me/personalpronouns', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const personalPronouns = Number.parseInt((await formField(c, 'pronounFlags')).trim(), 10) - if (Number.isNaN(personalPronouns)) return c.body(null, 400) - const account = await updateAccount(c.env.DB, id, { personalPronouns }) - await pushAccountUpdate(c, account) - return c.json({ success: true }) - }) + .put( + '/account/me/personalpronouns', + describeRoute({ + tags: ['Profile'], + summary: 'Set personal pronouns', + description: + 'Posted as `pronounFlags`. The response carries no account, so the client learns ' + + 'the new value only from the broadcast AccountUpdate.', + security: AUTHED, + requestBody: form(PronounsRequest, 'The pronounFlags bitmask'), + responses: { + 200: json(SuccessResponse, 'Updated'), + 400: { description: 'Non-numeric pronounFlags (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const personalPronouns = Number.parseInt((await formField(c, 'pronounFlags')).trim(), 10) + if (Number.isNaN(personalPronouns)) return c.body(null, 400) + const account = await updateAccount(c.env.DB, id, { personalPronouns }) + await pushAccountUpdate(c, account) + return c.json({ success: true }) + } + ) - .put('/account/me/bio', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const bio = await formField(c, 'bio') - const account = await updateAccount(c.env.DB, id, { bio }) - await pushAccountUpdate(c, account) - return c.json({ success: true }) - }) + .put( + '/account/me/bio', + describeRoute({ + tags: ['Profile'], + summary: 'Set bio', + description: 'Free text; empty is allowed. Persisted and broadcast.', + security: AUTHED, + requestBody: form(BioRequest, 'The new bio'), + responses: { + 200: json(SuccessResponse, 'Updated'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const bio = await formField(c, 'bio') + const account = await updateAccount(c.env.DB, id, { bio }) + await pushAccountUpdate(c, account) + return c.json({ success: true }) + } + ) - .put('/account/me/profileimage', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const imageName = await formField(c, 'imageName') - if (!imageName) return c.body(null, 400) - // Persist the new avatar key on the account row and fire the AccountUpdate - // websocket (the new profileImage rides along in the DTO payload). - const account = await updateAccount(c.env.DB, id, { profileImage: imageName }) - await pushAccountUpdate(c, account) - return c.json({ success: true }) + .put( + '/account/me/profileimage', + describeRoute({ + tags: ['Profile'], + summary: 'Set profile image', + description: 'Persists the avatar object key and broadcasts it in the AccountUpdate payload.', + security: AUTHED, + requestBody: form(ProfileImageRequest, 'The avatar object key'), + responses: { + 200: json(SuccessResponse, 'Updated'), + 400: { description: 'Empty imageName (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const imageName = await formField(c, 'imageName') + if (!imageName) return c.body(null, 400) + // Persist the new avatar key on the account row and fire the AccountUpdate + // websocket (the new profileImage rides along in the DTO payload). + const account = await updateAccount(c.env.DB, id, { profileImage: imageName }) + await pushAccountUpdate(c, account) + return c.json({ success: 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 accounts', + version: '1.0.0', + description: [ + 'Account reads, profile mutations and lookups for recflare, a private-server', + 'reimplementation of the Rec Room backend. Accounts live in the shared `recflare`', + 'D1 database, whose `account` schema is owned by the `auth` worker.', + '', + 'The shapes here are **reverse-engineered from the game client**, which is the only', + 'real consumer. They record observed behaviour, not a designed contract; the handlers', + 'are lenient and reads fall back to a synthesized default account rather than 404.', + 'Nothing in this spec is enforced at runtime — treat a field marked required as "the', + 'client always sends it", not "the server rejects it if absent".', + ].join('\n'), + }, + servers: [{ url: 'https://accounts.recflare.net', description: 'Production' }], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'An `access_token` from the auth worker’s `POST /connect/token`.', + }, + }, + }, + }, }) +) export default app diff --git a/apps/accounts/src/openapi.ts b/apps/accounts/src/openapi.ts new file mode 100644 index 0000000..1f0ae08 --- /dev/null +++ b/apps/accounts/src/openapi.ts @@ -0,0 +1,170 @@ +import { resolver } from 'hono-openapi' +import { z } from 'zod' + +import type { OpenAPIV3_1 } from 'openapi-types' + +/** + * OpenAPI schemas for the accounts worker. + * + * IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to + * generate the spec and are never wired into `hono-openapi`'s `validator()`. + * + * As with the auth worker, this is deliberate. The Rec Room client is the only real + * consumer and the handlers are intentionally lenient — form fields are read as + * `typeof value === 'string' ? value : ''` and missing/malformed input falls through + * to a graceful path (or a synthesized default account) rather than a hard error. + * These schemas record what the client is observed to send and what we send back; 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 a form request body. `describeRoute`'s `requestBody` takes a + * plain OpenAPI schema (not a `resolver()`), so convert here. zod's `$schema` key and + * `additionalProperties: false` are dropped — these handlers read the fields they know + * and ignore the rest, so claiming a closed object would misreport them as stricter + * than they are. The client posts both urlencoded and multipart, hence the wildcard. + */ +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; cast at the + // boundary (the emitted value is valid OpenAPI 3.1). + 'application/x-www-form-urlencoded': { schema: jsonSchema as OpenAPIV3_1.SchemaObject }, + 'multipart/form-data': { schema: jsonSchema as OpenAPIV3_1.SchemaObject }, + }, + } +} + +/** + * The public account DTO (`toAccountDto`) — the camelCase shape returned for any + * account, with private fields (email, birthday) excluded. Fields the client parses + * as enums are numbers here. + */ +export const AccountDto = z + .object({ + accountId: z.int(), + username: z.string(), + displayName: z.string(), + profileImage: z.string().describe('Avatar object key'), + isJunior: z.boolean(), + platforms: z.int().describe('PlatformType bitmask of linked platforms'), + personalPronouns: z.int().describe('Pronoun flags bitmask'), + identityFlags: z.int().describe('Identity flags bitmask'), + createdAt: z.iso.datetime(), + }) + .meta({ id: 'AccountDto' }) + +/** + * The private self DTO (`toSelfAccountDto`, the `/account/me` shape) — the public DTO + * plus owner-only fields. `juniorState`/`parentAccountId` are omitted entirely when + * unset (emitting `null` makes the client's enum parser throw); `email`/`birthday` are + * kept as nullable since they aren't enums. + */ +export const SelfAccountDto = AccountDto.extend({ + email: z.string().nullable(), + birthday: z.null().describe('Always null — birthday is not stored'), + availableUsernameChanges: z.int().describe('Remaining username changes'), +}).meta({ id: 'SelfAccountDto' }) + +/** Player bio, from `GET /account/:id/bio`. */ +export const BioResponse = z + .object({ accountId: z.int(), bio: z.string().describe('"" when unset') }) + .meta({ id: 'BioResponse' }) + +/** A bare `{ success: true }` ack, returned by most profile mutations. */ +export const SuccessResponse = z + .object({ success: z.literal(true) }) + .meta({ id: 'SuccessResponse' }) + +/** The RecNet result envelope `{ success, value }` used by create + username change. */ +export function envelope(value: z.ZodType, id: string) { + return z + .object({ + success: z.boolean(), + value, + error: z.string().optional().describe('Present (with success:false) on failure'), + }) + .meta({ id }) +} + +/** + * The username-change envelope. Always HTTP 200 even on failure: `success:false` with + * a message in `error` and `value` an empty string; on success `value` is the updated + * public account. + */ +export const UsernameResult = envelope( + z.union([AccountDto, z.literal('')]), + 'UsernameResult' +).describe('value is the updated account on success, "" on failure') + +/** `POST /account/create` response. */ +export const CreateAccountResult = envelope(AccountDto, 'CreateAccountResult') + +/** `GET /parentalcontrol/me` response. */ +export const ParentalControl = z + .object({ accountId: z.int(), disallowInAppPurchases: z.boolean() }) + .meta({ id: 'ParentalControl' }) + +/** + * `GET /accountprivacysettings/:id` response. A bare `{}` fails the client's + * deserializer, so the id is echoed back and recent history reported visible; nothing + * stores per-player privacy yet. + */ +export const PrivacySettings = z + .object({ accountId: z.int(), isRecentHistoryVisible: z.boolean() }) + .meta({ id: 'PrivacySettings' }) + +/** Root health check. */ +export const HealthResponse = z + .object({ service: z.literal('accounts'), status: z.literal('ok') }) + .meta({ id: 'HealthResponse' }) + +// ---- Request bodies -------------------------------------------------------- + +/** `POST /account/create` form body. Both fields are parsed but not yet persisted. */ +export const CreateAccountRequest = z + .object({ + platform: z.string().optional().describe('PlatformType integer string; defaults to 0'), + platformId: z.string().optional().describe('Parsed for fidelity; currently unused'), + }) + .meta({ id: 'CreateAccountRequest' }) + +/** Single-string form bodies, one per profile mutation. */ +export const DisplayNameRequest = z + .object({ displayName: z.string().describe('Trimmed; empty is rejected (400)') }) + .meta({ id: 'DisplayNameRequest' }) + +export const UsernameRequest = z + .object({ username: z.string().describe('Trimmed; must be unique and changes must remain') }) + .meta({ id: 'UsernameRequest' }) + +export const EmailRequest = z + .object({ email: z.string().describe('Must contain "@"; otherwise 400') }) + .meta({ id: 'EmailRequest' }) + +export const PhoneRequest = z + .object({ phone: z.string().describe('Trimmed; empty is rejected (400)') }) + .meta({ id: 'PhoneRequest' }) + +export const IdentityFlagsRequest = z + .object({ identityFlags: z.string().describe('Integer string bitmask; non-numeric is 400') }) + .meta({ id: 'IdentityFlagsRequest' }) + +export const PronounsRequest = z + .object({ pronounFlags: z.string().describe('Integer string bitmask; non-numeric is 400') }) + .meta({ id: 'PronounsRequest' }) + +export const BioRequest = z + .object({ bio: z.string().describe('Free text; empty is allowed') }) + .meta({ id: 'BioRequest' }) + +export const ProfileImageRequest = z + .object({ imageName: z.string().describe('Avatar object key; empty is rejected (400)') }) + .meta({ id: 'ProfileImageRequest' }) diff --git a/apps/accounts/src/test/integration/api.test.ts b/apps/accounts/src/test/integration/api.test.ts index 468fbf2..7cb6b70 100644 --- a/apps/accounts/src/test/integration/api.test.ts +++ b/apps/accounts/src/test/integration/api.test.ts @@ -412,4 +412,51 @@ describe('auth-gated endpoints', () => { const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('888') }) expect(((await me.json()) as { email: string }).email).toBe('ners@recroom.com') }) + + 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 /', + 'GET /account/bulk', + 'GET /account/me', + 'GET /account/search', + 'GET /account/{id}', + 'GET /account/{id}/bio', + 'GET /accountprivacysettings/{id}', + 'GET /parentalcontrol/me', + 'POST /account/create', + 'POST /account/me/email', + 'POST /account/me/phone', + 'PUT /account/me/bio', + 'PUT /account/me/displayname', + 'PUT /account/me/identityflags', + 'PUT /account/me/personalpronouns', + 'PUT /account/me/profileimage', + 'PUT /account/me/username', + ]) + + // 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 60c0ab3..ae2ac49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,12 +62,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 @@ -137,10 +152,10 @@ importers: specifier: workspace:* version: link:../../packages/jwt '@standard-community/standard-json': - specifier: ^0.3.5 + 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 + 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 @@ -149,7 +164,7 @@ importers: 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 + specifier: 12.1.3 version: 12.1.3 workers-tagged-logger: specifier: 1.0.1