From 44a6b6b1782db7d0aacb7fd2bc6013a05ae9897f Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 19 Aug 2026 16:01:05 -0400 Subject: [PATCH] [chat] updates to privacy and filters --- apps/api/package.json | 1 + apps/api/src/openapi.ts | 2 +- apps/api/src/routes/gameplay.ts | 42 ++++- apps/api/src/sanitize.ts | 49 ++++++ apps/api/src/test/integration/api.test.ts | 63 ++++++- apps/chat/src/chat.app.ts | 187 ++++++++++++++++++--- apps/chat/src/context.ts | 5 + apps/chat/src/openapi.ts | 35 +++- apps/chat/src/test/integration/api.test.ts | 127 +++++++++++++- apps/chat/wrangler.jsonc | 10 ++ pnpm-lock.yaml | 9 + 11 files changed, 488 insertions(+), 42 deletions(-) create mode 100644 apps/api/src/sanitize.ts diff --git a/apps/api/package.json b/apps/api/package.json index 400300b..9b0595a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,6 +16,7 @@ "test": "run-vitest" }, "dependencies": { + "@2toad/profanity": "3.3.0", "@repo/domain": "workspace:*", "@repo/hono-helpers": "workspace:*", "@repo/jwt": "workspace:*", diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 5b9d73a..4a640d7 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -556,7 +556,7 @@ export const SuccessValueEnvelope = z.object({ success: z.boolean(), value: z.nu /** `POST /api/sanitize/v1` JSON body — the text to clean. */ export const SanitizeRequest = z.object({ Value: z.string() }) -/** `POST /api/sanitize/v1/isPure` — whether the text is clean (always true here). */ +/** `POST /api/sanitize/v1/isPure` — whether the text is free of profanity. */ export const IsPureResponse = z.object({ IsPure: z.boolean() }) /** `GET /api/keepsakes/globalconfig` — the keepsake feature switches. */ diff --git a/apps/api/src/routes/gameplay.ts b/apps/api/src/routes/gameplay.ts index 897828f..000246f 100644 --- a/apps/api/src/routes/gameplay.ts +++ b/apps/api/src/routes/gameplay.ts @@ -3,7 +3,9 @@ import { describeRoute } from 'hono-openapi' import charadesWords from '../../static/charades.json' import communityBoard from '../../static/community-board.json' +import { authedId, unauthorized } from '../http' import { + AUTHED, BareString, idParam, IsPureResponse, @@ -15,10 +17,23 @@ import { KeepsakeConfig, SanitizeRequest, stringParam, + UNAUTHORIZED_RESPONSE, } from '../openapi' +import { containsSwears } from '../sanitize' +import type { Context } from 'hono' import type { App } from '../context' +/** + * The text to check, from the JSON body the client posts (`{ "Value": "..." }`). A body + * that isn't JSON, or carries no `Value`, reads as the empty string — which every caller + * here treats as "nothing to object to" rather than as a bad request. + */ +async function sanitizeValue(c: Context): Promise { + const body = await c.req.json<{ Value?: unknown }>().catch(() => ({}) as { Value?: unknown }) + return typeof body.Value === 'string' ? body.Value : '' +} + // Text sanitization, keepsakes, objectives/events/rewards, and the misc analytics // sinks the client hits during load. export const gameplayRoutes = new Hono({ strict: false }) @@ -36,21 +51,34 @@ export const gameplayRoutes = new Hono({ strict: false }) requestBody: jsonBody(SanitizeRequest, 'The text to clean'), responses: { 200: json(BareString, 'The input text, unchanged (a bare JSON string)') }, }), - async (c) => { - const body = await c.req.json<{ Value?: unknown }>().catch(() => ({}) as { Value?: unknown }) - return c.json(typeof body.Value === 'string' ? body.Value : '') - } + async (c) => c.json(await sanitizeValue(c)) ) + // The yes/no form of the filter, and the one that actually filters: the client asks + // this before it accepts a display name, a room name or an invention title. Auth-gated, + // as the reference is — the client only ever asks while logged in. .post( '/api/sanitize/v1/isPure', describeRoute({ tags: ['Gameplay'], summary: 'Whether a string is clean', - description: 'The yes/no form of the filter. Always `true` — nothing is filtered here.', + description: + 'Reports whether the posted `Value` contains a swear — the check the client runs ' + + 'against a display name, room name or invention title before it accepts one. ' + + 'Matching is word-boundary aware, so ordinary words that contain a swear ' + + '(`analysis`, `Scunthorpe`, `class`) are pure, while leetspeak (`sh1t`, `a$$hole`) ' + + 'is not. An empty or absent `Value` is pure.', + security: AUTHED, requestBody: jsonBody(SanitizeRequest, 'The text to check'), - responses: { 200: json(IsPureResponse, 'Always pure') }, + responses: { + 200: json(IsPureResponse, 'Whether the text is clean'), + 401: UNAUTHORIZED_RESPONSE, + }, }), - (c) => c.json({ IsPure: true }) + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json({ IsPure: !containsSwears(await sanitizeValue(c)) }) + } ) // ---- Activities ----------------------------------------------------------- diff --git a/apps/api/src/sanitize.ts b/apps/api/src/sanitize.ts new file mode 100644 index 0000000..526c1b4 --- /dev/null +++ b/apps/api/src/sanitize.ts @@ -0,0 +1,49 @@ +import { Profanity } from '@2toad/profanity' + +/** + * The profanity filter behind `POST /api/sanitize/v1/isPure`. + * + * The word list is `@2toad/profanity`'s rather than one of ours: the hard part of this is + * not naming swears, it's not flagging ordinary text — a filter that rejects "Grape + * Escape" or "Title Screen" as a room name is worse than no filter, because the player is + * told their name is unacceptable and can't see why. It matches whole words, so `grape`, + * `analysis`, `assassin`, `class` and `Scunthorpe` come out clean, while leetspeak + * (`sh1t`, `a$$hole`) and letters spaced apart (`f u c k`) do not. + * + * Two knobs below adjust the list for this server; the matching itself is the library's. + */ + +/** + * Words to filter on top of the library's list — the ones it doesn't carry but a room + * full of players will find. Matched as whole words like everything else, so `kys` here + * doesn't flag `skyscraper`. + */ +const EXTRA_WORDS: string[] = ['kys', 'molest'] + +/** + * Innocent words that the list reads a swear inside of. Empty today — the whole-word + * matching means the usual victims (`shiitake`, `Scunthorpe`, `analysis`) already pass — + * and this is where one goes if a player ever turns up with a name it gets wrong, rather + * than a change to how matching works. + */ +const ALLOWED_WORDS: string[] = [] + +/** + * Built once per isolate, not per request: the constructor compiles the word list into a + * regex, which is the whole reason a check costs microseconds at request time. Module + * scope is where that cost belongs. + */ +const filter = new Profanity({ wholeWord: true }) +filter.addWords(EXTRA_WORDS) +filter.whitelist.addWords(ALLOWED_WORDS) + +/** + * Whether `value` contains a swear. Mirrors the reference server's + * `Sanitize.ContainsSwears`, which is the whole of what `isPure` reports. + * + * An empty value is clean — the client checks a field as it's being typed, and an empty + * box is not something to refuse. + */ +export function containsSwears(value: string): boolean { + return value !== '' && filter.exists(value) +} diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index d27aa7a..0124fad 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -1700,7 +1700,7 @@ describe('public endpoints', () => { expect(await ids(featuredPage)).toEqual([202]) }) - test('POST /api/sanitize/v1 echoes the value; isPure reports true', async () => { + test('POST /api/sanitize/v1 echoes the value', async () => { const san = await exports.default.fetch(`${ORIGIN}/api/sanitize/v1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1708,10 +1708,65 @@ describe('public endpoints', () => { }) expect(san.status).toBe(200) expect(await san.json()).toBe('hello world') + }) - const pure = await exports.default.fetch(`${ORIGIN}/api/sanitize/v1/isPure`, { method: 'POST' }) - expect(pure.status).toBe(200) - expect(await pure.json()).toEqual({ IsPure: true }) + describe('POST /api/sanitize/v1/isPure', () => { + const isPure = async (Value?: string, authed = true) => + exports.default.fetch(`${ORIGIN}/api/sanitize/v1/isPure`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(authed ? await bearer('42') : {}), + }, + body: Value === undefined ? undefined : JSON.stringify({ Value }), + }) + + test('401s without a token', async () => { + expect((await isPure('hello', false)).status).toBe(401) + }) + + test.each([ + 'hello world', + 'My Cool Room', + // The words a substring filter gets wrong. Rejecting these is worse than + // missing a swear: the player is told the name is unacceptable and can't + // see why. + 'Grape Escape', + 'Title Screen', + 'assassin', + 'Bass Pro Shop', + 'analysis of the class', + 'Scunthorpe United', + 'shiitake mushrooms', + // Nothing to object to in an empty box — the client checks as you type. + '', + ])('%j is pure', async (value) => { + const res = await isPure(value) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ IsPure: true }) + }) + + test.each([ + 'fuck this', + // Leetspeak and symbol substitution are folded back to letters. + 'sh1t', + 'a$$hole', + 'n1gger', + // A swear anywhere in the string, not just on its own. + 'my totally fucking cool room', + // Ours, on top of the dataset — see EXTRA_PATTERNS. + 'kys', + ])('%j is not pure', async (value) => { + const res = await isPure(value) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ IsPure: false }) + }) + + test('a body with no Value is pure rather than a bad request', async () => { + const res = await isPure() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ IsPure: true }) + }) }) }) diff --git a/apps/chat/src/chat.app.ts b/apps/chat/src/chat.app.ts index 2be9e71..496002e 100644 --- a/apps/chat/src/chat.app.ts +++ b/apps/chat/src/chat.app.ts @@ -10,6 +10,7 @@ import { getThreadMessages } from './message-db' import { AUTHED, ChatMessageDto, + ChatPrivacySettingRequest, ChatPrivacySettings, ChatResult, ChatThreadDto, @@ -50,7 +51,7 @@ import { } from './thread-db' import type { Context } from 'hono' -import type { App } from './context' +import type { App, Env } from './context' import type { ChatMessage } from './message-db' /** @@ -108,7 +109,10 @@ const PARTY_INVITE_LIFETIME_MINUTES = 60 /** * Who may start a chat with a player — the client's `ChatPrivacy` enum, served numerically * like every other enum on this build. `Friends` is what a fresh account reports, and what - * every account reports here: nothing stores a per-player setting yet. + * a player who has never touched their privacy screen reads back here. + * + * The PUT spells the same enum by NAME (`directMessagePrivacySetting=Favorites`); only the + * GET is numeric. Both directions go through `parseChatPrivacy`, which takes either. */ const ChatPrivacy = { Friends: 0, @@ -116,6 +120,92 @@ const ChatPrivacy = { NoOne: 2, } as const +type ChatPrivacyValue = (typeof ChatPrivacy)[keyof typeof ChatPrivacy] + +/** The enum member names, indexed by ordinal — what a stored setting holds. */ +const CHAT_PRIVACY_NAMES = ['Friends', 'Favorites', 'NoOne'] as const + +/** + * The keys the two settings live under in the player's `playersettings` map. Chat has no + * table of its own for them: they belong with the player's other toggles, and the settings + * bag is already read and written per player. + */ +const DM_PRIVACY_KEY = 'directMessagePrivacySetting' +const GROUP_PRIVACY_KEY = 'groupChatPrivacySetting' + +/** + * A `ChatPrivacy` out of whatever was stored or posted — the member name as the client + * sends it (case-insensitively), or the ordinal as the GET serves it, since a value that + * made a round trip through the settings bag could be spelled either way. + * + * `undefined` for anything unrecognized, which the read and the write treat differently: a + * stored value that won't parse falls back to the default, but a posted one that won't + * parse is a field worth leaving alone rather than a write of `Friends`. + */ +function parseChatPrivacy(value: string | undefined): ChatPrivacyValue | undefined { + const raw = (value ?? '').trim() + if (raw === '') return undefined + + const byName = CHAT_PRIVACY_NAMES.findIndex((n) => n.toLowerCase() === raw.toLowerCase()) + if (byName !== -1) return byName as ChatPrivacyValue + + const ordinal = Number.parseInt(raw, 10) + return ordinal >= 0 && ordinal < CHAT_PRIVACY_NAMES.length + ? (ordinal as ChatPrivacyValue) + : undefined +} + +/** The player's settings map from the KV the `playersettings` worker owns. */ +async function getPlayerSettings( + env: Env, + accountId: number +): Promise | null> { + return env.RECFLARE_PLAYER_SETTINGS.get>( + `player:${accountId}`, + 'json' + ).catch(() => null) +} + +/** + * A player's two chat privacy settings. Absent settings, an absent key and an unparseable + * value all read `Friends` — the reference's default, and the safer of the two directions + * to be wrong in: it describes a player as more private than this server enforces, rather + * than less. + */ +async function readChatPrivacy( + env: Env, + accountId: number +): Promise<{ + directMessagePrivacySetting: ChatPrivacyValue + groupChatPrivacySetting: ChatPrivacyValue +}> { + const stored = (await getPlayerSettings(env, accountId)) ?? {} + return { + directMessagePrivacySetting: parseChatPrivacy(stored[DM_PRIVACY_KEY]) ?? ChatPrivacy.Friends, + groupChatPrivacySetting: parseChatPrivacy(stored[GROUP_PRIVACY_KEY]) ?? ChatPrivacy.Friends, + } +} + +/** + * Write the posted setting(s) back into the player's settings map. + * + * The write MERGES, exactly as the `playersettings` worker's own PUT does: the map holds + * every setting the player has (OOBE state, tutorial mask, …), so storing these two on + * their own would wipe the rest. Values are stored by NAME, the way the client posts them, + * so the bag stays readable; `parseChatPrivacy` takes either spelling back. + */ +async function writeChatPrivacy( + env: Env, + accountId: number, + settings: Partial> +): Promise { + const merged: Record = { ...(await getPlayerSettings(env, accountId)) } + for (const [key, value] of Object.entries(settings)) { + if (value !== undefined) merged[key] = CHAT_PRIVACY_NAMES[value] + } + await env.RECFLARE_PLAYER_SETTINGS.put(`player:${accountId}`, JSON.stringify(merged)) +} + /** The hub is a single global Durable Object instance, as every worker addresses it. */ const HUB_INSTANCE = 'global' @@ -525,14 +615,16 @@ const app = new Hono() ) // The caller's chat privacy settings — who may DM them, and who may pull them into a - // group chat. Both report `Friends`, which is the reference's default and the safer of - // the two directions to be wrong in: it describes a player as more private than the - // server actually enforces, rather than less. + // group chat — read out of their `playersettings` map. A player who has never opened the + // privacy screen reads `Friends` for both, the reference's default and the safer of the + // two directions to be wrong in: it describes a player as more private than the server + // actually enforces, rather than less. // - // REPORTED, NOT ENFORCED. Nothing here stores a per-player setting or checks one — the - // DM check below allows every message regardless — so this is what the client renders on - // its privacy screen. Wire the two together if this ever becomes real: a screen that says - // "Friends" while anyone can message you is worse than one that says nothing. + // STORED, NOT ENFORCED. The PUT below keeps the player's choice, but nothing checks it: + // the DM check further down allows every message regardless, because this server has no + // friends/favorites list to test a sender against. Wire the two together once it does — + // a screen that says "Favorites" while anyone can message you is worse than one that + // says nothing. // // `playerId` comes off the TOKEN, not a query param: the answer is about the caller. .get( @@ -542,31 +634,79 @@ const app = new Hono() summary: 'The caller’s chat privacy settings', description: [ 'Who may direct-message the caller and who may add them to a group chat, as the', - '`ChatPrivacy` enum by NUMBER (0 Friends · 1 Favorites · 2 NoOne). Both are `Friends`', - 'here — nothing stores a per-player setting — and nothing enforces them either: the', - 'DM check allows every message. `playerId` is the caller, read from the token.', + '`ChatPrivacy` enum by NUMBER (0 Friends · 1 Favorites · 2 NoOne) — note the PUT takes', + 'the same enum by NAME. Read from the caller’s `playersettings` map; a player who has', + 'never set them reads `Friends` for both, as does one whose stored value won’t parse.', + 'Stored but not enforced: the DM check allows every message. `playerId` is the caller,', + 'read from the token.', ].join(' '), security: AUTHED, responses: { - 200: json(ChatPrivacySettings, 'The caller’s settings — always Friends/Friends'), + 200: json(ChatPrivacySettings, 'The caller’s stored settings (Friends/Friends by default)'), 401: UNAUTHORIZED_RESPONSE, }, }), async (c) => { const id = await authedId(c) if (id === null) return c.body(null, 401) - return c.json({ - playerId: id, - directMessagePrivacySetting: ChatPrivacy.Friends, - groupChatPrivacySetting: ChatPrivacy.Friends, - }) + return c.json({ playerId: id, ...(await readChatPrivacy(c.env, id)) }) + } + ) + + // Set one of the two settings. The client PUTs whichever row of its privacy screen the + // player just changed — `directMessagePrivacySetting=Favorites` OR + // `groupChatPrivacySetting=Favorites`, never both — so a field that isn't in the body is + // left alone rather than reset to the default, which would silently undo the other row. + // + // The body spells the enum by NAME while the GET answers the ordinal; that asymmetry is + // the client's, not a mistake here. An unrecognized value writes nothing. + // + // Answers the RESULTING settings, the same body the GET serves, rather than an empty + // ack: the client has just changed a toggle it renders, and a body it can read back + // can't disagree with what was stored. + .put( + '/thread/chatPrivacySetting', + describeRoute({ + tags: ['Threads'], + summary: 'Set the caller’s chat privacy settings', + description: [ + 'Stores the posted setting(s) in the caller’s `playersettings` map and answers the', + 'resulting settings — the same body `GET /thread/chatPrivacySetting` serves, with the', + 'enum by NUMBER. The body names the enum by NAME', + '(`directMessagePrivacySetting=Favorites`); the ordinal is accepted too. The client', + 'sends one field per call, so an absent field leaves that setting as it was, and the', + 'write merges into the settings map so the player’s other settings are untouched. A', + 'body with nothing readable in it is a no-op 200 answering the stored settings, not a', + '400. Stored, not enforced: nothing checks these when a message is sent.', + ].join(' '), + security: AUTHED, + requestBody: form(ChatPrivacySettingRequest, 'The setting(s) to store'), + responses: { + 200: json(ChatPrivacySettings, 'The caller’s settings as they now stand'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) + + const posted = { + [DM_PRIVACY_KEY]: parseChatPrivacy(await formField(c, DM_PRIVACY_KEY)), + [GROUP_PRIVACY_KEY]: parseChatPrivacy(await formField(c, GROUP_PRIVACY_KEY)), + } + if (posted[DM_PRIVACY_KEY] !== undefined || posted[GROUP_PRIVACY_KEY] !== undefined) { + await writeChatPrivacy(c.env, id, posted) + } + + return c.json({ playerId: id, ...(await readChatPrivacy(c.env, id)) }) } ) // May the caller DM this player? Asked before the client opens a new direct message, so - // it can grey the button out rather than let the send fail. Always 0 (Success): nothing - // here stores the who-can-message-me privacy setting the name refers to, so there is no - // setting to refuse on. + // it can grey the button out rather than let the send fail. Always 0 (Success): the + // setting the name refers to is stored (see `/thread/chatPrivacySetting`) but can't be + // checked, since Friends and Favorites both need a friends list this server doesn't + // keep. Enforce it here the moment one exists. // // The body is a bare ChatResult INTEGER — the client instantiates its response wrapper // with the ChatResult enum, not a bool, so `true` decodes as nothing. The refusals this @@ -582,8 +722,9 @@ const app = new Hono() description: [ 'Whether the caller may open a direct message with `receivingPlayerId`, as a bare', 'ChatResult integer — 0 (Success) means allowed; a real refusal would be 15 (the', - 'caller’s own privacy setting) or 16 (the other player’s). Always 0 here: this server', - 'stores no who-can-message-me privacy setting, so there is nothing to refuse on.', + 'caller’s own privacy setting) or 16 (the other player’s). Always 0 here: the settings', + '`/thread/chatPrivacySetting` stores are not enforced, since Friends and Favorites both', + 'need a friends list this server doesn’t keep.', '`receivingPlayerId` is accepted and ignored; the answer is the same for every player,', 'and the client asks again for the next one.', ].join(' '), diff --git a/apps/chat/src/context.ts b/apps/chat/src/context.ts index 0f0da3b..2999227 100644 --- a/apps/chat/src/context.ts +++ b/apps/chat/src/context.ts @@ -11,6 +11,11 @@ export type Env = SharedHonoEnv & { JWT_SECRET: SecretsStoreSecret /** Shared `recflare` D1 — this worker owns the `message` and thread tables. */ DB: D1Database + /** + * Per-player settings KV, owned by the `playersettings` worker. Holds the caller's + * chat privacy settings (`/thread/chatPrivacySetting`) alongside their other toggles. + */ + RECFLARE_PLAYER_SETTINGS: KVNamespace /** The `notify` worker's NotificationsHub DO — pushes ChatMessageReceived to members. */ RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace } diff --git a/apps/chat/src/openapi.ts b/apps/chat/src/openapi.ts index 7ed8119..25bf1e0 100644 --- a/apps/chat/src/openapi.ts +++ b/apps/chat/src/openapi.ts @@ -165,14 +165,16 @@ export const PartyInviteSettings = z.object({ }) /** - * `GET /thread/chatPrivacySetting` — who may start a chat with the caller. camelCase, unlike - * the PascalCase thread DTOs, and the two settings are the `ChatPrivacy` enum served + * `GET|PUT /thread/chatPrivacySetting` — who may start a chat with the caller. camelCase, + * unlike the PascalCase thread DTOs, and the two settings are the `ChatPrivacy` enum served * NUMERICALLY (0 Friends · 1 Favorites · 2 NoOne): this client build carries no by-name enum - * formatter, so a string would decode as nothing. + * formatter, so a string would decode as nothing. Note the asymmetry with the PUT, which + * sends the enum by NAME (`directMessagePrivacySetting=Favorites`). * - * Reported, not enforced. Nothing on this server stores a per-player privacy setting or - * checks one — `GET /thread/checkCanSendDirectMessageWithPrivacySetting` allows every DM — - * so these are the values the client renders its privacy screen from. + * STORED, NOT ENFORCED. The PUT keeps the player's choice in the `playersettings` KV and this + * is what the client renders its privacy screen from, but nothing checks it — + * `GET /thread/checkCanSendDirectMessageWithPrivacySetting` still allows every DM, since this + * server has no friends/favorites list to test a sender against. */ export const ChatPrivacySettings = z.object({ playerId: z.int().describe('The caller — read from the token, not from the query'), @@ -257,6 +259,27 @@ export const SnoozeThreadRequest = z.object({ .describe('`True`/`False` as the client spells it (`1`/`yes` also count as true)'), }) +/** + * `PUT /thread/chatPrivacySetting` form body. The client sends ONE of the two fields per + * call — it PUTs whichever row of its privacy screen the player just changed — so a field + * that isn't in the body leaves that setting as it was rather than resetting it. + * + * The value is the `ChatPrivacy` enum by NAME (`Favorites`), which is how the client spells + * it here even though the GET answers with the ordinal; the ordinal is accepted too. + */ +export const ChatPrivacySettingRequest = z.object({ + directMessagePrivacySetting: z + .string() + .optional() + .describe('Who may DM the caller: `Friends` · `Favorites` · `NoOne` (or 0 · 1 · 2)'), + groupChatPrivacySetting: z + .string() + .optional() + .describe( + 'Who may add the caller to a group chat: `Friends` · `Favorites` · `NoOne` (or 0 · 1 · 2)' + ), +}) + /** `PUT|POST /thread/:id/favorite` form body. */ export const FavoriteThreadRequest = z.object({ favorite: z diff --git a/apps/chat/src/test/integration/api.test.ts b/apps/chat/src/test/integration/api.test.ts index 55743a8..e840dc7 100644 --- a/apps/chat/src/test/integration/api.test.ts +++ b/apps/chat/src/test/integration/api.test.ts @@ -410,7 +410,7 @@ describe('GET /thread/party', () => { }) describe('GET /thread/chatPrivacySetting', () => { - it('reports Friends for both settings, keyed to the caller', async () => { + it('reports Friends for both settings by default, keyed to the caller', async () => { const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`, { headers: await bearer(886001), }) @@ -424,6 +424,37 @@ describe('GET /thread/chatPrivacySetting', () => { }) }) + it('reads the stored settings out of the player settings map', async () => { + await env.RECFLARE_PLAYER_SETTINGS.put( + 'player:886003', + JSON.stringify({ + directMessagePrivacySetting: 'Favorites', + groupChatPrivacySetting: 'NoOne', + }) + ) + const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`, { + headers: await bearer(886003), + }) + expect(await res.json()).toEqual({ + playerId: 886003, + directMessagePrivacySetting: 1, + groupChatPrivacySetting: 2, + }) + }) + + it('falls back to Friends for a stored value it can’t parse', async () => { + await env.RECFLARE_PLAYER_SETTINGS.put( + 'player:886004', + JSON.stringify({ directMessagePrivacySetting: 'Nobody at all' }) + ) + const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`, { + headers: await bearer(886004), + }) + expect( + ((await res.json()) as { directMessagePrivacySetting: number }).directMessagePrivacySetting + ).toBe(0) + }) + it('reads playerId off the token, not a query param', async () => { const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting?playerId=999999`, { headers: await bearer(886002), @@ -437,6 +468,99 @@ describe('GET /thread/chatPrivacySetting', () => { }) }) +describe('PUT /thread/chatPrivacySetting', () => { + const path = `${ORIGIN}/thread/chatPrivacySetting` + + /** The client's PUT: one form field, the enum by NAME. */ + async function put(playerId: number, body: Record) { + return SELF.fetch(path, { + method: 'PUT', + headers: await bearer(playerId), + body: new URLSearchParams(body), + }) + } + + const settings = async (playerId: number) => + env.RECFLARE_PLAYER_SETTINGS.get>(`player:${playerId}`, 'json') + + it('stores the DM setting and answers the resulting settings', async () => { + const res = await put(887001, { directMessagePrivacySetting: 'Favorites' }) + expect(res.status).toBe(200) + // The same body the GET serves — the enum by NUMBER, not the name that was posted. + expect(await res.json()).toEqual({ + playerId: 887001, + directMessagePrivacySetting: 1, + groupChatPrivacySetting: 0, + }) + // Stored by NAME in the player settings map the `playersettings` worker owns. + expect((await settings(887001))?.directMessagePrivacySetting).toBe('Favorites') + }) + + it('stores the group chat setting on its own', async () => { + const res = await put(887002, { groupChatPrivacySetting: 'NoOne' }) + expect(await res.json()).toEqual({ + playerId: 887002, + directMessagePrivacySetting: 0, + groupChatPrivacySetting: 2, + }) + }) + + it('leaves the other setting alone — the client sends one field per call', async () => { + await put(887003, { directMessagePrivacySetting: 'NoOne' }) + const res = await put(887003, { groupChatPrivacySetting: 'Favorites' }) + expect(await res.json()).toEqual({ + playerId: 887003, + directMessagePrivacySetting: 2, + groupChatPrivacySetting: 1, + }) + }) + + it('merges, leaving the player’s other settings untouched', async () => { + await env.RECFLARE_PLAYER_SETTINGS.put( + 'player:887004', + JSON.stringify({ 'Recroom.OOBE': '77' }) + ) + await put(887004, { directMessagePrivacySetting: 'Favorites' }) + expect(await settings(887004)).toEqual({ + 'Recroom.OOBE': '77', + directMessagePrivacySetting: 'Favorites', + }) + }) + + it('accepts the enum by ordinal too, and is case-insensitive about the name', async () => { + expect(await (await put(887005, { directMessagePrivacySetting: '2' })).json()).toMatchObject({ + directMessagePrivacySetting: 2, + }) + expect( + await (await put(887005, { directMessagePrivacySetting: 'favorites' })).json() + ).toMatchObject({ directMessagePrivacySetting: 1 }) + }) + + it('accepts the fields in the query string as well as the body', async () => { + const res = await SELF.fetch(`${path}?groupChatPrivacySetting=NoOne`, { + method: 'PUT', + headers: await bearer(887006), + }) + expect(await res.json()).toMatchObject({ groupChatPrivacySetting: 2 }) + }) + + it('ignores an unreadable value rather than 400ing or writing a default', async () => { + await put(887007, { directMessagePrivacySetting: 'Favorites' }) + const res = await put(887007, { directMessagePrivacySetting: 'Whoever' }) + expect(res.status).toBe(200) + // Unchanged — a value that won't parse is not a write of `Friends`. + expect(await res.json()).toMatchObject({ directMessagePrivacySetting: 1 }) + }) + + it('401s without a token', async () => { + const res = await SELF.fetch(path, { + method: 'PUT', + body: new URLSearchParams({ directMessagePrivacySetting: 'NoOne' }), + }) + expect(res.status).toBe(401) + }) +}) + describe('GET /thread/checkCanSendDirectMessageWithPrivacySetting', () => { const path = `${ORIGIN}/thread/checkCanSendDirectMessageWithPrivacySetting` @@ -1406,6 +1530,7 @@ describe('openapi', () => { 'POST /thread/{id}/read', 'POST /thread/{id}/rename', 'POST /thread/{id}/snooze', + 'PUT /thread/chatPrivacySetting', 'PUT /thread/{id}/favorite', 'PUT /thread/{id}/message/{messageId}/read', 'PUT /thread/{id}/read', diff --git a/apps/chat/wrangler.jsonc b/apps/chat/wrangler.jsonc index b4b2a00..e0c6f50 100644 --- a/apps/chat/wrangler.jsonc +++ b/apps/chat/wrangler.jsonc @@ -17,6 +17,16 @@ "migrations_table": "d1_migrations_chat" } ], + // Per-player settings KV, owned by the `playersettings` worker. Chat keeps the two + // chat-privacy settings in it rather than in a table of its own, so they sit with the + // player's other toggles. The "local" id placeholder is replaced with the real id from + // RECFLARE_KV at deploy time. + "kv_namespaces": [ + { + "binding": "RECFLARE_PLAYER_SETTINGS", + "id": "local" + } + ], "logpush": false, // Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the // same store as JWT_SECRET so tokens signed by `auth` verify here. The "local" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1747780..6699190 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,9 @@ importers: apps/api: dependencies: + '@2toad/profanity': + specifier: 3.3.0 + version: 3.3.0 '@repo/domain': specifier: workspace:* version: link:../../packages/domain @@ -1340,6 +1343,10 @@ importers: packages: + '@2toad/profanity@3.3.0': + resolution: {integrity: sha512-hBptHbB6Regs5AgtHiLr/WfgHRh6ZGdA9EexOWU14n2eNjQekvK9/gg18iFUYvL2WrNb4X3Ci0bzgzJXhzmpNQ==} + engines: {node: '>=12'} + '@ai-sdk/gateway@3.0.13': resolution: {integrity: sha512-g7nE4PFtngOZNZSy1lOPpkC+FAiHxqBJXqyRMEG7NUrEVZlz5goBdtHg1YgWRJIX776JTXAmbOI5JreAKVAsVA==} engines: {node: '>=18'} @@ -4348,6 +4355,8 @@ packages: snapshots: + '@2toad/profanity@3.3.0': {} + '@ai-sdk/gateway@3.0.13(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.2