[chat] updates to privacy and filters

This commit is contained in:
Devin Zuczek
2026-08-19 16:01:05 -04:00
parent 244b3bca70
commit bf4389f480
11 changed files with 488 additions and 42 deletions
+1
View File
@@ -16,6 +16,7 @@
"test": "run-vitest"
},
"dependencies": {
"@2toad/profanity": "3.3.0",
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
+1 -1
View File
@@ -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. */
+35 -7
View File
@@ -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<App>): Promise<string> {
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<App>({ strict: false })
@@ -36,21 +51,34 @@ export const gameplayRoutes = new Hono<App>({ 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 -----------------------------------------------------------
+49
View File
@@ -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)
}
+59 -4
View File
@@ -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 })
})
})
})
+164 -23
View File
@@ -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<Record<string, string> | null> {
return env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
`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<Record<typeof DM_PRIVACY_KEY | typeof GROUP_PRIVACY_KEY, ChatPrivacyValue>>
): Promise<void> {
const merged: Record<string, string> = { ...(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<App>()
)
// 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<App>()
summary: 'The callers 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 callers `playersettings` map; a player who has',
'never set them reads `Friends` for both, as does one whose stored value wont 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 callers settings — always Friends/Friends'),
200: json(ChatPrivacySettings, 'The callers 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 callers chat privacy settings',
description: [
'Stores the posted setting(s) in the callers `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 players 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 callers 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<App>()
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',
'callers own privacy setting) or 16 (the other players). Always 0 here: this server',
'stores no who-can-message-me privacy setting, so there is nothing to refuse on.',
'callers own privacy setting) or 16 (the other players). Always 0 here: the settings',
'`/thread/chatPrivacySetting` stores are not enforced, since Friends and Favorites both',
'need a friends list this server doesnt keep.',
'`receivingPlayerId` is accepted and ignored; the answer is the same for every player,',
'and the client asks again for the next one.',
].join(' '),
+5
View File
@@ -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<NotificationsHub>
}
+29 -6
View File
@@ -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
+126 -1
View File
@@ -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 cant 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<string, string>) {
return SELF.fetch(path, {
method: 'PUT',
headers: await bearer(playerId),
body: new URLSearchParams(body),
})
}
const settings = async (playerId: number) =>
env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(`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 players 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',
+10
View File
@@ -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"