update api docs

This commit is contained in:
Devin Zuczek
2026-07-22 10:12:41 -04:00
parent 881663a4fe
commit 68b98665b2
8 changed files with 225 additions and 150 deletions
+30 -28
View File
@@ -11,7 +11,7 @@ import {
searchAccounts,
updateAccount,
} from '@repo/domain'
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import {
@@ -638,36 +638,38 @@ const app = new Hono<App>()
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 workers `POST /connect/token`.',
withCleanSpec(
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 workers `POST /connect/token`.',
},
},
},
},
},
})
})
)
)
export default app
+37 -35
View File
@@ -2,7 +2,7 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { avatarRoutes } from './routes/avatar'
import { configRoutes } from './routes/config'
@@ -58,43 +58,45 @@ const app = new Hono<App>({ strict: false })
app.get(
'/openapi.json',
describeRoute({ hide: true }),
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare api',
version: '1.0.0',
description: [
'The catch-all Game API for recflare, a private-server reimplementation of the Rec',
'Room backend: everything the client calls that has not been split out into its own',
'worker yet. Today that is config, the friend graph, inventions, saved photos,',
'reputation and the assorted sinks the client hits while loading. Relationships,',
'inventions and images are D1-backed; several endpoints are still stubs, noted per',
'route.',
'',
'Expect this surface to shrink. Paths that also exist on a dedicated worker (avatar,',
'equipment, consumables and objectives on `econ`) are already served there — the',
'client calls that host and the copy here is a stub, which each route says.',
'',
'The shapes 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 parse bodies defensively. 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://api.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare api',
version: '1.0.0',
description: [
'The catch-all Game API for recflare, a private-server reimplementation of the Rec',
'Room backend: everything the client calls that has not been split out into its own',
'worker yet. Today that is config, the friend graph, inventions, saved photos,',
'reputation and the assorted sinks the client hits while loading. Relationships,',
'inventions and images are D1-backed; several endpoints are still stubs, noted per',
'route.',
'',
'Expect this surface to shrink. Paths that also exist on a dedicated worker (avatar,',
'equipment, consumables and objectives on `econ`) are already served there — the',
'client calls that host and the copy here is a stub, which each route says.',
'',
'The shapes 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 parse bodies defensively. 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://api.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
},
})
})
)
)
export default app
+12
View File
@@ -1990,4 +1990,16 @@ describe('openapi', () => {
const raw = await res.text()
expect(raw.match(/\$ref/g)).toBeNull()
})
// `z.int()` carries the safe-integer range as its bounds, which Scalar would
// otherwise show as the example value for every integer field (-9007199254740991).
// withCleanSpec() supplies a placeholder instead; this guards the wrapper staying
// wired up.
test('integer fields carry a placeholder example', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
const raw = await res.text()
const integers = raw.match(/"type":"integer"/g) ?? []
expect(integers.length).toBeGreaterThan(0)
expect(raw.match(/"example":12345/g)?.length).toBe(integers.length)
})
})
+30 -28
View File
@@ -18,7 +18,7 @@ import {
setPresence,
verifyPassword,
} from '@repo/domain'
import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
import {
@@ -720,36 +720,38 @@ const app = new Hono<App>()
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`.',
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare auth',
version: '1.0.0',
description: [
'Authentication and token issuance for recflare, a private-server reimplementation',
'of the Rec Room backend.',
'',
'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
+31 -29
View File
@@ -3,7 +3,7 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { consumeGift, createGift, getGift, getPendingGifts } from '@repo/domain'
import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
@@ -1200,37 +1200,39 @@ const app = new Hono<App>({ strict: false })
app.get(
'/openapi.json',
describeRoute({ hide: true }),
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare econ',
version: '1.0.0',
description: [
'Avatar and economy endpoints for recflare, a private-server reimplementation of the',
'Rec Room backend. The client calls these on the `econ` host; many are also served by',
'the `api` worker. Storefront catalogs are static assets (`sf{N}.json`); balances,',
'inventory, consumables, saved outfits and gift boxes are D1-backed.',
'',
'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 parse bodies defensively. 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://econ.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare econ',
version: '1.0.0',
description: [
'Avatar and economy endpoints for recflare, a private-server reimplementation of the',
'Rec Room backend. The client calls these on the `econ` host; many are also served by',
'the `api` worker. Storefront catalogs are static assets (`sf{N}.json`); balances,',
'inventory, consumables, saved outfits and gift boxes are D1-backed.',
'',
'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 parse bodies defensively. 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://econ.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
},
})
})
)
)
export default app
+32 -30
View File
@@ -23,7 +23,7 @@ import {
setPresence,
setRoomInstanceInProgress,
} from '@repo/domain'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import {
@@ -1084,38 +1084,40 @@ async function sweepExpiredPresence(env: Env): Promise<void> {
app.get(
'/openapi.json',
describeRoute({ hide: true }),
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare match',
version: '1.0.0',
description: [
'Matchmaking and presence for recflare, a private-server reimplementation of the Rec',
'Room backend. Rooms and room instances are D1-backed (matchmaking finds or creates a',
'`room_instance` per session); presence — the instance each player is currently in —',
'lives in the shared `presence` table and expires on a TTL. A cron sweep clears',
'expired presence and frees up instances a crashed player never left.',
'',
'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 parse bodies defensively. 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://match.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare match',
version: '1.0.0',
description: [
'Matchmaking and presence for recflare, a private-server reimplementation of the Rec',
'Room backend. Rooms and room instances are D1-backed (matchmaking finds or creates a',
'`room_instance` per session); presence — the instance each player is currently in —',
'lives in the shared `presence` table and expires on a TTL. A cron sweep clears',
'expired presence and frees up instances a crashed player never left.',
'',
'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 parse bodies defensively. 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://match.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
},
})
})
)
)
// The HTTP surface is a standard Hono app, exported by name so it can be mounted
@@ -0,0 +1,52 @@
import type { Handler, MiddlewareHandler } from 'hono'
/**
* Zod 4 encodes `z.int()` as `{ type: 'integer', minimum: -9007199254740991, maximum:
* 9007199254740991 }` — the safe-integer range. That is accurate, but Scalar (and most
* spec viewers) derive the displayed example from `minimum` when a schema carries no
* `example` of its own, so every integer field in the docs rendered as
* `-9007199254740991`.
*
* The bounds are left alone; we just supply a neutral placeholder so the viewer has
* something better to show.
*/
const PLACEHOLDER_INTEGER = 12345
/** Recursively add a placeholder example to integer schemas that lack one. */
function addIntegerExamples(node: unknown): void {
if (Array.isArray(node)) {
for (const item of node) addIntegerExamples(item)
return
}
if (node === null || typeof node !== 'object') return
const obj = node as Record<string, unknown>
if (obj.type === 'integer' && obj.example === undefined && obj.examples === undefined) {
// Don't contradict a schema that really is narrow (`z.int().max(10)`, an enum-ish
// range) — the placeholder only goes in where it's a legal value.
const min = obj.minimum
const max = obj.maximum
const tooLow = typeof min === 'number' && PLACEHOLDER_INTEGER < min
const tooHigh = typeof max === 'number' && PLACEHOLDER_INTEGER > max
if (!tooLow && !tooHigh) obj.example = PLACEHOLDER_INTEGER
}
for (const value of Object.values(obj)) addIntegerExamples(value)
}
/**
* Wrap `openAPIRouteHandler(...)` so the generated document gets example values for its
* integer fields. Purely cosmetic — nothing about the documented shapes changes.
*
* ```ts
* app.get('/openapi.json', describeRoute({ hide: true }), withCleanSpec(openAPIRouteHandler(app, { ... })))
* ```
*/
export function withCleanSpec(handler: Handler | MiddlewareHandler): Handler {
return async (c, next) => {
const res = await (handler as Handler)(c, next)
if (!(res instanceof Response)) return res as never
const spec: unknown = await res.json()
addIntegerExamples(spec)
return c.json(spec as Record<string, unknown>)
}
}
+1
View File
@@ -3,6 +3,7 @@ export * from './helpers/env'
export { logger } from './helpers/logger'
export { getRequestLogData, type LogDataRequest } from './helpers/request'
export * from './helpers/errors'
export * from './helpers/openapi'
export * from './helpers/url'
export * from './middleware/withCache'
export * from './middleware/withDefaultCors'