[openapi] define missing routes

This commit is contained in:
Devin Zuczek
2026-08-25 12:41:31 -04:00
parent 3b5e47c39d
commit 6a9a1a95b5
9 changed files with 650 additions and 111 deletions
+7 -7
View File
@@ -103,7 +103,7 @@ const app = new Hono<App>()
.get( .get(
'/gameai/user/access', '/gameai/user/access',
describeRoute({ describeRoute({
tags: ['Game AI'], tags: ['Game AI', '2025'],
summary: 'May the caller use Game AI here?', summary: 'May the caller use Game AI here?',
description: [ description: [
'Asked before the client offers any Game AI feature in a room. This server hosts no', 'Asked before the client offers any Game AI feature in a room. This server hosts no',
@@ -136,7 +136,7 @@ const app = new Hono<App>()
.get( .get(
'/gameai/room/:roomId{[0-9]+}/spendsummary', '/gameai/room/:roomId{[0-9]+}/spendsummary',
describeRoute({ describeRoute({
tags: ['Game AI'], tags: ['Game AI', '2025'],
summary: 'A rooms Game AI spend summary', summary: 'A rooms Game AI spend summary',
description: [ description: [
'What a room has spent of its Game AI budget. Refused with the same 200-plus-', 'What a room has spent of its Game AI budget. Refused with the same 200-plus-',
@@ -169,7 +169,7 @@ const app = new Hono<App>()
.get( .get(
'/roomieai/user/access', '/roomieai/user/access',
describeRoute({ describeRoute({
tags: ['Roomie AI'], tags: ['Roomie AI', '2025'],
summary: 'The callers Roomie AI energy budget', summary: 'The callers Roomie AI energy budget',
description: [ description: [
'What Roomie may spend: an energy ceiling, what is left of it, and when it next', 'What Roomie may spend: an energy ceiling, what is left of it, and when it next',
@@ -209,7 +209,7 @@ const app = new Hono<App>()
.get( .get(
'/roomieai/user/facts', '/roomieai/user/facts',
describeRoute({ describeRoute({
tags: ['Roomie AI'], tags: ['Roomie AI', '2025'],
summary: 'What Roomie knows about the caller', summary: 'What Roomie knows about the caller',
description: [ description: [
'The memory Roomie is primed with: `UserContext`, a prose profile written from past', 'The memory Roomie is primed with: `UserContext`, a prose profile written from past',
@@ -247,7 +247,7 @@ const app = new Hono<App>()
.get( .get(
'/makerai/user/access', '/makerai/user/access',
describeRoute({ describeRoute({
tags: ['Maker AI'], tags: ['Maker AI', '2025'],
summary: 'May the caller use Maker AI?', summary: 'May the caller use Maker AI?',
description: [ description: [
'Asked before the client offers Maker AI. Always granted — the gate is about', 'Asked before the client offers Maker AI. Always granted — the gate is about',
@@ -287,7 +287,7 @@ const app = new Hono<App>()
.get( .get(
'/makerai/user/balances', '/makerai/user/balances',
describeRoute({ describeRoute({
tags: ['Maker AI'], tags: ['Maker AI', '2025'],
summary: 'The callers Maker AI usage balances', summary: 'The callers Maker AI usage balances',
description: [ description: [
'What Maker AI has cost the caller. Live, these meter model usage in DOLLARS against', 'What Maker AI has cost the caller. Live, these meter model usage in DOLLARS against',
@@ -331,7 +331,7 @@ const app = new Hono<App>()
.post( .post(
'/realtime-session/create', '/realtime-session/create',
describeRoute({ describeRoute({
tags: ['Roomie AI'], tags: ['Roomie AI', '2025'],
summary: 'Open a realtime AI session', summary: 'Open a realtime AI session',
description: [ description: [
'Posted when the player actually pulls out an assistant. Live, this mints a short-', 'Posted when the player actually pulls out an assistant. Live, this mints a short-',
+11
View File
@@ -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 * 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 * what's missing, update leaves anything absent at its stored value. The fields may be
+3 -3
View File
@@ -331,7 +331,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
.get( .get(
'/outfits/me', '/outfits/me',
describeRoute({ describeRoute({
tags: ['Avatar'], tags: ['Avatar', '2025'],
summary: 'The callers outfit', summary: 'The callers outfit',
description: description:
'The newer outfit read, on a bare un-prefixed path. Served from slot 0 of the shared ' + '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<App>({ strict: false })
.put( .put(
'/outfits/me', '/outfits/me',
describeRoute({ describeRoute({
tags: ['Avatar'], tags: ['Avatar', '2025'],
summary: 'Save the callers outfit', summary: 'Save the callers outfit',
description: description:
'Saves into the shared `outfit` table, in the slot the body names — slot 0 being the ' + '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<App>({ strict: false })
.get( .get(
'/outfits/me/saved', '/outfits/me/saved',
describeRoute({ describeRoute({
tags: ['Avatar'], tags: ['Avatar', '2025'],
summary: 'The callers saved outfits', summary: 'The callers saved outfits',
description: description:
'The wardrobe behind the newer outfit screen. Empty for now: the outfits saved ' + 'The wardrobe behind the newer outfit screen. Empty for now: the outfits saved ' +
+2 -2
View File
@@ -66,7 +66,7 @@ const app = new Hono<App>()
.get( .get(
'/sections/bulk', '/sections/bulk',
describeRoute({ describeRoute({
tags: ['Discovery'], tags: ['Discovery', '2025'],
summary: 'Look up sections by id', summary: 'Look up sections by id',
description: [ description: [
'The sections named by the repeated `?id=` query, drawn from the catalogue in', 'The sections named by the repeated `?id=` query, drawn from the catalogue in',
@@ -106,7 +106,7 @@ const app = new Hono<App>()
.get( .get(
'/sections/pagesource/:type', '/sections/pagesource/:type',
describeRoute({ describeRoute({
tags: ['Discovery'], tags: ['Discovery', '2025'],
summary: 'Section layout for a page source', summary: 'Section layout for a page source',
description: [ description: [
'The sections of one discovery page, in the order the client draws them. `{type}` IS', 'The sections of one discovery page, in the order the client draws them. `{type}` IS',
+4 -1
View File
@@ -20,7 +20,10 @@
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"hono": "4.12.27", "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": { "devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20", "@cloudflare/vitest-pool-workers": "0.16.20",
+317 -98
View File
@@ -1,4 +1,5 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger' import { useWorkersLogger } from 'workers-tagged-logger'
import { import {
@@ -10,10 +11,30 @@ import {
getRecentlyUpdatedRooms, getRecentlyUpdatedRooms,
getVisitedRooms, getVisitedRooms,
} from '@repo/domain' } from '@repo/domain'
import { withNotFound, withOnError } from '@repo/hono-helpers' import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt' import { validateAndGetAccountId } from '@repo/jwt'
import { resolveCuratedList, serializeCuratedList } from './curated-lists' 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 { Context } from 'hono'
import type { CuratedList, Room } from '@repo/domain' import type { CuratedList, Room } from '@repo/domain'
@@ -305,29 +326,62 @@ const app = new Hono<App>()
.onError(withOnError()) .onError(withOnError())
.notFound(withNotFound()) .notFound(withNotFound())
.get('/', async (c) => { // Root health check.
return c.text('hello, world!') .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=`. // 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 // 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 // (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 // 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. // instead, so an unknown id still answers 200.
.get('/curatedlists/bulk', async (c) => { .get(
return c.json([ '/curatedlists/bulk',
{ describeRoute({
ListId: 17859340, tags: ['Lists', '2025'],
CreatorAccountId: 1, summary: 'Curated lists by id',
Name: 'My List', description: [
Description: null, 'A set of curated lists, asked for by repeating `?id=`. Nothing curates lists here yet,',
ImageName: '', 'so this serves ONE canned list whatever is asked for — an unknown id included, because',
Type: ListEntityType.Rooms, 'a 404 shows as a row that failed to load rather than one the client hides.',
ItemIds: ['123', '456'], '',
CreatedAt: '2025-07-18T00:00:00Z', '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 // 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 // back ONE list object — not a collection — and asks for two different things through the
@@ -350,21 +404,57 @@ const app = new Hono<App>()
// page's heading, which reads as real content rather than as a missing list. The // 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; // exceptions are the client's own reserved playlists and a request naming no list at all;
// both are real answers, not misses (see `resolveCuratedList`). // both are real answers, not misses (see `resolveCuratedList`).
.get('/curatedlists', async (c) => { .get(
const creatorAccountId = c.req.query('creatorAccountId') '/curatedlists',
const type = c.req.query('type') describeRoute({
const name = c.req.query('name') 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 PAGEs row set or a PLAYERs own playlist:',
'',
'- A pages 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 players 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 menus',
' “Saved for Later” row, asked for with the players own id and `type=1` (Rooms), so its',
' `ItemIds` are room ids.',
'',
'D1 is asked FIRST, so a players own list wins over a capture that happens to share its',
'name: the captures are this servers fixtures and a players 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 pages',
'rows under another pages 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 = const list =
(await ownedList(c, creatorAccountId, type, name)) ?? (await ownedList(c, creatorAccountId, type, name)) ??
resolveCuratedList(creatorAccountId, type, name) resolveCuratedList(creatorAccountId, type, name)
if (list === undefined) return c.notFound() if (list === undefined) return c.notFound()
// Serialized by hand rather than through `c.json`: the reference's `ListId`s are // 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 // 64-bit and are carried as strings so their digits survive being parsed — see
// `serializeCuratedList`, which puts them back on the wire as numbers. // `serializeCuratedList`, which puts them back on the wire as numbers.
return c.body(serializeCuratedList(list), 200, { 'content-type': 'application/json' }) 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 // 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 // it yet (`PUT /curatedlists/:name/items/:itemId/createlistifneeded`) — what the client
@@ -378,37 +468,69 @@ const app = new Hono<App>()
// //
// Answers the list as it now stands rather than an acknowledgement, so the row the client // 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. // re-renders is the one this call just changed.
.put('/curatedlists/:name/items/:itemId/createlistifneeded', async (c) => { .put(
const accountId = await authedId(c) '/curatedlists/:name/items/:itemId/createlistifneeded',
if (accountId === null) return unauthorized(c) describeRoute({
tags: ['Lists', '2025'],
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>) summary: 'Save an item into the callers list',
const list = await addPlayerListItem( description: [
c.env.DB, 'Saves an item into one of the callers 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',
creatorAccountId: accountId, 'the list and the item; the form body carries `accessibility` and `type`, both of which',
name: c.req.param('name'), 'apply only on creation.',
// 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 'AUTH-GATED, and the owner is the TOKENs account: unlike the read, this call names no',
// type is part of the list's identity, so guessing another would strand the list '`creatorAccountId`, so the only account it could mean is the callers — and taking an',
// where the client's own read (`?type=1`) can't find it. 'owner from the client would let anyone write into anyones list.',
type: intField(body, c, 'type', ListEntityType.Rooms), '',
// PRIVATE by default. A list a player builds for themselves is theirs to see; 'Answers the list as it now stands rather than an acknowledgement, so the row the client',
// the client sends `accessibility=0` and this only applies on creation anyway. 're-renders is the one this call just changed. Saving the same item twice leaves it in',
accessibility: intField(body, c, 'accessibility', Accessibility.Private), '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',
'reads.',
].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 const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
// real difference in what the client is sent, not an oversight — don't unify them. const list = await addPlayerListItem(
// Every other key, and their order, is the read's. c.env.DB,
const { Accessibility: _accessibility, ...saved } = list {
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 SAVE's projection of a list drops `Accessibility`; the read's keeps it. That is a
// the client unquoted with every digit intact. // real difference in what the client is sent, not an oversight — don't unify them.
return c.body(serializeCuratedList(saved), 200, { 'content-type': 'application/json' }) // 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 // 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. // key the curated page above lists in its `ItemIds` (e.g.
@@ -422,54 +544,151 @@ const app = new Hono<App>()
// instead of one it hides. `Type` is echoed back from the query: it // 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 // 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. // caller didn't ask for would have it resolve the ids against the wrong service.
.get('/algorithmiclists/:list', async (c) => { .get(
// Echoed, but only when it fits the byte the client reads it back into — anything '/algorithmiclists/:list',
// outside 0255 can't round-trip, so a nonsense `?type=` gets the default instead of a describeRoute({
// number that would break the response on the way in. tags: ['Lists', '2025'],
const type = Number.parseInt(c.req.query('type') ?? '', 10) summary: 'One discovery rows contents',
const echoed = type >= 0 && type <= MAX_LIST_ENTITY_TYPE ? type : DEFAULT_ALGORITHMIC_LIST_TYPE 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` workers browse feeds read, so a row and its feed cant 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 rows entities, possibly none') },
}),
async (c) => {
// Echoed, but only when it fits the byte the client reads it back into — anything
// outside 0255 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 // 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 // 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 // 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 // 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. // never did. An empty carousel is also what a brand-new account legitimately has.
const personal = PERSONAL_ROW_FEEDS[key] const personal = PERSONAL_ROW_FEEDS[key]
if (personal !== undefined) { if (personal !== undefined) {
const accountId = await authedId(c) const accountId = await authedId(c)
const rooms = accountId === null ? [] : await personal(c.env.DB, accountId) const rooms = accountId === null ? [] : await personal(c.env.DB, accountId)
return c.json({ Type: echoed, Entities: toEntities(rooms) }) 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 // 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 }` // 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, // 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 // so there is nothing here to serve statically beyond the acknowledgement itself. The
// body is read for the log only. // body is read for the log only.
.post('/contextualfeatures', async (c) => { .post(
const id = await authedId(c) '/contextualfeatures',
if (id === null) return unauthorized(c) 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 pages row',
'set, or a players own playlist in D1 (`__SavedForLater_Rooms`, the Play menus',
'“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 callers 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 didnt 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',
'callers 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 workers `POST /connect/token`.',
},
},
},
},
})
)
)
export default app export default app
+287
View File
@@ -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 lists `ItemIds` are: 0 Accounts · 1 Rooms ·',
'2 Inventions · 3 CustomAvatarItems · 4 PurchasableItems · 5 Generic · 6 ChipAndPort ·',
'7 DiscoverySection · 8 DiscoverySectionSubType. Part of a lists 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 lists name, matched case-insensitively (the casing that arrives is the',
'clients). Naming NO list asks for the page default for `type`; naming one that',
'matches nothing is a 404, unless it is one of the clients 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 callers list to save into, created if they have none by that name.',
'`__SavedForLater_Rooms` is the one the client creates for itself — the Play menus',
'“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 rows 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 0255 (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 saves 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 lists identity, so another value would strand it where the clients own `?type=1` read cant 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 lists accessibility alone.'
),
})
@@ -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 WHERE room_id >= ?1').bind(FIRST).run()
await env.DB.prepare('DELETE FROM room_tag 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<string, unknown> }
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()
})
+1
View File
@@ -31,6 +31,7 @@ export const DOCUMENTED_SERVICES: ReadonlyArray<{ slug: string; title: string }>
{ slug: 'playersettings', title: 'playersettings — per-player settings' }, { slug: 'playersettings', title: 'playersettings — per-player settings' },
{ slug: 'roomcomments', title: 'roomcomments — notes pinned in a room' }, { slug: 'roomcomments', title: 'roomcomments — notes pinned in a room' },
{ slug: 'discovery', title: 'discovery — discovery page layouts' }, { slug: 'discovery', title: 'discovery — discovery page layouts' },
{ slug: 'lists', title: 'lists — curated & algorithmic lists' },
{ slug: 'leaderboard', title: 'leaderboard — room score boards' }, { slug: 'leaderboard', title: 'leaderboard — room score boards' },
{ slug: 'ai', title: 'ai — game AI access' }, { slug: 'ai', title: 'ai — game AI access' },
{ slug: 'api', title: 'api — everything else' }, { slug: 'api', title: 'api — everything else' },