mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[chat] updates to privacy and filters
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
"test": "run-vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@2toad/profanity": "3.3.0",
|
||||
"@repo/domain": "workspace:*",
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 -----------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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 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<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',
|
||||
'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(' '),
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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 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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user