Files
recflare/apps/playersettings/src/playersettings.app.ts
T
2026-09-08 23:27:04 -04:00

312 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { validateAndGetAccountId } from '@repo/jwt'
import { DEFAULT_SETTINGS } from './default-settings'
import {
AUTHED,
formOrJson,
HealthResponse,
json,
PlayerSettingEntry,
SettingFormDelete,
SettingFormWrite,
SettingJsonDelete,
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.
*
* Every `/playersettings` route is 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`
* claim isn't an integer.
*/
async function authedId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/** Results.Unauthorized() equivalent — 401 with empty body. */
function unauthorized(c: Context<App>) {
return c.body(null, 401)
}
/**
* Pull `{ key, value }` pairs out of a PUT body: a form-urlencoded `key`/`value`,
* or a JSON body (single object or array). Entries with an empty key are dropped.
*/
async function parseSettings(c: Context<App>): Promise<Array<{ key: string; value: string }>> {
const contentType = c.req.header('content-type') ?? ''
if (contentType.includes('application/json')) {
const body = await c.req.json<unknown>().catch(() => null)
const list = Array.isArray(body) ? body : body == null ? [] : [body]
return list
.map((o) => {
const rec = o as Record<string, unknown>
const key = rec.key ?? rec.Key
const value = rec.value ?? rec.Value
return {
key: typeof key === 'string' ? key : '',
value:
typeof value === 'string'
? value
: typeof value === 'number' || typeof value === 'boolean'
? String(value)
: '',
}
})
.filter((s) => s.key !== '')
}
// form-urlencoded / multipart
const form = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const key = typeof form.key === 'string' ? form.key : ''
const value = typeof form.value === 'string' ? form.value : ''
return key ? [{ key, value }] : []
}
/**
* Pull the setting name(s) to remove out of a DELETE body. The client sends a bare
* form-urlencoded `key=PlayerShoppingBagId` — with no `value`, and (unlike its PUTs) not
* always a `content-type` Hono's body parser recognises on a DELETE, so an unparsed body
* is re-read as raw text. A JSON body is accepted too, as a bare string, a `{ key }`
* object, or an array of either. Blank names are dropped.
*/
async function parseDeleteKeys(c: Context<App>): Promise<string[]> {
const contentType = c.req.header('content-type') ?? ''
if (contentType.includes('application/json')) {
const body = await c.req.json<unknown>().catch(() => null)
const list = Array.isArray(body) ? body : body == null ? [] : [body]
return list
.map((o) => {
if (typeof o === 'string') return o
const rec = o as Record<string, unknown>
const key = rec.key ?? rec.Key
return typeof key === 'string' ? key : ''
})
.filter((k) => k !== '')
}
// Pick ONE read of the body from the content-type: Hono's parser only recognises the
// form types, and re-reading as text after it has cached a FormData re-serialises the
// body as multipart, so trying both in turn parses garbage.
let key = ''
if (contentType.includes('form-data') || contentType.includes('x-www-form-urlencoded')) {
const form = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
if (typeof form.key === 'string') key = form.key
} else {
key = new URLSearchParams(await c.req.text().catch(() => '')).get('key') ?? ''
}
// Last resort, for a client that hangs the name off the URL instead.
if (key === '') key = c.req.query('key') ?? ''
return key ? [key] : []
}
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 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',
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))
}
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',
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 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)
}
)
// Remove a setting from the caller's map. The client sends `key=PlayerShoppingBagId`
// when it drops a value it no longer wants defaulted (a stale shopping bag id, say)
// rather than writing an empty string over it.
.delete(
'/playersettings',
describeRoute({
tags: ['Player Settings'],
summary: 'Delete a player setting',
description: [
'Removes the named setting(s) from the callers KV map. The client sends a bare',
'form-urlencoded `key=PlayerShoppingBagId` (no `value`); a JSON body — a string, a',
'`{ key }` object, or an array of either — and a `?key=` query param are also read.',
'Deleting a key that isnt stored, or sending nothing to delete, is a no-op 200, not a',
'404. Empty body on success.',
'',
'Note that emptying the map entirely puts the player back to a first read: the next',
'`GET` re-seeds the defaults.',
].join(' '),
security: AUTHED,
requestBody: formOrJson(SettingFormDelete, SettingJsonDelete, 'The setting(s) to remove'),
responses: {
200: { description: 'Removed, or nothing to remove (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const keys = await parseDeleteKeys(c)
if (keys.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'
)
if (!existing) return c.body(null, 200)
const remaining = { ...existing }
let removed = false
for (const key of keys) {
if (key in remaining) {
delete remaining[key]
removed = true
}
}
if (removed) await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(remaining))
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