Files
recflare/apps/playersettings/src/playersettings.app.ts
T
2026-07-22 11:43:30 -04:00

218 lines
7.1 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,
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`
* 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 }] : []
}
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)
}
)
// 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