mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
accounts documentation
This commit is contained in:
+91
-29
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
+490
-151
@@ -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<App>, account: Account): Promise<voi
|
||||
}
|
||||
}
|
||||
|
||||
/** The empty-body 401 every auth-gated route returns; reused across their specs. */
|
||||
const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
|
||||
|
||||
/** Bearer-JWT security requirement, for the auth-gated routes. */
|
||||
const AUTHED = [{ bearerAuth: [] }]
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -132,203 +163,511 @@ const app = new Hono<App>()
|
||||
.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
|
||||
|
||||
@@ -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' })
|
||||
@@ -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<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 /',
|
||||
'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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user