From 6a9a1a95b5b016b1ba2b80cdb44288cc0f58fd14 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 25 Aug 2026 12:41:31 -0400 Subject: [PATCH] [openapi] define missing routes --- apps/ai/src/ai.app.ts | 14 +- apps/api/src/openapi.ts | 11 + apps/api/src/routes/avatar.ts | 6 +- apps/discovery/src/discovery.app.ts | 4 +- apps/lists/package.json | 5 +- apps/lists/src/lists.app.ts | 415 +++++++++++++++----- apps/lists/src/openapi.ts | 287 ++++++++++++++ apps/lists/src/test/integration/api.test.ts | 18 + apps/www/src/docs.ts | 1 + 9 files changed, 650 insertions(+), 111 deletions(-) create mode 100644 apps/lists/src/openapi.ts diff --git a/apps/ai/src/ai.app.ts b/apps/ai/src/ai.app.ts index b594efa..25c6783 100644 --- a/apps/ai/src/ai.app.ts +++ b/apps/ai/src/ai.app.ts @@ -103,7 +103,7 @@ const app = new Hono() .get( '/gameai/user/access', describeRoute({ - tags: ['Game AI'], + tags: ['Game AI', '2025'], summary: 'May the caller use Game AI here?', description: [ 'Asked before the client offers any Game AI feature in a room. This server hosts no', @@ -136,7 +136,7 @@ const app = new Hono() .get( '/gameai/room/:roomId{[0-9]+}/spendsummary', describeRoute({ - tags: ['Game AI'], + tags: ['Game AI', '2025'], summary: 'A room’s Game AI spend summary', description: [ 'What a room has spent of its Game AI budget. Refused with the same 200-plus-', @@ -169,7 +169,7 @@ const app = new Hono() .get( '/roomieai/user/access', describeRoute({ - tags: ['Roomie AI'], + tags: ['Roomie AI', '2025'], summary: 'The caller’s Roomie AI energy budget', description: [ 'What Roomie may spend: an energy ceiling, what is left of it, and when it next', @@ -209,7 +209,7 @@ const app = new Hono() .get( '/roomieai/user/facts', describeRoute({ - tags: ['Roomie AI'], + tags: ['Roomie AI', '2025'], summary: 'What Roomie knows about the caller', description: [ 'The memory Roomie is primed with: `UserContext`, a prose profile written from past', @@ -247,7 +247,7 @@ const app = new Hono() .get( '/makerai/user/access', describeRoute({ - tags: ['Maker AI'], + tags: ['Maker AI', '2025'], summary: 'May the caller use Maker AI?', description: [ 'Asked before the client offers Maker AI. Always granted — the gate is about', @@ -287,7 +287,7 @@ const app = new Hono() .get( '/makerai/user/balances', describeRoute({ - tags: ['Maker AI'], + tags: ['Maker AI', '2025'], summary: 'The caller’s Maker AI usage balances', description: [ 'What Maker AI has cost the caller. Live, these meter model usage in DOLLARS against', @@ -331,7 +331,7 @@ const app = new Hono() .post( '/realtime-session/create', describeRoute({ - tags: ['Roomie AI'], + tags: ['Roomie AI', '2025'], summary: 'Open a realtime AI session', description: [ 'Posted when the player actually pulls out an assistant. Live, this mints a short-', diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 983fdb5..9573a00 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -728,6 +728,17 @@ export const PlayerEventResultDto = z.object({ }), }) +/** + * The envelope a delete answers with. Same three keys as {@link PlayerEventResultDto}, + * but both payload fields are null — the event is gone, so there is nothing to redraw + * and the client reads only `Result`. + */ +export const PlayerEventDeletedDto = z.object({ + PlayerEvent: z.null(), + Result: z.int().describe('0 = success'), + TagModifyResult: z.null(), +}) + /** * The JSON body of an event create / update. Every field is optional: create defaults * what's missing, update leaves anything absent at its stored value. The fields may be diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index b406120..db4465e 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -331,7 +331,7 @@ export const avatarRoutes = new Hono({ strict: false }) .get( '/outfits/me', describeRoute({ - tags: ['Avatar'], + tags: ['Avatar', '2025'], summary: 'The caller’s outfit', description: 'The newer outfit read, on a bare un-prefixed path. Served from slot 0 of the shared ' + @@ -374,7 +374,7 @@ export const avatarRoutes = new Hono({ strict: false }) .put( '/outfits/me', describeRoute({ - tags: ['Avatar'], + tags: ['Avatar', '2025'], summary: 'Save the caller’s outfit', description: 'Saves into the shared `outfit` table, in the slot the body names — slot 0 being the ' + @@ -479,7 +479,7 @@ export const avatarRoutes = new Hono({ strict: false }) .get( '/outfits/me/saved', describeRoute({ - tags: ['Avatar'], + tags: ['Avatar', '2025'], summary: 'The caller’s saved outfits', description: 'The wardrobe behind the newer outfit screen. Empty for now: the outfits saved ' + diff --git a/apps/discovery/src/discovery.app.ts b/apps/discovery/src/discovery.app.ts index 9511fe6..5db2c64 100644 --- a/apps/discovery/src/discovery.app.ts +++ b/apps/discovery/src/discovery.app.ts @@ -66,7 +66,7 @@ const app = new Hono() .get( '/sections/bulk', describeRoute({ - tags: ['Discovery'], + tags: ['Discovery', '2025'], summary: 'Look up sections by id', description: [ 'The sections named by the repeated `?id=` query, drawn from the catalogue in', @@ -106,7 +106,7 @@ const app = new Hono() .get( '/sections/pagesource/:type', describeRoute({ - tags: ['Discovery'], + tags: ['Discovery', '2025'], summary: 'Section layout for a page source', description: [ 'The sections of one discovery page, in the order the client draws them. `{type}` IS', diff --git a/apps/lists/package.json b/apps/lists/package.json index 4ffb202..8d65d55 100644 --- a/apps/lists/package.json +++ b/apps/lists/package.json @@ -20,7 +20,10 @@ "@repo/hono-helpers": "workspace:*", "@repo/jwt": "workspace:*", "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/lists/src/lists.app.ts b/apps/lists/src/lists.app.ts index 50a93bc..453e259 100644 --- a/apps/lists/src/lists.app.ts +++ b/apps/lists/src/lists.app.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono' +import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { useWorkersLogger } from 'workers-tagged-logger' import { @@ -10,10 +11,30 @@ import { getRecentlyUpdatedRooms, getVisitedRooms, } from '@repo/domain' -import { withNotFound, withOnError } from '@repo/hono-helpers' +import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' import { resolveCuratedList, serializeCuratedList } from './curated-lists' +import { + ALGORITHMIC_LIST_PARAM, + ALGORITHMIC_TYPE_PARAM, + AlgorithmicList, + AUTHED, + ContextualFeaturesAck, + CREATOR_ACCOUNT_ID_PARAM, + CuratedListRead, + CuratedListSaved, + CuratedListsBulk, + form, + ITEM_ID_PARAM, + json, + LIST_IDS_PARAM, + LIST_NAME_PARAM, + LIST_TYPE_PARAM, + SAVE_LIST_NAME_PARAM, + SaveItemBody, + UNAUTHORIZED_RESPONSE, +} from './openapi' import type { Context } from 'hono' import type { CuratedList, Room } from '@repo/domain' @@ -305,29 +326,62 @@ const app = new Hono() .onError(withOnError()) .notFound(withNotFound()) - .get('/', async (c) => { - return c.text('hello, world!') - }) + // Root health check. + .get( + '/', + describeRoute({ + tags: ['Service'], + summary: 'Health check', + description: 'Liveness probe for the lists worker. No auth; the body is plain text.', + responses: { + 200: { + description: 'Service is up', + content: { 'text/plain': { schema: { type: 'string', example: 'hello, world!' } } }, + }, + }, + }), + async (c) => { + return c.text('hello, world!') + } + ) // Bulk curated-list lookup — the client asks for a set of lists by repeating `?id=`. // Nothing curates lists here yet, so this serves one canned list: `ItemIds` are strings // (not numbers) and `Description` may be null, but `ImageName` has to be a string — the // client's parser reads it straight into a string field. A 404 shows as a failed load // instead, so an unknown id still answers 200. - .get('/curatedlists/bulk', async (c) => { - return c.json([ - { - ListId: 17859340, - CreatorAccountId: 1, - Name: 'My List', - Description: null, - ImageName: '', - Type: ListEntityType.Rooms, - ItemIds: ['123', '456'], - CreatedAt: '2025-07-18T00:00:00Z', - }, - ]) - }) + .get( + '/curatedlists/bulk', + describeRoute({ + tags: ['Lists', '2025'], + summary: 'Curated lists by id', + description: [ + 'A set of curated lists, asked for by repeating `?id=`. Nothing curates lists here yet,', + 'so this serves ONE canned list whatever is asked for — an unknown id included, because', + 'a 404 shows as a row that failed to load rather than one the client hides.', + '', + 'The canned list is shaped the way the client parses one: `ItemIds` are strings rather', + 'than numbers, `Description` may be null, and `ImageName` has to be a string — the', + 'client reads it straight into a string field.', + ].join('\n'), + parameters: [LIST_IDS_PARAM], + responses: { 200: json(CuratedListsBulk, 'The canned list, as a one-element array') }, + }), + async (c) => { + return c.json([ + { + ListId: 17859340, + CreatorAccountId: 1, + Name: 'My List', + Description: null, + ImageName: '', + Type: ListEntityType.Rooms, + ItemIds: ['123', '456'], + CreatedAt: '2025-07-18T00:00:00Z', + }, + ]) + } + ) // One curated list (`GET /curatedlists?creatorAccountId=&type=&name=`). The client reads // back ONE list object — not a collection — and asks for two different things through the @@ -350,21 +404,57 @@ const app = new Hono() // page's heading, which reads as real content rather than as a missing list. The // exceptions are the client's own reserved playlists and a request naming no list at all; // both are real answers, not misses (see `resolveCuratedList`). - .get('/curatedlists', async (c) => { - const creatorAccountId = c.req.query('creatorAccountId') - const type = c.req.query('type') - const name = c.req.query('name') + .get( + '/curatedlists', + describeRoute({ + tags: ['Lists', '2025'], + summary: 'One curated list', + description: [ + 'ONE list object — not a collection — asked for with the same three parameters whether', + 'the client wants a discovery PAGE’s row set or a PLAYER’s own playlist:', + '', + '- A page’s rows are a static capture in `static/curated-lists.json`, whose `ItemIds`', + ' are the discovery section keys the page is built from (not room ids).', + '- A player’s playlist lives in D1, in the `list` / `list_item` tables this worker owns.', + ' `__SavedForLater_Rooms` is the one the client creates for itself — the Play menu’s', + ' “Saved for Later” row, asked for with the player’s own id and `type=1` (Rooms), so its', + ' `ItemIds` are room ids.', + '', + 'D1 is asked FIRST, so a player’s own list wins over a capture that happens to share its', + 'name: the captures are this server’s fixtures and a player’s list is their data.', + '', + 'Not auth-gated — the client names the owner rather than proving it, `Accessibility` is a', + 'property of the list rather than of the reader, and the answer is only ever ids the', + 'client then resolves itself.', + '', + 'A name matching NEITHER 404s: answering it with an unrelated capture puts one page’s', + 'rows under another page’s heading, which reads as real content rather than as a missing', + 'list. The two exceptions are real answers rather than misses — a reserved `__` playlist', + 'nobody owns yet comes back EMPTY, and a request naming no list at all gets the page', + 'default for its `type`.', + ].join('\n'), + parameters: [CREATOR_ACCOUNT_ID_PARAM, LIST_TYPE_PARAM, LIST_NAME_PARAM], + responses: { + 200: json(CuratedListRead, 'The list'), + 404: { description: 'No list of that name, and it is not a reserved playlist' }, + }, + }), + async (c) => { + const creatorAccountId = c.req.query('creatorAccountId') + const type = c.req.query('type') + const name = c.req.query('name') - const list = - (await ownedList(c, creatorAccountId, type, name)) ?? - resolveCuratedList(creatorAccountId, type, name) - if (list === undefined) return c.notFound() + const list = + (await ownedList(c, creatorAccountId, type, name)) ?? + resolveCuratedList(creatorAccountId, type, name) + if (list === undefined) return c.notFound() - // Serialized by hand rather than through `c.json`: the reference's `ListId`s are - // 64-bit and are carried as strings so their digits survive being parsed — see - // `serializeCuratedList`, which puts them back on the wire as numbers. - return c.body(serializeCuratedList(list), 200, { 'content-type': 'application/json' }) - }) + // Serialized by hand rather than through `c.json`: the reference's `ListId`s are + // 64-bit and are carried as strings so their digits survive being parsed — see + // `serializeCuratedList`, which puts them back on the wire as numbers. + return c.body(serializeCuratedList(list), 200, { 'content-type': 'application/json' }) + } + ) // Save an item into one of the caller's own lists, creating the list if they don't have // it yet (`PUT /curatedlists/:name/items/:itemId/createlistifneeded`) — what the client @@ -378,37 +468,69 @@ const app = new Hono() // // Answers the list as it now stands rather than an acknowledgement, so the row the client // re-renders is the one this call just changed. - .put('/curatedlists/:name/items/:itemId/createlistifneeded', async (c) => { - const accountId = await authedId(c) - if (accountId === null) return unauthorized(c) - - const body = await c.req.parseBody().catch(() => ({}) as Record) - const list = await addPlayerListItem( - c.env.DB, - { - creatorAccountId: accountId, - name: c.req.param('name'), - // The `ListEntityType`, saying what the item ids in this list ARE. Rooms when the - // body names none: every list the client creates this way is a room list, and the - // type is part of the list's identity, so guessing another would strand the list - // where the client's own read (`?type=1`) can't find it. - type: intField(body, c, 'type', ListEntityType.Rooms), - // PRIVATE by default. A list a player builds for themselves is theirs to see; - // the client sends `accessibility=0` and this only applies on creation anyway. - accessibility: intField(body, c, 'accessibility', Accessibility.Private), + .put( + '/curatedlists/:name/items/:itemId/createlistifneeded', + describeRoute({ + tags: ['Lists', '2025'], + summary: 'Save an item into the caller’s list', + description: [ + 'Saves an item into one of the caller’s own lists, creating the list when they have none', + 'by that name — what the client calls when someone saves a room for later. The path names', + 'the list and the item; the form body carries `accessibility` and `type`, both of which', + 'apply only on creation.', + '', + 'AUTH-GATED, and the owner is the TOKEN’s account: unlike the read, this call names no', + '`creatorAccountId`, so the only account it could mean is the caller’s — and taking an', + 'owner from the client would let anyone write into anyone’s list.', + '', + 'Answers the list as it now stands rather than an acknowledgement, so the row the client', + 're-renders is the one this call just changed. Saving the same item twice leaves it in', + 'the list once.', + '', + 'The response drops `Accessibility`, which the read keeps. That is a real difference in', + 'what the client is sent, not an oversight — every other key, and their order, is the', + 'read’s.', + ].join('\n'), + security: AUTHED, + parameters: [SAVE_LIST_NAME_PARAM, ITEM_ID_PARAM], + requestBody: form(SaveItemBody, 'Applied only when the list is created'), + responses: { + 200: json(CuratedListSaved, 'The list as it now stands, without `Accessibility`'), + 401: UNAUTHORIZED_RESPONSE, }, - c.req.param('itemId') - ) + }), + async (c) => { + const accountId = await authedId(c) + if (accountId === null) return unauthorized(c) - // The SAVE's projection of a list drops `Accessibility`; the read's keeps it. That is a - // real difference in what the client is sent, not an oversight — don't unify them. - // Every other key, and their order, is the read's. - const { Accessibility: _accessibility, ...saved } = list + const body = await c.req.parseBody().catch(() => ({}) as Record) + const list = await addPlayerListItem( + c.env.DB, + { + creatorAccountId: accountId, + name: c.req.param('name'), + // The `ListEntityType`, saying what the item ids in this list ARE. Rooms when the + // body names none: every list the client creates this way is a room list, and the + // type is part of the list's identity, so guessing another would strand the list + // where the client's own read (`?type=1`) can't find it. + type: intField(body, c, 'type', ListEntityType.Rooms), + // PRIVATE by default. A list a player builds for themselves is theirs to see; + // the client sends `accessibility=0` and this only applies on creation anyway. + accessibility: intField(body, c, 'accessibility', Accessibility.Private), + }, + c.req.param('itemId') + ) - // Serialized by hand for the same reason the read is: the 64-bit `ListId` has to reach - // the client unquoted with every digit intact. - return c.body(serializeCuratedList(saved), 200, { 'content-type': 'application/json' }) - }) + // The SAVE's projection of a list drops `Accessibility`; the read's keeps it. That is a + // real difference in what the client is sent, not an oversight — don't unify them. + // Every other key, and their order, is the read's. + const { Accessibility: _accessibility, ...saved } = list + + // Serialized by hand for the same reason the read is: the 64-bit `ListId` has to reach + // the client unquoted with every digit intact. + return c.body(serializeCuratedList(saved), 200, { 'content-type': 'application/json' }) + } + ) // One discovery ROW's contents (`GET /algorithmiclists/:list?type=1`). `:list` is the row // key the curated page above lists in its `ItemIds` (e.g. @@ -422,54 +544,151 @@ const app = new Hono() // instead of one it hides. `Type` is echoed back from the query: it // tells the client what the `Id`s ARE (rooms, players, …), so answering with a type the // caller didn't ask for would have it resolve the ids against the wrong service. - .get('/algorithmiclists/:list', async (c) => { - // Echoed, but only when it fits the byte the client reads it back into — anything - // outside 0–255 can't round-trip, so a nonsense `?type=` gets the default instead of a - // number that would break the response on the way in. - const type = Number.parseInt(c.req.query('type') ?? '', 10) - const echoed = type >= 0 && type <= MAX_LIST_ENTITY_TYPE ? type : DEFAULT_ALGORITHMIC_LIST_TYPE + .get( + '/algorithmiclists/:list', + describeRoute({ + tags: ['Lists', '2025'], + summary: 'One discovery row’s contents', + description: [ + 'The entities that fill one discovery row. `{list}` is the row key a curated page lists', + 'in its `ItemIds` (e.g. `Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore`), and', + 'only the IDS travel — the client resolves each room or item itself.', + '', + '`HotList`, `recentlyupdated` and `new` are ranked live off the same room tables the', + '`rooms` worker’s browse feeds read, so a row and its feed can’t disagree. The', + '`*_algoendpoint` category rows serve the public rooms carrying one tag, busiest first.', + '`recentlyvisited` is per-caller and is the one row that reads the token; without one it', + 'answers EMPTY rather than 401ing, since canned rooms would claim the caller visited', + 'rooms they never did — and an empty carousel is what a brand-new account legitimately', + 'has. A couple of store rows are hand-picked id lists.', + '', + 'Every other row — an unknown key included — answers an EMPTY 200 rather than a 404,', + 'which the client renders as a row that failed to load instead of one it hides.', + ].join('\n'), + parameters: [ALGORITHMIC_LIST_PARAM, ALGORITHMIC_TYPE_PARAM], + responses: { 200: json(AlgorithmicList, 'The row’s entities, possibly none') }, + }), + async (c) => { + // Echoed, but only when it fits the byte the client reads it back into — anything + // outside 0–255 can't round-trip, so a nonsense `?type=` gets the default instead of a + // number that would break the response on the way in. + const type = Number.parseInt(c.req.query('type') ?? '', 10) + const echoed = + type >= 0 && type <= MAX_LIST_ENTITY_TYPE ? type : DEFAULT_ALGORITHMIC_LIST_TYPE - const key = c.req.param('list').toLowerCase() + const key = c.req.param('list').toLowerCase() - // A per-caller row needs to know who is asking, so it is the one kind of row that - // reads the token. No token — or one that doesn't resolve — answers an EMPTY row - // rather than 401ing or falling through to the canned entities: this is a row about - // what the caller has done, and canned rooms would claim they visited rooms they - // never did. An empty carousel is also what a brand-new account legitimately has. - const personal = PERSONAL_ROW_FEEDS[key] - if (personal !== undefined) { - const accountId = await authedId(c) - const rooms = accountId === null ? [] : await personal(c.env.DB, accountId) - return c.json({ Type: echoed, Entities: toEntities(rooms) }) + // A per-caller row needs to know who is asking, so it is the one kind of row that + // reads the token. No token — or one that doesn't resolve — answers an EMPTY row + // rather than 401ing or falling through to the canned entities: this is a row about + // what the caller has done, and canned rooms would claim they visited rooms they + // never did. An empty carousel is also what a brand-new account legitimately has. + const personal = PERSONAL_ROW_FEEDS[key] + if (personal !== undefined) { + const accountId = await authedId(c) + const rooms = accountId === null ? [] : await personal(c.env.DB, accountId) + return c.json({ Type: echoed, Entities: toEntities(rooms) }) + } + + // A row with a live feed behind it serves that; everything else gets the canned + // entities. Only the ids travel — the client resolves each room itself — so the + // ranking is read for its order and the room blobs are thrown away. + const feed = ROW_FEEDS[key] + if (feed !== undefined) { + return c.json({ Type: echoed, Entities: toEntities(await feed(c.env.DB)) }) + } + + // Then the hand-picked rows, which are already entities: the ids are the answer. + const canned = STATIC_ROW_ENTITIES[key] + if (canned !== undefined) { + return c.json({ Type: echoed, Entities: canned }) + } + + return c.json({ Type: echoed, Entities: ALGORITHMIC_LIST_ENTITIES }) } - - // A row with a live feed behind it serves that; everything else gets the canned - // entities. Only the ids travel — the client resolves each room itself — so the - // ranking is read for its order and the room blobs are thrown away. - const feed = ROW_FEEDS[key] - if (feed !== undefined) { - return c.json({ Type: echoed, Entities: toEntities(await feed(c.env.DB)) }) - } - - // Then the hand-picked rows, which are already entities: the ids are the answer. - const canned = STATIC_ROW_ENTITIES[key] - if (canned !== undefined) { - return c.json({ Type: echoed, Entities: canned }) - } - - return c.json({ Type: echoed, Entities: ALGORITHMIC_LIST_ENTITIES }) - }) + ) // Contextual features — the client posts the context it's in and reads back whether the // call was accepted. Auth-gated, and the answer is a bare `{ success, error_id, error }` // with no payload: the reference server acknowledges the post and carries nothing back, // so there is nothing here to serve statically beyond the acknowledgement itself. The // body is read for the log only. - .post('/contextualfeatures', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) + .post( + '/contextualfeatures', + describeRoute({ + tags: ['Lists', '2025'], + summary: 'Acknowledge a contextual-features post', + description: [ + 'The client posts the context it is in and reads back whether the call was accepted.', + 'Auth-gated, and the answer is a bare `{ success, error_id, error }` with no payload:', + 'the reference server acknowledges the post and carries nothing back, so there is', + 'nothing here to serve beyond the acknowledgement itself. The body is read for the log', + 'only.', + ].join('\n'), + security: AUTHED, + responses: { + 200: json(ContextualFeaturesAck, 'Accepted'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) - return c.json({ success: true, error_id: null, error: null }) - }) + return c.json({ success: true, error_id: null, error: null }) + } + ) + +// 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 lists', + version: '1.0.0', + description: [ + 'Curated and algorithmic lists for recflare, a private-server reimplementation of the', + 'Rec Room backend. A discovery page is built from these: the `discovery` worker says', + 'which carousels a page has, and this worker says what is in them.', + '', + 'Two kinds of list. A CURATED list is named — either a static capture of a page’s row', + 'set, or a player’s own playlist in D1 (`__SavedForLater_Rooms`, the Play menu’s', + '“Saved for Later”). An ALGORITHMIC list is a ranking asked for by row slug: the hot,', + 'recently-updated and new feeds, the room categories, and the caller’s own recently', + 'visited rooms.', + '', + 'Only IDS travel. Every list answers ids the client resolves against the `rooms` and', + '`commerce` workers itself, which is why a list carries a `Type` saying what its ids', + 'ARE — answering with a type the caller didn’t ask for would have it look the ids up', + 'against the wrong service.', + '', + 'A row with nothing behind it answers an empty 200 rather than a 404: the client', + 'renders a failed row for an error and hides an empty one, and an empty carousel is', + 'the honest answer for a ranking this server has nothing for.', + '', + 'Reads are unauthenticated — the client names the owner rather than proving it, and a', + 'list is only ever ids. Writing needs a token, since the list written into is the', + 'caller’s own.', + ].join('\n'), + }, + servers: [{ url: 'https://lists.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/lists/src/openapi.ts b/apps/lists/src/openapi.ts new file mode 100644 index 0000000..f393d6a --- /dev/null +++ b/apps/lists/src/openapi.ts @@ -0,0 +1,287 @@ +import { resolver } from 'hono-openapi' +import { z } from 'zod' + +import type { OpenAPIV3_1 } from 'openapi-types' + +/** + * OpenAPI schemas for the lists 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 other workers: a reverse-engineered protocol, lenient handlers, no runtime + * validation. + * + * 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) } } } +} + +function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject { + const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema) + return jsonSchema as OpenAPIV3_1.SchemaObject +} + +/** + * A form-encoded request body. The client posts `application/x-www-form-urlencoded`; Hono's + * `parseBody()` also reads multipart, so both are documented on the one body. + */ +export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject { + const f = toOpenApiSchema(schema) + return { + description, + content: { + 'application/x-www-form-urlencoded': { schema: f }, + 'multipart/form-data': { schema: f }, + }, + } +} + +/** The empty-body 401 the auth-gated routes return. */ +export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' } + +/** Bearer-JWT security requirement, for the auth-gated routes. */ +export const AUTHED = [{ bearerAuth: [] }] + +// ---- Parameters ------------------------------------------------------------ + +/** + * The repeated `?id=` query the bulk lookup selects on — repetition, not a delimiter, so + * `explode: true` form style rather than one comma-joined value. + * + * Documented for the shape of the request only: nothing curates lists here yet, so the + * answer is the same canned list whatever is asked for (see `CuratedListsBulk`). + */ +export const LIST_IDS_PARAM: OpenAPIV3_1.ParameterObject = { + name: 'id', + in: 'query', + required: false, + description: [ + 'A list id to look up, repeated once per list wanted. Ignored today — the canned list', + 'is served whatever is asked for, an unknown id included, because a 404 renders as a', + 'row that failed to load rather than one the client hides.', + ].join(' '), + style: 'form', + explode: true, + schema: { type: 'array', items: { type: 'string' } }, + example: ['17859340'], +} + +/** + * `?creatorAccountId=` on the read. NOT auth: the client asks for its own lists by naming + * its account id, and `Accessibility` is a property of the list rather than of the reader. + */ +export const CREATOR_ACCOUNT_ID_PARAM: OpenAPIV3_1.ParameterObject = { + name: 'creatorAccountId', + in: 'query', + required: false, + description: [ + 'Who owns the list. Matched exactly against a stored list, and used as the most', + 'specific key against the static captures — a creator nothing owns falls back to', + 'matching on type and name. Echoed back on an unowned reserved list, so the client', + 'still sees the list it asked for.', + ].join(' '), + schema: { type: 'integer', example: 42 }, +} + +/** + * `?type=` on the read — the `ListEntityType`, which is what the `ItemIds` ARE. See the + * note on `resolveCuratedList`: it is NOT the page-source enum, even though the captures + * are pages. + */ +export const LIST_TYPE_PARAM: OpenAPIV3_1.ParameterObject = { + name: 'type', + in: 'query', + required: false, + description: [ + 'The `ListEntityType` — what the list’s `ItemIds` are: 0 Accounts · 1 Rooms ·', + '2 Inventions · 3 CustomAvatarItems · 4 PurchasableItems · 5 Generic · 6 ChipAndPort ·', + '7 DiscoverySection · 8 DiscoverySectionSubType. Part of a list’s identity, not a', + 'filter: `__SavedForLater_Rooms` is asked for with `type=1` (its items are room ids)', + 'while every static capture is `type=7` (its items are discovery section keys).', + ].join(' '), + schema: { type: 'integer', example: 1 }, +} + +/** `?name=` on the read — the list itself. */ +export const LIST_NAME_PARAM: OpenAPIV3_1.ParameterObject = { + name: 'name', + in: 'query', + required: false, + description: [ + 'The list’s name, matched case-insensitively (the casing that arrives is the', + 'client’s). Naming NO list asks for the page default for `type`; naming one that', + 'matches nothing is a 404, unless it is one of the client’s own reserved `__`', + 'playlists, which answers empty.', + ].join(' '), + schema: { type: 'string', example: '__SavedForLater_Rooms' }, +} + +/** `{name}` on the save — the list written into, created when the caller has none. */ +export const SAVE_LIST_NAME_PARAM: OpenAPIV3_1.ParameterObject = { + name: 'name', + in: 'path', + required: true, + description: [ + 'The caller’s list to save into, created if they have none by that name.', + '`__SavedForLater_Rooms` is the one the client creates for itself — the Play menu’s', + '“Saved for Later” row.', + ].join(' '), + schema: { type: 'string', example: '__SavedForLater_Rooms' }, +} + +/** `{itemId}` on the save — what goes into the list, as a string. */ +export const ITEM_ID_PARAM: OpenAPIV3_1.ParameterObject = { + name: 'itemId', + in: 'path', + required: true, + description: [ + 'The item to save, as a string — a room id for the room lists the client builds this', + 'way. Saving the same item twice leaves it in the list once.', + ].join(' '), + schema: { type: 'string', example: '953' }, +} + +/** + * `{list}` on a discovery row — the row SLUG, which is what a curated page's `ItemIds` and + * a discovery section's `sourceMetadata` name. + */ +export const ALGORITHMIC_LIST_PARAM: OpenAPIV3_1.ParameterObject = { + name: 'list', + in: 'path', + required: true, + description: [ + 'The row key, matched case-insensitively. `HotList`, `recentlyupdated` and `new` are', + 'ranked for real; `recentlyvisited` is per-caller; the seven `*_algoendpoint` category', + 'rows serve the public rooms carrying one tag; `summerpartycarousel` and `newitems` are', + 'hand-picked store ids. Every other key — an unknown one included — answers an empty', + 'row with a 200.', + ].join(' '), + // Deliberately not an `enum`: an unknown slug is a legal request that answers an empty + // row, so freezing today's keys here would document a rejection that never happens. + schema: { type: 'string', example: 'HotList' }, +} + +/** `?type=` on a discovery row — echoed back, saying what the row's `Id`s are. */ +export const ALGORITHMIC_TYPE_PARAM: OpenAPIV3_1.ParameterObject = { + name: 'type', + in: 'query', + required: false, + description: [ + 'The `ListEntityType` the caller wants the row’s ids read as, ECHOED back on the', + 'response — it tells the client which service to resolve the ids against. A BYTE on', + 'the client, so a value outside 0–255 (or none at all) is answered with 1, Rooms,', + 'which is what the client always asks for.', + ].join(' '), + schema: { type: 'integer', minimum: 0, maximum: 255, example: 1 }, +} + +// ---- Response schemas ------------------------------------------------------ + +/** + * One curated list as the client parses it, out of D1 or out of a static capture. + * + * `ListId` is a NUMBER on the wire — a quoted id fails the client's parser — and the + * reference's ids are 64-bit (`624765592684307326`), past what a JS number holds exactly. + * They are carried as strings internally and unquoted on the way out, so this is `number` + * rather than a bounded integer. + */ +const CuratedListFields = { + ListId: z + .number() + .describe('64-bit; 0 on an unowned reserved list, since nothing was stored to have an id'), + CreatorAccountId: z.int().describe('The owner; echoed from the query on a reserved list'), + Name: z.string(), + Description: z.string().nullable(), + ImageName: z + .string() + .describe( + 'Must be a STRING — the client reads it straight into a string field. `DefaultRoomImage.jpg` where nothing set one; empty or null renders a blank tile.' + ), + Type: z.int().describe('The `ListEntityType` — what the `ItemIds` are'), + ItemIds: z + .string() + .array() + .describe( + 'Strings even where they stand for numeric ids, in the order they were added — which is the order the row displays them.' + ), + CreatedAt: z.string().describe('ISO-8601 UTC'), +} + +/** + * The READ's projection, which keeps `Accessibility`. The save's drops it — a real + * difference in what the client is sent, not an oversight; don't unify them. + */ +export const CuratedListRead = z.object({ + ...CuratedListFields, + Accessibility: z + .int() + .optional() + .describe( + 'The `Accessibility` enum — 0 Private · 1 Public (its Unlisted/Dev members exist but nothing sets one on a list). Carried by every list the read serves, stored or captured; absent from the canned bulk list and from the save’s response.' + ), +}) + +/** The SAVE's projection: every key of the read, in the read's order, minus `Accessibility`. */ +export const CuratedListSaved = z.object(CuratedListFields) + +/** `GET /curatedlists/bulk` — a list per id asked for; today one canned list, always. */ +export const CuratedListsBulk = CuratedListRead.array() + +/** + * One entity of a row. `Id` is a STRING even though most of what a row names (rooms, store + * items) is numbered, and `Context` is where the reference attributes the ranking or + * experiment that produced the entity — nothing here produces one, so it is null on every + * entity rather than a made-up context the client would carry into telemetry. + */ +export const ListEntityDto = z.object({ + Id: z.string().describe('The room/item id the client resolves itself'), + Context: z.string().nullable().describe('Ranking attribution; always null here'), +}) + +/** + * `GET /algorithmiclists/{list}` — one discovery row's contents. Only ids travel: the + * client resolves each room or item against the `rooms`/`commerce` workers itself. + */ +export const AlgorithmicList = z.object({ + Type: z.int().describe('The `ListEntityType`, echoed from `?type=` — see the parameter'), + Entities: ListEntityDto.array().describe('Empty for a row with nothing behind it'), +}) + +/** + * `POST /contextualfeatures` — the bare acknowledgement, with no payload. The reference + * server carries nothing back, so there is nothing here to serve beyond the ack itself. + */ +export const ContextualFeaturesAck = z.object({ + success: z.literal(true), + error_id: z.null(), + error: z.null(), +}) + +// ---- Request schemas ------------------------------------------------------- + +/** + * The form-encoded save body the client sends (`accessibility=0&type=1`). Both fields are + * also read off the query string: the same parameters ride the query everywhere else on + * this worker, and a body that failed to parse would otherwise silently create a list with + * the wrong type. + */ +export const SaveItemBody = z.object({ + type: z + .string() + .optional() + .describe( + 'The `ListEntityType` the list is created with (integer, as text). Rooms (1) when absent — every list the client creates this way is a room list, and the type is part of the list’s identity, so another value would strand it where the client’s own `?type=1` read can’t find it.' + ), + accessibility: z + .string() + .optional() + .describe( + 'The `Accessibility` enum — 0 Private · 1 Public (integer, as text). PRIVATE when absent: a list a player builds for themselves is theirs to see, and the client sends `accessibility=0`. Applied only on creation — a later save leaves an existing list’s accessibility alone.' + ), +}) diff --git a/apps/lists/src/test/integration/api.test.ts b/apps/lists/src/test/integration/api.test.ts index d3489eb..c6a6dbe 100644 --- a/apps/lists/src/test/integration/api.test.ts +++ b/apps/lists/src/test/integration/api.test.ts @@ -936,3 +936,21 @@ it('serves the rows when there are more rooms than D1 allows bound parameters', await env.DB.prepare('DELETE FROM room WHERE room_id >= ?1').bind(FIRST).run() await env.DB.prepare('DELETE FROM room_tag WHERE room_id >= ?1').bind(FIRST).run() }) + +it('generates a spec with no dangling $refs', async () => { + const res = await SELF.fetch(`${ORIGIN}/openapi.json`) + expect(res.status).toBe(200) + const spec = (await res.json()) as { paths: Record } + expect(Object.keys(spec.paths)).toEqual( + expect.arrayContaining([ + '/curatedlists', + '/curatedlists/bulk', + '/curatedlists/{name}/items/{itemId}/createlistifneeded', + '/algorithmiclists/{list}', + '/contextualfeatures', + ]) + ) + // The spec route keeps itself out of its own output. + expect(Object.keys(spec.paths)).not.toContain('/openapi.json') + expect(JSON.stringify(spec).match(/\$ref/g)).toBeNull() +}) diff --git a/apps/www/src/docs.ts b/apps/www/src/docs.ts index 485ea35..97d4745 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: 'lists', title: 'lists — curated & algorithmic lists' }, { slug: 'leaderboard', title: 'leaderboard — room score boards' }, { slug: 'ai', title: 'ai — game AI access' }, { slug: 'api', title: 'api — everything else' },