diff --git a/apps/api/src/api.app.ts b/apps/api/src/api.app.ts index a209cee..8e0bc97 100644 --- a/apps/api/src/api.app.ts +++ b/apps/api/src/api.app.ts @@ -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({ 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. diff --git a/apps/api/src/context.ts b/apps/api/src/context.ts index 24c392b..f04b058 100644 --- a/apps/api/src/context.ts +++ b/apps/api/src/context.ts @@ -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:` → 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 diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 5bfb3f9..f046eba 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -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. */ diff --git a/apps/api/src/routes/players.ts b/apps/api/src/routes/players.ts new file mode 100644 index 0000000..0d0133a --- /dev/null +++ b/apps/api/src/routes/players.ts @@ -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): Promise { + const contentType = c.req.header('content-type') ?? '' + + if (contentType.includes('application/json')) { + const body = await c.req.json().catch(() => null) + if (body === null || typeof body !== 'object') return null + const rec = body as Record + return parsePhotoTaggingSetting(rec.Setting ?? rec.setting) + } + + const form = await c.req.parseBody().catch(() => ({}) as Record) + return parsePhotoTaggingSetting(form.Setting ?? form.setting) +} + +// ---- Players --------------------------------------------------------------- +export const playerRoutes = new Hono({ strict: false }) + .get( + '/api/players/v1/playerPhotoTaggingSetting', + describeRoute({ + tags: ['Players'], + summary: 'Who may tag the caller in photos', + description: + 'The caller’s `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 reference’s 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>( + 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 caller’s `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 player’s 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 can’t 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>( + 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) + } + ) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index c4d8e7a..eee7493 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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 player’s 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>('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>( + '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', ]) diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index 20261e8..578ff95 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -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": {