[chat] updates to privacy and filters

This commit is contained in:
Devin Zuczek
2026-08-19 16:01:05 -04:00
parent b22a3eb405
commit 44a6b6b178
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 })
})
})
})