mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
[openapi] define missing routes
This commit is contained in:
@@ -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 room’s Game AI spend summary',
|
summary: 'A room’s 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 caller’s Roomie AI energy budget',
|
summary: 'The caller’s 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 caller’s Maker AI usage balances',
|
summary: 'The caller’s 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-',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 caller’s outfit',
|
summary: 'The caller’s 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 caller’s outfit',
|
summary: 'Save the caller’s 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 caller’s saved outfits',
|
summary: 'The caller’s 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 ' +
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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 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 =
|
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 caller’s list',
|
||||||
const list = await addPlayerListItem(
|
description: [
|
||||||
c.env.DB,
|
'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',
|
||||||
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 TOKEN’s 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 caller’s — and taking an',
|
||||||
// where the client's own read (`?type=1`) can't find it.
|
'owner from the client would let anyone write into anyone’s 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',
|
||||||
|
'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
|
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 0–255 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 row’s 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` 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
|
// 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 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
|
export default app
|
||||||
|
|||||||
@@ -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.'
|
||||||
|
),
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
Reference in New Issue
Block a user