diff --git a/apps/playersettings/README.md b/apps/playersettings/README.md index 23f2bf7..adf0141 100644 --- a/apps/playersettings/README.md +++ b/apps/playersettings/README.md @@ -10,11 +10,18 @@ Player-settings worker served on the `playersettings` subdomain. `key=…&value=…` (or a JSON `{key,value}` / array) and **upserts** it into the player's settings, keyed by the `sub` claim of the Bearer JWT. Returns `200`. Persisted in Workers KV (`RECFLARE_PLAYER_SETTINGS`, key `player:`). +- `DELETE /playersettings` — `[Authorize]`. Removes a setting from the player's + map. The client sends a bare form-urlencoded `key=PlayerShoppingBagId` (no + `value`); a JSON body and a `?key=` query param are also read. Deleting a key + that isn't stored is a no-op `200`, not a `404`. > A full settings PUT would replace the player's _entire_ settings set on each > call; we merge instead, so a single-key PUT (e.g. `key=PlayerSessionCount`) > doesn't wipe the others. +> Emptying the map with DELETE puts the player back to a first read: the next +> `GET` re-seeds the defaults. + ## KV namespace ```sh diff --git a/apps/playersettings/src/openapi.ts b/apps/playersettings/src/openapi.ts index 33153ba..91ec371 100644 --- a/apps/playersettings/src/openapi.ts +++ b/apps/playersettings/src/openapi.ts @@ -103,3 +103,23 @@ export const SettingJsonWrite = z.union([ }) ), ]) + +/** + * The form-encoded delete the client actually sends: a bare `key=PlayerShoppingBagId`, + * with no `value`. An empty `key` is ignored. + */ +export const SettingFormDelete = z.object({ + key: z.string().describe('The setting name to remove; an empty key is ignored'), +}) + +/** + * The JSON form of the same delete. Accepted as a bare setting name, a `{ key }` / + * `{ Key }` object, or an array of either. + */ +export const SettingJsonDelete = z.union([ + z.string(), + z.object({ key: z.string().optional(), Key: z.string().optional() }), + z.array( + z.union([z.string(), z.object({ key: z.string().optional(), Key: z.string().optional() })]) + ), +]) diff --git a/apps/playersettings/src/playersettings.app.ts b/apps/playersettings/src/playersettings.app.ts index 5bd2cab..b915c58 100644 --- a/apps/playersettings/src/playersettings.app.ts +++ b/apps/playersettings/src/playersettings.app.ts @@ -12,7 +12,9 @@ import { HealthResponse, json, PlayerSettingEntry, + SettingFormDelete, SettingFormWrite, + SettingJsonDelete, SettingJsonWrite, UNAUTHORIZED_RESPONSE, } from './openapi' @@ -26,7 +28,7 @@ import type { App } from './context' * (`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. + * Every `/playersettings` route is auth-gated on the Bearer JWT issued by the `auth` worker. */ /** @@ -78,6 +80,45 @@ async function parseSettings(c: Context): Promise): Promise { + const contentType = c.req.header('content-type') ?? '' + + if (contentType.includes('application/json')) { + const body = await c.req.json().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 + 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) + 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() .use( '*', @@ -180,6 +221,59 @@ const app = new Hono() } ) + // 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 caller’s 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 isn’t 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>( + 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( diff --git a/apps/playersettings/src/test/integration/api.test.ts b/apps/playersettings/src/test/integration/api.test.ts index 3be76da..4f678e3 100644 --- a/apps/playersettings/src/test/integration/api.test.ts +++ b/apps/playersettings/src/test/integration/api.test.ts @@ -53,6 +53,17 @@ function putForm( } } +function deleteForm( + fields: Record, + headers: Record = {} +): RequestInit { + return { + method: 'DELETE', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers }, + body: new URLSearchParams(fields).toString(), + } +} + describe('playersettings endpoints', () => { it('GET / reports service status', async () => { const res = await SELF.fetch(`${ORIGIN}/`) @@ -134,6 +145,80 @@ describe('playersettings endpoints', () => { expect(res.status).toBe(200) }) + it('DELETE /playersettings 401s without a token', async () => { + const res = await SELF.fetch( + `${ORIGIN}/playersettings`, + deleteForm({ key: 'PlayerShoppingBagId' }) + ) + expect(res.status).toBe(401) + }) + + it('DELETE /playersettings removes the named key and leaves the rest', async () => { + await SELF.fetch( + `${ORIGIN}/playersettings`, + putForm({ key: 'PlayerShoppingBagId', value: 'bag-1' }, await bearer('20')) + ) + await SELF.fetch( + `${ORIGIN}/playersettings`, + putForm({ key: 'PlayerSessionCount', value: '3' }, await bearer('20')) + ) + + const res = await SELF.fetch( + `${ORIGIN}/playersettings`, + deleteForm({ key: 'PlayerShoppingBagId' }, await bearer('20')) + ) + expect(res.status).toBe(200) + + const stored = await env.RECFLARE_PLAYER_SETTINGS.get>( + 'player:20', + 'json' + ) + expect(stored).toEqual({ PlayerSessionCount: '3' }) + }) + + it('DELETE /playersettings reads a body with no content-type', async () => { + await SELF.fetch( + `${ORIGIN}/playersettings`, + putForm({ key: 'PlayerShoppingBagId', value: 'bag-2' }, await bearer('21')) + ) + + const res = await SELF.fetch(`${ORIGIN}/playersettings`, { + method: 'DELETE', + headers: await bearer('21'), + body: 'key=PlayerShoppingBagId', + }) + expect(res.status).toBe(200) + + const stored = await env.RECFLARE_PLAYER_SETTINGS.get>( + 'player:21', + 'json' + ) + expect(stored).toEqual({}) + }) + + it('DELETE /playersettings 200s for an unknown key and an empty body', async () => { + await SELF.fetch( + `${ORIGIN}/playersettings`, + putForm({ key: 'A', value: '1' }, await bearer('22')) + ) + + const unknown = await SELF.fetch( + `${ORIGIN}/playersettings`, + deleteForm({ key: 'NotStored' }, await bearer('22')) + ) + expect(unknown.status).toBe(200) + + const empty = await SELF.fetch(`${ORIGIN}/playersettings`, deleteForm({}, await bearer('22'))) + expect(empty.status).toBe(200) + + // Neither call touched the stored map. + const stored = await env.RECFLARE_PLAYER_SETTINGS.get>( + 'player:22', + 'json' + ) + expect(stored).toEqual({ A: '1' }) + }) + it('GET /openapi.json documents every route', async () => { const res = await SELF.fetch(`${ORIGIN}/openapi.json`) expect(res.status).toBe(200) @@ -154,7 +239,12 @@ describe('playersettings endpoints', () => { Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`) ) ) - expect([...documented].sort()).toEqual(['GET /', 'GET /playersettings', 'PUT /playersettings']) + expect([...documented].sort()).toEqual([ + 'DELETE /playersettings', + 'GET /', + 'GET /playersettings', + 'PUT /playersettings', + ]) // Every operation carries a summary — a path present but undescribed is not // documentation.