[api] player photo tagging setting

GET/PUT /api/players/v1/playerPhotoTaggingSetting, ported from the reference's
PlayerDB.Get/SetPlayerPhotoTaggingSetting. Backed by the shared player-settings
KV bag (owned by the `playersettings` worker) under a `PlayerPhotoTaggingSetting`
key rather than its own table.

The setting is served as the enum ORDINAL (0 Anyone / 1 Friends / 2 NoOne) — the
reference registers no JsonStringEnumConverter, so the client decodes a number.
Unset reads back 0; the PUT answers a bare true, or false when the body carries
no recognizable setting, as the reference's bool does.

The write merges into the player's settings map and seeds the `playersettings`
defaults when there is none yet, so writing this key can't cost a player the
seeding that worker's first read would have done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Devin Zuczek
2026-08-16 00:34:54 -04:00
parent ba0aec92a4
commit f871736839
6 changed files with 267 additions and 0 deletions
+2
View File
@@ -12,6 +12,7 @@ import { gameplayRoutes } from './routes/gameplay'
import { imageRoutes } from './routes/images'
import { inventoryRoutes } from './routes/inventory'
import { moderationRoutes } from './routes/moderation'
import { playerRoutes } from './routes/players'
import { progressionRoutes } from './routes/progression'
import { roomRoutes } from './routes/rooms'
import { socialRoutes } from './routes/social'
@@ -64,6 +65,7 @@ const app = new Hono<App>({ strict: false })
.route('/', roomRoutes)
.route('/', imageRoutes)
.route('/', accountRoutes)
.route('/', playerRoutes)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
+4
View File
@@ -24,6 +24,10 @@ export type Env = SharedHonoEnv & {
// Shared CDN bucket (owned by the `cdn` worker, written by `storage`). Read
// here only to hash an invention's uploaded data blob under `invention/`.
CDN_ASSETS: R2Bucket
// Per-player settings bag (KV owned by the `playersettings` worker, which serves
// the same map at `/playersettings`). Key `player:<id>` → JSON `{ key: value }`;
// read/written here for the player preferences the client calls by name.
RECFLARE_PLAYER_SETTINGS: KVNamespace
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
// push RelationshipChanged notifications when a player's relationship changes.
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
+14
View File
@@ -254,6 +254,20 @@ export const BulkIdsRequest = z.object({
Ids: z.string().describe('Comma-separated account ids, e.g. `1,2,3`'),
})
// ---- Players ---------------------------------------------------------------
/**
* `PlayerPhotoTaggingSetting` — who may tag the player in a photo, as the enum ORDINAL.
* The reference serves the number (it registers no `JsonStringEnumConverter`), so this
* is a bare integer body, not a name and not an envelope.
*/
export const PhotoTaggingSetting = z.int().describe('0 = Anyone, 1 = Friends, 2 = NoOne')
/** The `{ Setting }` JSON body `PUT /api/players/v1/playerPhotoTaggingSetting` takes. */
export const SetPhotoTaggingSettingRequest = z.object({
Setting: PhotoTaggingSetting,
})
// ---- Inventions ------------------------------------------------------------
/** One version of an invention — carries the blob name the client downloads. */
+158
View File
@@ -0,0 +1,158 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
// The `playersettings` worker owns this KV map; its defaults are imported as a value
// (a plain array, no runtime dependencies) so a first write from here seeds the same
// settings that worker's first read would have.
import { DEFAULT_SETTINGS } from '../../../playersettings/src/default-settings'
import { authedId, unauthorized } from '../http'
import {
AUTHED,
BareBoolean,
json,
jsonBody,
PhotoTaggingSetting,
SetPhotoTaggingSettingRequest,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import type { Context } from 'hono'
import type { App } from '../context'
/**
* Player-account preferences. These live in the same per-player KV bag the
* `playersettings` worker serves (`player:{id}` → `{ key: value }`), just under a
* dedicated route the client calls by name — the reference keeps the photo-tagging
* setting on the player record, but a settings key is the same thing without a table.
*/
/** The settings key the photo-tagging preference is stored under. */
const PHOTO_TAGGING_KEY = 'PlayerPhotoTaggingSetting'
/**
* `PlayerPhotoTaggingSetting` — who may tag this player in a photo. Serialized as the
* ordinal, not the name: the reference server leaves `JsonStringEnumConverter` off, so
* the client's decoder is reading a number.
*/
const PHOTO_TAGGING_VALUES = ['Anyone', 'Friends', 'NoOne'] as const
/** `Anyone` — what a player who has never set one reads back as. */
const PHOTO_TAGGING_DEFAULT = 0
/** The caller's KV key in the shared player-settings bag. */
function settingsKey(id: number): string {
return `player:${id}`
}
/**
* Coerce a posted setting to its ordinal. Accepts the number the client sends and the
* enum NAME as well, so a client that spells it out still lands on the right value.
* `null` when the body carries nothing recognizable — the caller answers `false`.
*/
function parsePhotoTaggingSetting(raw: unknown): number | null {
if (typeof raw === 'number' && Number.isInteger(raw)) {
return raw >= 0 && raw < PHOTO_TAGGING_VALUES.length ? raw : null
}
if (typeof raw !== 'string' || raw === '') return null
const asNumber = Number.parseInt(raw, 10)
if (!Number.isNaN(asNumber)) {
return asNumber >= 0 && asNumber < PHOTO_TAGGING_VALUES.length ? asNumber : null
}
const named = PHOTO_TAGGING_VALUES.findIndex((v) => v.toLowerCase() === raw.toLowerCase())
return named === -1 ? null : named
}
/**
* The `Setting` field out of a PUT body: JSON (what the client posts, `{ "Setting": 1 }`),
* or a form-urlencoded `Setting` for hand-rolled callers. Either casing is accepted.
*/
async function readSetting(c: Context<App>): Promise<number | null> {
const contentType = c.req.header('content-type') ?? ''
if (contentType.includes('application/json')) {
const body = await c.req.json<unknown>().catch(() => null)
if (body === null || typeof body !== 'object') return null
const rec = body as Record<string, unknown>
return parsePhotoTaggingSetting(rec.Setting ?? rec.setting)
}
const form = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
return parsePhotoTaggingSetting(form.Setting ?? form.setting)
}
// ---- Players ---------------------------------------------------------------
export const playerRoutes = new Hono<App>({ strict: false })
.get(
'/api/players/v1/playerPhotoTaggingSetting',
describeRoute({
tags: ['Players'],
summary: 'Who may tag the caller in photos',
description:
'The callers `PlayerPhotoTaggingSetting` as the enum ORDINAL — `0` Anyone, `1` ' +
'Friends, `2` NoOne — read from the `PlayerPhotoTaggingSetting` key of the shared ' +
'player-settings bag the `playersettings` worker serves. A player who has never set ' +
'one reads back `0` (Anyone), which is the references default; nothing is written ' +
'on a read.',
security: AUTHED,
responses: {
200: json(PhotoTaggingSetting, 'The setting, as its ordinal'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const stored = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
settingsKey(id),
'json'
)
const parsed = parsePhotoTaggingSetting(stored?.[PHOTO_TAGGING_KEY])
return c.json(parsed ?? PHOTO_TAGGING_DEFAULT)
}
)
.put(
'/api/players/v1/playerPhotoTaggingSetting',
describeRoute({
tags: ['Players'],
summary: 'Set who may tag the caller in photos',
description:
'Writes `{ "Setting": 0 | 1 | 2 }` to the callers `PlayerPhotoTaggingSetting` key ' +
'and answers a bare `true`, as the reference does (it answers `false` when there was ' +
'nothing to update — here, when the body carries no recognizable setting). The enum ' +
'NAME is accepted alongside the ordinal. The write MERGES into the players settings ' +
'bag, so it leaves every other key alone; a player with no bag yet is seeded with the ' +
'`playersettings` defaults first, so this write cant cost them that seeding.',
security: AUTHED,
requestBody: jsonBody(SetPhotoTaggingSettingRequest, 'The setting to store'),
responses: {
200: json(BareBoolean, 'True when the setting was written'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const setting = await readSetting(c)
if (setting === null) return c.json(false)
const kvKey = settingsKey(id)
const existing = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
kvKey,
'json'
)
const base =
existing && Object.keys(existing).length > 0
? existing
: Object.fromEntries(DEFAULT_SETTINGS.map((s) => [s.Key, s.Value]))
await c.env.RECFLARE_PLAYER_SETTINGS.put(
kvKey,
JSON.stringify({ ...base, [PHOTO_TAGGING_KEY]: String(setting) })
)
return c.json(true)
}
)
+80
View File
@@ -1647,6 +1647,84 @@ describe('account', () => {
)
})
describe('photo tagging setting', () => {
const PATH = `${ORIGIN}/api/players/v1/playerPhotoTaggingSetting`
const read = async (sub: string) => exports.default.fetch(PATH, { headers: await bearer(sub) })
const write = async (sub: string, body: unknown) =>
exports.default.fetch(PATH, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...(await bearer(sub)) },
body: JSON.stringify(body),
})
test('both verbs 401 without a bearer token', async () => {
expect((await exports.default.fetch(PATH)).status).toBe(401)
const put = await exports.default.fetch(PATH, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Setting: 1 }),
})
expect(put.status).toBe(401)
})
test('a player who has never set one reads Anyone (0)', async () => {
const res = await read('9001')
expect(res.status).toBe(200)
expect(await res.json()).toBe(0)
})
test('PUT stores the setting and answers true', async () => {
const put = await write('9002', { Setting: 2 })
expect(put.status).toBe(200)
expect(await put.json()).toBe(true)
expect(await (await read('9002')).json()).toBe(2)
})
test('the enum name is accepted alongside the ordinal', async () => {
expect(await (await write('9003', { Setting: 'Friends' })).json()).toBe(true)
expect(await (await read('9003')).json()).toBe(1)
})
test('an unrecognized setting is a false, and changes nothing', async () => {
await write('9004', { Setting: 2 })
expect(await (await write('9004', { Setting: 7 })).json()).toBe(false)
expect(await (await write('9004', {})).json()).toBe(false)
expect(await (await read('9004')).json()).toBe(2)
})
test('the write merges — the players other settings survive', async () => {
await env.RECFLARE_PLAYER_SETTINGS.put(
'player:9005',
JSON.stringify({ TUTORIAL_COMPLETE_MASK: '11' })
)
await write('9005', { Setting: 1 })
expect(
await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>('player:9005', 'json')
).toEqual({ TUTORIAL_COMPLETE_MASK: '11', PlayerPhotoTaggingSetting: '1' })
})
test('a first write seeds the playersettings defaults alongside it', async () => {
await write('9006', { Setting: 2 })
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
'player:9006',
'json'
)
expect(stored).toMatchObject({ 'Recroom.OOBE': '77', PlayerPhotoTaggingSetting: '2' })
})
test('a form-urlencoded PUT is accepted too', async () => {
const res = await exports.default.fetch(PATH, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(await bearer('9007')) },
body: new URLSearchParams({ Setting: '1' }),
})
expect(await res.json()).toBe(true)
expect(await (await read('9007')).json()).toBe(1)
})
})
describe('auth-gated endpoints', () => {
test('401 without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`)
@@ -3850,6 +3928,7 @@ describe('openapi', () => {
'GET /api/playerevents/v1/tagfilters',
'GET /api/playerevents/v1/{eventId}',
'GET /api/playerevents/v1/{eventId}/responses',
'GET /api/players/v1/playerPhotoTaggingSetting',
'GET /api/players/v1/progression/{id}',
'GET /api/players/v2/progression/bulk',
'GET /api/quickPlay/v1/getandclear',
@@ -3917,6 +3996,7 @@ describe('openapi', () => {
'POST /api/sanitize/v1',
'POST /api/sanitize/v1/isPure',
'POST /api/v1/progression/bulk',
'PUT /api/players/v1/playerPhotoTaggingSetting',
'PUT /outfits/me',
])
+9
View File
@@ -32,6 +32,15 @@
"bucket_name": "recflare-cdn"
}
],
// Per-player settings bag, shared with the `playersettings` worker (which owns it
// and serves the same map at `/playersettings`). Same binding name and same "local"
// id placeholder as that worker's config, so both point at one namespace.
"kv_namespaces": [
{
"binding": "RECFLARE_PLAYER_SETTINGS",
"id": "local"
}
],
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
// the `notify` worker). We only invoke its RPC methods; no migration here.
"durable_objects": {