support for 202507 endpoints (#37)

* [auth][api] accept the 20250424.01 client

* [2025] unstable

* 20250718.0

* correct one this time

* stubs

* more stubs

* more stubs

* [lists] add worker

* [ai] route stubs

* [api] player photo setting

* [econ] add roomEconConfig route

* [infra] update worker generators

* [worker] add cards/moderation/platformnotification workers

* [lists] updates to some endpoints

* [clubs] stub out announcement endpoint, for now

* [econ] stub out season endpoints for now

* [chat] apps/chat stub out party endpoint not sure the shape yet

* [api] stub out statsig and lockeditems

* [doc] new services

* [lists] stub the bulk endpoint

* [datacollection] add placeholder service until we can kill it

* [api] set gifting to lvl5

* update lock

* [cdn] enable cache

* [match] matchmake v2

* [lists] stub some lists

* [ai] stubs

* [rooms] new subroom save endpoint

* [econ] add bulk purchase endpoint

* [discovery] update featured creator to 1 for fun

* [api] add photo settings flag

* [chat] fixup chat permissions (sorta)

* [auth] restrictions endpoint

* [rooms] contributed endpoint

* [api] fix outfit endpoint

* [discovery] attempt to fix store

* [chat] privacy endpoints

* [api] cheered images

* [rooms] add xp endpoint (disbaled)

* [rooms] add xp endpoint (disabled)

* update images-db for cheers

* [rooms] add autocomplete endpoint

* [cdn/img] increase cache ttl for statics

* [api] bulk route for images

* [accounts] add banner image

* [api] add misc missing endpoints

* [discovery] remove AI tab

* [platformnotifications] stub some endpoints

* [lists] add some more lists

* [rooms] additional endpoints

* [chat] stub a few privacy endpoints

* [econ] stub some endpoints

* misc db fixes

* [api] tweak shape for images v6

* [rooms] dont show trending RROs
This commit is contained in:
devin
2026-08-18 23:07:24 -04:00
committed by Devin Zuczek
parent 66c09806f9
commit 178d3b5b0e
162 changed files with 114930 additions and 469 deletions
+19
View File
@@ -0,0 +1,19 @@
import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
export type Env = SharedHonoEnv & {
/**
* Static-asset fetcher for the page layouts in `static/` (see wrangler.jsonc
* `assets`). Fetched by filename so `{type}` is a wildcard; the binding is the only
* way in, since `run_worker_first` keeps the runtime from serving the files directly.
*/
ASSETS: Fetcher
}
/** Variables can be extended */
export type Variables = SharedHonoVariables
export interface App extends HonoApp {
Bindings: Env
Variables: Variables
}
+118
View File
@@ -0,0 +1,118 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { DiscoverySections, json, PAGE_SOURCE_PARAM, ServiceStatus } from './openapi'
import { fetchPageSource } from './page-sources'
import type { App } from './context'
/**
* Discovery Worker. Serves the layout of the client's discovery pages — which carousels a
* page shows and in what order — out of `static/`, one file per page source, through the
* ASSETS binding (see `page-sources.ts`). It does not serve the carousels' CONTENTS: each
* section names a client-side feed the client resolves against the `rooms`/`api` workers
* itself.
*
* Unauthenticated: every client gets the same layout, and the client fetches this before
* anything player-specific.
*/
const app = new Hono<App>()
.use(
'*',
// middleware
(c, next) =>
useWorkersLogger(c.env.NAME, {
environment: c.env.ENVIRONMENT,
release: c.env.SENTRY_RELEASE,
})(c, next)
)
.onError(withOnError())
.notFound(withNotFound())
// Root health check.
.get(
'/',
describeRoute({
tags: ['Service'],
summary: 'Health check',
description: 'Liveness probe for the discovery worker. No auth.',
responses: { 200: json(ServiceStatus, 'Service is up') },
}),
(c) => c.json({ service: 'discovery', status: 'ok' })
)
// One discovery page's section layout, served verbatim from `static/<type>.json`.
.get(
'/sections/pagesource/:type',
describeRoute({
tags: ['Discovery'],
summary: 'Section layout for a page source',
description: [
'The sections of one discovery page, in the order the client draws them. `{type}` IS',
'the filename — the body is `static/<type>.json` served verbatim — so the page sources',
'that exist are whichever files are published (`WatchHome`, `PlayHighlight`,',
'`CommunityBoard`, `PlayMenuTabs`, `PlayCategories`, `StoreCategories`,',
'`StoreFeatured`, `StoreClothing`, `StoreConsumables` and `bulk` at the time of',
'writing). The match is exact, case included.',
'',
'This replaces the `Discovery.DiscoveryPageContent.*` game configs, which carried the',
'same layouts as embedded JSON strings: with `Discovery.UseNewDiscoveryServerAPI` set',
'to `True` the client asks this service instead. The two are not the same shape — the',
'configs wrapped the list in `{ pageSource, sections }` with PascalCase fields, while',
'this answers the bare ARRAY with camelCase ones.',
'',
'A section only NAMES a feed (`source`/`sourceMetadata`); its rooms, items and accounts',
'are fetched separately by the client. Nothing here is player-specific, so there is no',
'auth and every client gets the same layout.',
].join('\n'),
parameters: [PAGE_SOURCE_PARAM],
responses: {
200: json(DiscoverySections, 'The pages sections'),
304: { description: '`If-None-Match` matched the files etag (no body)' },
404: { description: 'No file is published under that name' },
},
}),
async (c) => {
const res = await fetchPageSource(c, c.req.param('type'))
return res ?? c.notFound()
}
)
// 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 discovery',
version: '1.0.0',
description: [
'Discovery page layouts for recflare, a private-server reimplementation of the Rec',
'Room backend. The client asks this service which sections each of its discovery',
'pages shows — Watch home, the play menu and its tabs, the community board, the store',
'pages — and draws them in the order given.',
'',
'Each layout is a file in `static/`, published as a Workers static asset and served',
'verbatim by filename, so the set of page sources is whatever is published rather',
'than anything the code enumerates. Nothing is editable at runtime and every client',
'gets the same answer, so the routes are unauthenticated.',
'',
'A section names a feed rather than carrying its contents: the client resolves the',
'rooms, items and accounts behind each carousel against the `rooms` and `api` workers',
'itself.',
].join('\n'),
},
servers: [{ url: 'https://discovery.recflare.net', description: 'Production' }],
},
})
)
)
export default app
+101
View File
@@ -0,0 +1,101 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the discovery 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) } } }
}
/** The `{type}` path parameter, which is the filename in `static/`. */
export const PAGE_SOURCE_PARAM: OpenAPIV3_1.ParameterObject = {
name: 'type',
in: 'path',
required: true,
description: [
'The page source — `WatchHome`, `PlayHighlight`, `CommunityBoard`, `PlayMenuTabs`,',
'`PlayCategories`, `StoreCategories`, `StoreFeatured`, `StoreClothing`,',
'`StoreConsumables`, `bulk` at the time of writing. It names a file in `static/`',
'(`<type>.json`) and is matched exactly, case included, so the set is whatever is',
'published rather than anything this worker enumerates.',
].join(' '),
// Deliberately not an `enum`: the accepted values are the published files, and a spec
// that froze today's list would be wrong the moment one is added.
schema: { type: 'string', example: 'WatchHome' },
}
// ---- Response schemas ------------------------------------------------------
/** `GET /` — the liveness probe body. */
export const ServiceStatus = z.object({
service: z.literal('discovery'),
status: z.literal('ok'),
})
/**
* How one carousel on a discovery page is filled and drawn. `source`/`sourceMetadata`
* name a feed (`Hot`, `Recent`, `PlaylistById` + an id, `CarouselEndpoint` + a slug, …)
* that the client resolves against the `rooms`/`api` workers itself — this worker only
* says WHICH carousels a page has and in what order, never their contents.
*/
export const DiscoverySection = z.object({
id: z.string().describe('Unique id of this section on this page, e.g. `Rooms_RRO_WatchHome`'),
sectionType: z
.int()
.describe(
'What the section lists: 0 RoomsSection · 1 AccountsSection · 2 InventionsSection · ' +
'3 ClubsSection · 4 StoreItemsSection · 5 EventsSection · 6 RoomBanner · 7 Top5Section · ' +
'8 CustomAvatarItemsSection · 9 AdsSection · 10 SkusSection · 11 RoomCategorySection · ' +
'12 RoomCategoryListSection · 13 DiscoverySection'
),
sectionSubType: z.string().describe('The sections kind, shared across pages, e.g. `Rooms_RRO`'),
source: z.string().describe('The feed that fills the section'),
sourceMetadata: z
.string()
.nullable()
.describe('Argument to `source` — a carousel slug, a playlist id, … `null` when it takes none'),
displayMetadata: z
.string()
.nullable()
.describe(
'How the section is drawn (`DisplayTitle`, `numRows`, `backgroundColor`, …), as an ' +
'embedded JSON *string* the client parses itself — not an object. Its booleans and ' +
'numbers are quoted in most sections and bare in some; both forms are live in the ' +
'captures, so the client evidently takes either. `null` where a section is drawn ' +
'however its type says (several store and play-highlight sections).'
),
})
/**
* `GET /sections/pagesource/{type}` — a page's sections, in the order they are drawn.
*
* The DTO accepts anything (its validator is a no-op), but the STORE page builder is much
* stricter and drops a section it doesn't like SILENTLY — no error reaches the client, the
* carousel simply isn't there. A section it keeps must have:
*
* - a non-empty `displayMetadata` that parses, and whose gating (platform, junior account,
* account age) says it's supported;
* - `sectionType` 4 (StoreItemsSection → the product carousel) or 13 (DiscoverySection).
* Anything else logs "Unhandled SectionType {0}, skipping";
* - for 13 only, a `source` of exactly `CuratedList` or `PageSource`, with `sourceMetadata`
* carrying the argument — a curated-list id, or another page source to recurse into.
*
* So a store section that renders nothing is worth checking against these before assuming
* the feed behind it is empty.
*/
export const DiscoverySections = DiscoverySection.array()
+37
View File
@@ -0,0 +1,37 @@
import type { Context } from 'hono'
import type { App } from './context'
/**
* The discovery page layouts, one file per page source in `static/`, served through the
* ASSETS binding (see wrangler.jsonc). `{type}` IS the filename: it is passed through to
* `static/<type>.json` unchanged, so publishing a new page source is dropping in a file —
* nothing in this worker enumerates or knows their names.
*
* Case included: the asset manifest is case-sensitive, so a file must be named exactly as
* the client asks for it (`WatchHome.json`, not `watchhome.json`). Nothing here folds the
* case, because there is no index to fold it against.
*/
/**
* What may reach the binding as a filename. Deliberately narrow — no dots, no slashes,
* nothing that could climb out of `static/` — so a path traversal is a 404 from this
* worker rather than a request the asset server has to be trusted to refuse.
*/
const SAFE_NAME = /^[A-Za-z0-9_-]+$/
/**
* Fetch `static/<type>.json` through the ASSETS binding. `null` when no such file is
* published, or when the name isn't one a file could have.
*
* The asset response is handed back whole rather than parsed and re-serialized: it
* already carries the right content type and an etag, so a client that sends
* `If-None-Match` gets its 304 for free.
*/
export async function fetchPageSource(c: Context<App>, type: string): Promise<Response | null> {
if (!SAFE_NAME.test(type)) return null
// Forwarding the original request keeps its conditional headers, so the binding
// answers 304 on a match; only the URL is rewritten to the asset's path.
const res = await c.env.ASSETS.fetch(new Request(new URL(`/${type}.json`, c.req.url), c.req.raw))
return res.ok || res.status === 304 ? res : null
}
@@ -0,0 +1,163 @@
import { SELF } from 'cloudflare:test'
import { describe, expect, it } from 'vitest'
/** Every layout published in `static/` today. Each is served under its own filename. */
const PAGE_SOURCES = [
'WatchHome',
'PlayHighlight',
'CommunityBoard',
'PlayMenuTabs',
'PlayCategories',
'StoreCategories',
'StoreFeatured',
'StoreClothing',
'StoreConsumables',
'bulk',
]
interface Section {
id: string
sectionType: number
sectionSubType: string
source: string
sourceMetadata: string | null
displayMetadata: string | null
}
/** Fetch a page source and return its parsed body. */
async function pageSource(type: string) {
const res = await SELF.fetch(`https://discovery.example.com/sections/pagesource/${type}`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('application/json')
return (await res.json()) as Section[]
}
describe('GET /', () => {
it('answers the liveness probe', async () => {
const res = await SELF.fetch('https://discovery.example.com/')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ service: 'discovery', status: 'ok' })
})
})
describe('GET /sections/pagesource/:type', () => {
// The point of the ASSETS binding: `{type}` is the filename, so every published file
// is reachable without the worker knowing its name.
it.each(PAGE_SOURCES)('serves %s', async (type) => {
const sections = await pageSource(type)
expect(sections.length).toBeGreaterThan(0)
for (const section of sections) {
expect(typeof section.id).toBe('string')
expect(typeof section.sectionType).toBe('number')
expect(typeof section.source).toBe('string')
// An embedded JSON *string* the client parses itself, not an object — or null,
// which several store and play-highlight sections use.
if (section.displayMetadata !== null) {
expect(typeof section.displayMetadata).toBe('string')
expect(() => JSON.parse(section.displayMetadata as string)).not.toThrow()
}
}
})
it('serves the file verbatim', async () => {
const sections = await pageSource('WatchHome')
expect(sections[0]).toEqual({
id: 'Rooms_ForYou_WatchHome',
sectionType: 0,
sectionSubType: 'Rooms_ForYou',
source: 'CarouselEndpoint',
sourceMetadata: 'foryou',
displayMetadata: expect.stringContaining('"DisplayTitle":"Recommended For You"'),
})
})
// The store page builder drops a section it doesn't like SILENTLY — no error reaches the
// client, the carousel just isn't drawn. Every section of the page we author ourselves
// has to survive it: a non-empty displayMetadata that parses, sectionType 4
// (StoreItemsSection) or 13 (DiscoverySection), and for 13 a source of exactly
// `CuratedList` or `PageSource` carrying its argument.
//
// Only StoreCategories is checked this strictly. The other store pages are reference
// captures served verbatim, and StoreFeatured carries a CustomAvatarItemsSection (8)
// that this builder would drop — that is the reference's data, not a mistake to fix here.
it('StoreCategories only carries sections the store page builder keeps', async () => {
for (const section of await pageSource('StoreCategories')) {
expect([4, 13]).toContain(section.sectionType)
expect(section.displayMetadata).toBeTruthy()
expect(() => JSON.parse(section.displayMetadata as string)).not.toThrow()
if (section.sectionType === 13) {
expect(['CuratedList', 'PageSource']).toContain(section.source)
expect(section.sourceMetadata).toBeTruthy()
}
}
})
it('serves the StoreCategories page', async () => {
const sections = await pageSource('StoreCategories')
expect(sections[0]).toEqual({
id: 'store-featured',
// StoreItemsSection: a store CATEGORY is drawn as the product carousel.
sectionType: 4,
sectionSubType: 'StoreCategory_Featured',
source: 'CuratedList',
// The curated list the `lists` worker serves from /curatedlists/bulk.
sourceMetadata: '17859340',
displayMetadata: expect.stringContaining('"DisplayTitle":"Featured"'),
})
// displayMetadata must be non-empty and parse, or the builder drops the section.
const display = JSON.parse(sections[0].displayMetadata as string) as {
categoryUriNames: string
}
expect(display.categoryUriNames).toBe('featured,new')
})
// The asset manifest is case-sensitive and there is no index to fold case against, so
// the name has to match the file exactly.
it('404s a name whose case does not match the file', async () => {
const res = await SELF.fetch('https://discovery.example.com/sections/pagesource/watchhome')
expect(res.status).toBe(404)
})
it('404s an unpublished page source', async () => {
const res = await SELF.fetch('https://discovery.example.com/sections/pagesource/nope')
expect(res.status).toBe(404)
})
// The name reaches the ASSETS binding as a filename, so anything that could climb out
// of `static/` is refused before it gets there.
it.each(['..', '%2e%2e%2fwrangler.jsonc', 'sub%2Fdir', 'WatchHome.json'])(
'404s a name that could not be a file in static/ (%s)',
async (type) => {
const res = await SELF.fetch(`https://discovery.example.com/sections/pagesource/${type}`)
expect(res.status).toBe(404)
}
)
it('answers 304 when the etag matches', async () => {
const first = await SELF.fetch('https://discovery.example.com/sections/pagesource/WatchHome')
const etag = first.headers.get('etag')
expect(etag).toBeTruthy()
const second = await SELF.fetch('https://discovery.example.com/sections/pagesource/WatchHome', {
headers: { 'if-none-match': etag as string },
})
expect(second.status).toBe(304)
})
// `run_worker_first` keeps the layouts off their own asset URLs: the only way to a file
// is the documented route.
it('does not serve the files at their asset paths', async () => {
const res = await SELF.fetch('https://discovery.example.com/WatchHome.json')
expect(res.status).toBe(404)
})
})
describe('GET /openapi.json', () => {
it('generates a spec with no dangling $refs', async () => {
const res = await SELF.fetch('https://discovery.example.com/openapi.json')
expect(res.status).toBe(200)
const spec = (await res.json()) as { paths: Record<string, unknown> }
expect(Object.keys(spec.paths)).toContain('/sections/pagesource/{type}')
expect(JSON.stringify(spec).match(/\$ref/g)).toBeNull()
})
})