updating api docs

This commit is contained in:
Devin Zuczek
2026-07-22 11:43:30 -04:00
parent 68b98665b2
commit 23b78104e8
28 changed files with 3358 additions and 780 deletions
+130 -25
View File
@@ -1,14 +1,34 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { DEFAULT_SETTINGS } from './default-settings'
import {
AUTHED,
formOrJson,
HealthResponse,
json,
PlayerSettingEntry,
SettingFormWrite,
SettingJsonWrite,
UNAUTHORIZED_RESPONSE,
} from './openapi'
import type { Context } from 'hono'
import type { App } from './context'
/**
* Player Settings Worker. Serves the small key/value settings bag the game client reads
* on load and writes back as the player toggles options. Backed by a per-player KV map
* (`player:{id}`); a player with nothing stored is seeded with the reference defaults on
* their first read.
*
* Both routes are auth-gated on the Bearer JWT issued by the `auth` worker.
*/
/**
* Resolve the account id from a Bearer token (the route is auth-gated).
* Returns `null` when the header is missing, the token is invalid, or the `sub`
@@ -72,41 +92,126 @@ const app = new Hono<App>()
.onError(withOnError())
.notFound(withNotFound())
.get('/', (c) => c.json({ service: 'playersettings', status: 'ok' }))
// Root health check.
.get(
'/',
describeRoute({
tags: ['Service'],
summary: 'Health check',
description: 'Liveness probe for the playersettings worker. No auth.',
responses: { 200: json(HealthResponse, 'Service is up') },
}),
(c) => c.json({ service: 'playersettings', status: 'ok' })
)
// The authenticated player's settings as `{ PlayerId, Key, Value }`. Reads
// the per-player KV map; seeds (and persists) the defaults on first read.
.get('/playersettings', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
.get(
'/playersettings',
describeRoute({
tags: ['Player Settings'],
summary: 'The players settings',
description: [
'The authenticated players settings as `{ PlayerId, Key, Value }` entries, read from',
'their KV map. A player with nothing stored is seeded with the reference defaults',
'(Recroom.OOBE, TUTORIAL_COMPLETE_MASK, FIRST_TIME_IN_FLAGS), which are persisted on',
'that first read.',
].join(' '),
security: AUTHED,
responses: {
200: json(PlayerSettingEntry.array(), 'The players settings (defaults on first read)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const kvKey = `player:${id}`
let stored = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(kvKey, 'json')
if (!stored || Object.keys(stored).length === 0) {
stored = Object.fromEntries(DEFAULT_SETTINGS.map((s) => [s.Key, s.Value]))
await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(stored))
const kvKey = `player:${id}`
let stored = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(kvKey, 'json')
if (!stored || Object.keys(stored).length === 0) {
stored = Object.fromEntries(DEFAULT_SETTINGS.map((s) => [s.Key, s.Value]))
await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(stored))
}
return c.json(Object.entries(stored).map(([Key, Value]) => ({ PlayerId: id, Key, Value })))
}
return c.json(Object.entries(stored).map(([Key, Value]) => ({ PlayerId: id, Key, Value })))
})
)
// Upsert player settings into KV, keyed by the authenticated player id.
// A full replace would overwrite the player's entire set; we merge so individual key PUTs
// (e.g. `key=PlayerSessionCount&value=1`) don't wipe the rest.
.put('/playersettings', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
.put(
'/playersettings',
describeRoute({
tags: ['Player Settings'],
summary: 'Write the players settings',
description: [
'Upserts the posted setting(s) into the callers KV map. The write MERGES: a single',
'key PUT (`key=PlayerSessionCount&value=1`, which is what the client sends) leaves the',
'players other settings alone. A JSON body is also accepted, as one object or an',
'array, in either `key`/`value` or `Key`/`Value` casing; entries with an empty key are',
'dropped. An unparseable or empty body is a no-op 200, not a 400. Empty body on success.',
].join(' '),
security: AUTHED,
requestBody: formOrJson(SettingFormWrite, SettingJsonWrite, 'The setting(s) to write'),
responses: {
200: { description: 'Applied, or nothing parseable to apply (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const incoming = await parseSettings(c)
if (incoming.length === 0) return c.body(null, 200)
const incoming = await parseSettings(c)
if (incoming.length === 0) return c.body(null, 200)
const kvKey = `player:${id}`
const existing = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(kvKey, 'json')
const merged: Record<string, string> = { ...existing }
for (const { key, value } of incoming) merged[key] = value
const kvKey = `player:${id}`
const existing = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
kvKey,
'json'
)
const merged: Record<string, string> = { ...existing }
for (const { key, value } of incoming) merged[key] = value
await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(merged))
return c.body(null, 200)
})
await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(merged))
return c.body(null, 200)
}
)
// 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 playersettings',
version: '1.0.0',
description: [
'The player key/value settings bag for recflare, a private-server reimplementation of',
'the Rec Room backend. The client reads these on load and writes them back as the',
'player toggles options; they are stored in a per-player KV map, seeded with the',
'reference defaults on a players first read.',
].join('\n'),
},
servers: [{ url: 'https://playersettings.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