diff --git a/apps/leaderboard/package.json b/apps/leaderboard/package.json index e15f4d8..1978c87 100644 --- a/apps/leaderboard/package.json +++ b/apps/leaderboard/package.json @@ -16,8 +16,13 @@ }, "dependencies": { "@repo/hono-helpers": "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/leaderboard/src/leaderboard.app.ts b/apps/leaderboard/src/leaderboard.app.ts index 3ca0dba..3fa4c93 100644 --- a/apps/leaderboard/src/leaderboard.app.ts +++ b/apps/leaderboard/src/leaderboard.app.ts @@ -1,13 +1,16 @@ import { Hono } from 'hono' +import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { useWorkersLogger } from 'workers-tagged-logger' -import { logger, withNotFound, withOnError } from '@repo/hono-helpers' +import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' + +import { GetNearbyScoresBody, GetRanksBody, json, jsonBody, LeaderboardRows } from './openapi' import type { App } from './context' /** - * Leaderboard Worker. Nothing scores anything here yet — the one route answers the shape - * the client parses, with no rows in it. + * Leaderboard Worker. Nothing scores anything here yet — the routes answer the shape the + * client parses, with no rows in them. */ const app = new Hono() .use( @@ -23,9 +26,24 @@ const app = new Hono() .onError(withOnError()) .notFound(withNotFound()) - .get('/', async (c) => { - return c.text('hello, world!') - }) + .get( + '/', + describeRoute({ + tags: ['Service'], + summary: 'Health check', + description: + 'Liveness probe for the leaderboard worker. Answers `text/plain`, not JSON, unlike the other workers’ health checks. No auth.', + responses: { + 200: { + description: 'Service is up', + content: { 'text/plain': { schema: { type: 'string' } } }, + }, + }, + }), + async (c) => { + return c.text('hello, world!') + } + ) // The scores around a player — what the client shows when it opens a leaderboard on // someone rather than at the top. Answers `{ Rows: [...] }`; an EMPTY `Rows` is a @@ -37,12 +55,101 @@ const app = new Hono() // body's shape hasn't been recovered from the client, and this route is how it gets // watched. Read it as text — the shape is unknown, so parsing it would only invent one — // and never fail on it, since an unreadable body must not cost the client its board. - .post('/leaderboard/GetNearbyScores', async (c) => { - const body = await c.req.text().catch(() => '') - logger.info('GetNearbyScores', { body }) + .post( + '/leaderboard/GetNearbyScores', + describeRoute({ + tags: ['Leaderboard'], + summary: 'The scores around a player', + description: [ + 'What the client shows when it opens a leaderboard ON someone rather than at the top.', + '', + 'Nothing is scored or stored on this server yet, so `Rows` is always empty — a complete', + 'answer meaning "this leaderboard has no scores", which the client renders as a blank', + 'board rather than failing. The key is always present; a bare `{}` trips its parser.', + '', + 'The request body is IGNORED, and logged rather than parsed: its shape has not been', + 'recovered from the client, so this route is how it gets watched. An unreadable body is', + 'not an error either — it must not cost the client its board.', + ].join(' '), + requestBody: jsonBody(GetNearbyScoresBody, 'Ignored and logged; shape not yet recovered'), + responses: { 200: json(LeaderboardRows, 'The board, always with no rows') }, + }), + async (c) => { + const body = await c.req.text().catch(() => '') + logger.info('GetNearbyScores', { body }) - const rows: unknown[] = [] - return c.json({ Rows: rows }) - }) + const rows: unknown[] = [] + return c.json({ Rows: rows }) + } + ) + + // A page of the board itself — what the client shows when it opens a leaderboard at the + // top rather than on a player. The body names the slice (`RankStart`/`RankEnd`, both + // inclusive), the board (`RoomId` + `StatChannel`), the viewer (`PlayerId`) and the + // ordering (`FilterType`, `SortAscending`). + // + // Same answer and same rules as GetNearbyScores: `{ Rows: [...] }`, where an EMPTY + // `Rows` is a complete answer meaning "this leaderboard has no scores" and the key must + // be present. Nothing is ranked or stored yet, so the body is ignored — only logged, and + // read as text so an unreadable body can never cost the client its board. + .post( + '/leaderboard/GetRanks', + describeRoute({ + tags: ['Leaderboard'], + summary: 'A page of the board', + description: [ + 'What the client shows when it opens a leaderboard at the TOP rather than on a player.', + 'The body names the slice (`RankStart`/`RankEnd`, both inclusive), the board (`RoomId`', + 'plus `StatChannel`), the viewer (`PlayerId`) and the ordering (`FilterType`,', + '`SortAscending`).', + '', + 'Answers exactly what `GetNearbyScores` answers, under the same rules: `Rows` is always', + 'empty because nothing is scored or stored here yet, and the key is always present.', + '', + 'The body is IGNORED — it is logged, not parsed — so the fields are documented as the', + 'record of what the client asks for rather than as anything the handler reads.', + ].join(' '), + requestBody: jsonBody(GetRanksBody, 'The slice and board the client is asking for'), + responses: { 200: json(LeaderboardRows, 'The board, always with no rows') }, + }), + async (c) => { + const body = await c.req.text().catch(() => '') + logger.info('GetRanks', { body }) + + const rows: unknown[] = [] + return c.json({ Rows: rows }) + } + ) + +// 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 }), + withCleanSpec( + openAPIRouteHandler(app, { + documentation: { + info: { + title: 'recflare leaderboard', + version: '1.0.0', + description: [ + 'Leaderboards for recflare, a private-server reimplementation of the Rec Room', + 'backend — the boards a room keeps for the stats it tracks.', + '', + 'NOTHING IS SCORED HERE YET. Both reads answer `{ "Rows": [] }`, which is a complete', + 'answer rather than an error: an empty list means "this leaderboard has no scores"', + 'and the client renders a blank board. The `Rows` key is always present — a bare', + '`{}` trips the client’s parser.', + '', + 'Neither route reads its request body. Both log it verbatim instead, which is how', + 'the shapes below get recovered from a live client; `GetNearbyScores`’ body is', + 'still unknown for exactly that reason. No route needs a token today.', + ].join('\n'), + }, + servers: [{ url: 'https://leaderboard.recflare.net', description: 'Production' }], + }, + }) + ) +) export default app diff --git a/apps/leaderboard/src/openapi.ts b/apps/leaderboard/src/openapi.ts new file mode 100644 index 0000000..367c2fc --- /dev/null +++ b/apps/leaderboard/src/openapi.ts @@ -0,0 +1,89 @@ +import { resolver } from 'hono-openapi' +import { z } from 'zod' + +import type { OpenAPIV3_1 } from 'openapi-types' + +/** + * OpenAPI schemas for the leaderboard worker. + * + * IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to generate + * the spec and are never wired into `hono-openapi`'s `validator()`. Same rationale as the + * auth/accounts/econ/match workers: a reverse-engineered protocol, lenient handlers, no + * runtime validation. Here it matters more than usual — the handlers do not parse their + * bodies at all yet, so a body that contradicts the schema below is still answered. + * + * Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a + * meta'd schema used in a response emits a `$ref` the framework doesn't always hoist into + * `components.schemas`, leaving a dangling reference. Leaving meta off makes every schema + * inline, which renders correctly in any tool. + */ + +/** 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) } } } +} + +/** + * Convert a zod schema to a plain OpenAPI schema for a request body. `describeRoute`'s + * `requestBody` takes an OpenAPI schema (not a `resolver()`). zod's `$schema` key and + * `additionalProperties: false` are dropped — the handlers read nothing out of these + * bodies at all, so a closed object would misreport them as stricter than they are. + */ +function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject { + const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema) + return jsonSchema as OpenAPIV3_1.SchemaObject +} + +/** An `application/json` request body — what the client posts to both reads. */ +export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject { + return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } } +} + +// ---- Response schemas ------------------------------------------------------ + +/** + * Both leaderboard reads answer this and nothing else: `{ Rows: [...] }`. + * + * `Rows` is EMPTY on this server — nothing scores anything yet — and an empty list is a + * complete answer meaning "this leaderboard has no scores", which the client renders as a + * blank board rather than failing. The key must be present; a bare `{}` trips its parser. + * + * The row shape is therefore undocumented: no live response has ever carried one, so + * describing a row here would be inventing it. It is typed as an open object rather than + * `unknown` so a viewer shows an object in the array. + */ +export const LeaderboardRows = z.object({ + Rows: z + .array(z.looseObject({})) + .describe('The board’s rows. Always empty — nothing is scored or stored yet.'), +}) + +// ---- Request schemas ------------------------------------------------------- + +/** + * The body the client posts to `GetRanks`, e.g. + * `{"RankStart":0,"RankEnd":9,"PlayerId":2,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`. + * + * Recovered from a live client, not from a spec, and IGNORED by the handler today — the + * board is empty whatever it says. It is documented because it is the record of what the + * client asks for, which is what an implementation will have to answer. + */ +export const GetRanksBody = z.object({ + RankStart: z.int().describe('First rank of the slice, 0-based and inclusive'), + RankEnd: z.int().describe('Last rank of the slice, inclusive — 0–9 is the first ten'), + PlayerId: z.int().describe('The player reading the board'), + StatChannel: z.int().describe('Which of the room’s tracked stats to rank on'), + RoomId: z.int().describe('The room whose board is being read'), + FilterType: z.int().describe('Client-side filter selector; its members aren’t known yet'), + SortAscending: z.boolean().describe('false ranks highest-first, the usual leaderboard'), +}) + +/** + * The body posted to `GetNearbyScores`. Its shape has NOT been recovered from the client — + * the handler logs the raw text precisely so it can be — so this documents an open object + * rather than guessing fields. Expect it to name a player and a board the way + * {@link GetRanksBody} does. + */ +export const GetNearbyScoresBody = z + .looseObject({}) + .describe('Unknown shape — logged by the handler so it can be recovered from a live client.') diff --git a/apps/leaderboard/src/test/integration/api.test.ts b/apps/leaderboard/src/test/integration/api.test.ts index ffa002a..2d206d2 100644 --- a/apps/leaderboard/src/test/integration/api.test.ts +++ b/apps/leaderboard/src/test/integration/api.test.ts @@ -15,3 +15,32 @@ it('answers GetNearbyScores with an empty row list', async () => { expect(res.status).toBe(200) expect(await res.json()).toEqual({ Rows: [] }) }) + +it('answers GetRanks with an empty row list', async () => { + const res = await SELF.fetch('https://example.com/leaderboard/GetRanks', { + method: 'POST', + body: JSON.stringify({ + RankStart: 0, + RankEnd: 9, + PlayerId: 2, + StatChannel: 1, + RoomId: 6, + FilterType: 0, + SortAscending: false, + }), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ Rows: [] }) +}) + +it('serves an openapi spec with no dangling refs', async () => { + const res = await SELF.fetch('https://example.com/openapi.json') + expect(res.status).toBe(200) + const spec = (await res.json()) as Record + expect(Object.keys(spec.paths as object).sort()).toEqual([ + '/', + '/leaderboard/GetNearbyScores', + '/leaderboard/GetRanks', + ]) + expect(JSON.stringify(spec).match(/\$ref/g)).toBeNull() +}) diff --git a/apps/www/src/docs.ts b/apps/www/src/docs.ts index 7fdf174..485ea35 100644 --- a/apps/www/src/docs.ts +++ b/apps/www/src/docs.ts @@ -31,6 +31,7 @@ export const DOCUMENTED_SERVICES: ReadonlyArray<{ slug: string; title: string }> { slug: 'playersettings', title: 'playersettings — per-player settings' }, { slug: 'roomcomments', title: 'roomcomments — notes pinned in a room' }, { slug: 'discovery', title: 'discovery — discovery page layouts' }, + { slug: 'leaderboard', title: 'leaderboard — room score boards' }, { slug: 'ai', title: 'ai — game AI access' }, { slug: 'api', title: 'api — everything else' }, ] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30c3e90..ba1b46e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -672,12 +672,27 @@ importers: '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers + '@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