[api] 2025 gameconfig swap, implement sanitize

This commit is contained in:
Devin Zuczek
2026-08-19 17:10:19 -04:00
parent ec808d3cd8
commit 6916ed2557
6 changed files with 6373 additions and 33 deletions
+22 -2
View File
@@ -553,8 +553,28 @@ export const SuccessValueEnvelope = z.object({ success: z.boolean(), value: z.nu
// ---- Gameplay --------------------------------------------------------------
/** `POST /api/sanitize/v1` JSON body — the text to clean. */
export const SanitizeRequest = z.object({ Value: z.string() })
/**
* `POST /api/sanitize/v1` (and `/isPure`) JSON body. Only `Value` is acted on, plus
* `ReplacementChar` and `PreRemoveBlockedCharacters` on the sanitize route; the rest are
* what the client sends, kept here so the spec shows a real request.
*/
export const SanitizeRequest = z.object({
Value: z.string().describe('The text to clean or check'),
ReplacementChar: z
.string()
.optional()
.describe('The mask a swears characters are replaced with. Defaults to `*`'),
PreRemoveBlockedCharacters: z
.boolean()
.optional()
.describe('Strip control and zero-width characters before filtering'),
Context: z.string().optional().describe('The surface being checked, e.g. `RoomChat`. Ignored'),
Intent: z.int().optional().describe('Reference filtering intent. Ignored'),
ruleset: z
.int()
.optional()
.describe('Reference ruleset — lowercase, as the client sends it. Ignored'),
})
/** `POST /api/sanitize/v1/isPure` — whether the text is free of profanity. */
export const IsPureResponse = z.object({ IsPure: z.boolean() })
+31 -4
View File
@@ -1,9 +1,11 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { isSupportedGameVersion } from '@repo/domain'
import { GAME_VERSION, isSupportedGameVersion } from '@repo/domain'
import { validateAndGetVersion } from '@repo/jwt'
import apiConfigV2 from '../../static/api-config-v2.json'
import gameConfigsV1All2025 from '../../static/gameconfigs-v1-all-2025.json'
import gameConfigsV1All from '../../static/gameconfigs-v1-all.json'
import {
AmplitudeConfig,
@@ -129,15 +131,40 @@ export const configRoutes = new Hono<App>({ strict: false })
}),
(c) => c.json([])
)
// Two catalogs, one per client generation: the 2023 build and the 2025 build read
// different keys out of this, and the 2025 one carries entries (`Screens.*`, the
// creative-door queries) the older catalog never had.
//
// Which one a caller gets is decided by the token's `rn.ver` claim — the build the
// client posted at login — since the request itself carries no version. Anything NEWER
// than `GAME_VERSION` (20230414) gets the 2025 catalog; that build and anything older
// get the 2023 one. Builds are date-stamped (`20230414`, `20250718.01`), so they order
// as strings, the same comparison `match` makes for cross-build joins.
//
// A request with no readable token version gets the 2023 catalog, the same body this
// route has always served: unauthenticated is not evidence of a newer client, and this
// stack targets `GAME_VERSION`. Like the version gate on `featuredrooms`, the claim is
// unverified — a client that lies about its build only misconfigures itself.
.get(
'/api/gameconfigs/v1/all',
describeRoute({
tags: ['Config'],
summary: 'Per-game configuration',
description: 'An opaque static catalog of per-game settings, served verbatim.',
responses: { 200: json(JsonObject, 'The game config catalog') },
description:
'An opaque static catalog of per-game settings, served verbatim. There are two: a ' +
'build NEWER than `20230414` gets the 2025 catalog, which carries keys the older one ' +
'never had; that build and anything older get the 2023 catalog. Builds are ' +
'date-stamped, so they compare as strings. The build is read from the tokens ' +
'`rn.ver` claim — the request carries no version of its own — so auth is optional ' +
'here and only selects the catalog; a request without a readable token version gets ' +
'the 2023 one.',
responses: { 200: json(JsonObject, 'The game config catalog for the callers build') },
}),
(c) => c.json(gameConfigsV1All)
async (c) => {
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
const newerThanTarget = version !== null && version > GAME_VERSION
return c.json(newerThanTarget ? gameConfigsV1All2025 : gameConfigsV1All)
}
)
// The property bag the client would attach to its Statsig user. The reference server
+59 -15
View File
@@ -19,39 +19,82 @@ import {
stringParam,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import { containsSwears } from '../sanitize'
import {
censorSwears,
containsSwears,
DEFAULT_REPLACEMENT_CHAR,
removeBlockedCharacters,
} 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.
* A sanitize request, as the client posts it:
*
* ```json
* { "Value": "...", "ReplacementChar": "*", "Context": "RoomChat",
* "Intent": 1, "ruleset": 0, "PreRemoveBlockedCharacters": false }
* ```
*
* Fields are read case-insensitively because the client's own casing isn't consistent —
* it sends `ruleset` lowercase among otherwise PascalCase keys, and a reader that trusts
* one spelling silently ignores the other.
*
* `Context` ("RoomChat", and whatever else names the surface being checked), `Intent` and
* `ruleset` are read but not acted on: they select among the reference's several
* filtering policies and this server has one, so honouring them would mean inventing
* differences between them. A body that isn't JSON, or carries no `Value`, reads as the
* empty string — nothing to object to, rather than 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 : ''
async function sanitizeRequest(
c: Context<App>
): Promise<{ value: string; replacementChar: string; preRemoveBlockedCharacters: boolean }> {
const body = await c.req
.json<Record<string, unknown>>()
.catch(() => ({}) as Record<string, unknown>)
const field = (name: string): unknown => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
return key === undefined ? undefined : body[key]
}
const value = field('Value')
const replacementChar = field('ReplacementChar')
return {
value: typeof value === 'string' ? value : '',
replacementChar:
typeof replacementChar === 'string' && replacementChar !== ''
? replacementChar
: DEFAULT_REPLACEMENT_CHAR,
preRemoveBlockedCharacters: field('PreRemoveBlockedCharacters') === true,
}
}
// Text sanitization, keepsakes, objectives/events/rewards, and the misc analytics
// sinks the client hits during load.
export const gameplayRoutes = new Hono<App>({ strict: false })
// Text sanitization (display names, room names, chat). `v1` echoes the input
// value back; `isPure` reports the text is clean.
// Text sanitization (display names, room names, chat). `v1` masks the swears in the
// text and hands it back; `isPure` answers the same question as a yes/no.
.post(
'/api/sanitize/v1',
describeRoute({
tags: ['Gameplay'],
summary: 'Sanitize a string',
description:
'Runs display names, room names and chat through the profanity filter. There is ' +
'no filter here — the input `Value` is echoed back verbatim as a bare JSON string ' +
'(an empty string if the body has no `Value`).',
'Masks any swear in the posted `Value` and returns the cleaned text as a bare JSON ' +
'string. Each character of a swear becomes the requests `ReplacementChar` (`*` when ' +
'it names none), so the shape of the message survives; text with nothing to object ' +
'to comes back untouched. `PreRemoveBlockedCharacters` strips control and zero-width ' +
'characters first — the ones used to break a word up so a filter misses it. ' +
'`Context`, `Intent` and `ruleset` are accepted and ignored: they pick among the ' +
'references filtering policies, and this server has one.',
requestBody: jsonBody(SanitizeRequest, 'The text to clean'),
responses: { 200: json(BareString, 'The input text, unchanged (a bare JSON string)') },
responses: { 200: json(BareString, 'The cleaned text (a bare JSON string)') },
}),
async (c) => c.json(await sanitizeValue(c))
async (c) => {
const { value, replacementChar, preRemoveBlockedCharacters } = await sanitizeRequest(c)
const text = preRemoveBlockedCharacters ? removeBlockedCharacters(value) : value
return c.json(censorSwears(text, replacementChar))
}
)
// 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,
@@ -77,7 +120,8 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ IsPure: !containsSwears(await sanitizeValue(c)) })
const { value } = await sanitizeRequest(c)
return c.json({ IsPure: !containsSwears(value) })
}
)
+91 -2
View File
@@ -1,4 +1,4 @@
import { Profanity } from '@2toad/profanity'
import { CensorType, Profanity } from '@2toad/profanity'
/**
* The profanity filter behind `POST /api/sanitize/v1/isPure`.
@@ -28,12 +28,24 @@ const EXTRA_WORDS: string[] = ['kys', 'molest']
*/
const ALLOWED_WORDS: string[] = []
/**
* A character that cannot appear in text a player typed, used to find where the filter
* matched: censoring with {@link CensorType.FirstChar} replaces the first character of
* every match with it and leaves the length alone, so the marker positions in the result
* ARE the match offsets in the original. The library exposes no other way to ask where a
* match is — its own censor replaces a match with a fixed string, which loses the length
* the client's `ReplacementChar` is meant to preserve.
*
* Stripped from the input before use, so nothing can smuggle one in and confuse the scan.
*/
const MARKER = '\u0000'
/**
* 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 })
const filter = new Profanity({ wholeWord: true, grawlixChar: MARKER })
filter.addWords(EXTRA_WORDS)
filter.whitelist.addWords(ALLOWED_WORDS)
@@ -47,3 +59,80 @@ filter.whitelist.addWords(ALLOWED_WORDS)
export function containsSwears(value: string): boolean {
return value !== '' && filter.exists(value)
}
/** The mask `POST /api/sanitize/v1` uses when the request names no `ReplacementChar`. */
export const DEFAULT_REPLACEMENT_CHAR = '*'
/**
* How far past a match's start to look for the end of it. Long enough for a swear spaced
* out letter by letter (`f u c k`), short enough that the probe below stays bounded.
*/
const MAX_SPAN = 40
/**
* Characters that carry no text: control codes, and the format characters (zero-width
* joiners, bidi overrides, the byte-order mark) whose whole use in a chat message is to
* break a word up so a filter reads it as two. Removed on request — the client asks with
* `PreRemoveBlockedCharacters`.
*/
const BLOCKED_CHARACTERS = /[\p{Cc}\p{Cf}]/gu
/** Strip the characters {@link BLOCKED_CHARACTERS} describes. */
export function removeBlockedCharacters(value: string): string {
return value.replaceAll(BLOCKED_CHARACTERS, '')
}
/**
* Where the match starting at `start` ends.
*
* The library reports where a match begins but not how far it runs, so the shortest
* stretch from `start` that it still objects to is taken as the match — `fuck` out of
* `fuck you`, rather than the whole line. That stretch is then extended to the end of the
* word it sits in, so a match inside a longer word masks the word (`a$$hole` whole, not
* `a$$h` with `ole` left showing) — which is what whole-word matching found it as.
*/
function spanEnd(text: string, start: number): number {
let end = start + 1
for (let k = 1; k <= MAX_SPAN && start + k <= text.length; k++) {
if (containsSwears(text.slice(start, start + k))) {
end = start + k
break
}
}
while (end < text.length && !/\s/.test(text[end] ?? '')) end++
return end
}
/**
* `value` with every swear in it masked, one `replacementChar` per character — the body
* of `POST /api/sanitize/v1`. Text with nothing to object to comes back untouched, which
* is the common case and costs a single regex.
*
* Masking per character rather than replacing the word with a fixed string keeps the
* shape of the message: the client asked for a `ReplacementChar`, and a four-letter word
* is expected to come back as four of them.
*/
export function censorSwears(
value: string,
replacementChar: string = DEFAULT_REPLACEMENT_CHAR
): string {
const text = value.replaceAll(MARKER, '')
if (!containsSwears(text)) return text
// A single character, whatever the client sent — a mask is one character repeated, and
// an empty or absent one falls back rather than deleting the word silently.
const mask = [...replacementChar][0] ?? DEFAULT_REPLACEMENT_CHAR
const marked = filter.censor(text, CensorType.FirstChar)
let censored = ''
let copied = 0
for (let i = 0; i < marked.length; i++) {
if (marked[i] !== MARKER) continue
const end = spanEnd(text, i)
censored += text.slice(copied, i) + mask.repeat(end - i)
copied = end
// Any further marks inside the span just masked are part of it.
i = end - 1
}
return censored + text.slice(copied)
}
+113 -10
View File
@@ -150,11 +150,22 @@ function b64url(input: ArrayBuffer | string): string {
// `roles` mints the `role` claim the auth worker stamps from an account's flags; left
// off, the token carries none, which is what a plain player's looks like to the
// role-gated routes.
async function bearer(sub = '42', roles?: string[]): Promise<Record<string, string>> {
// role-gated routes. `version` mints the `rn.ver` claim auth stamps from the build the
// client posted at login; left off, the token carries none, like one issued before the
// claim existed.
async function bearer(
sub = '42',
roles?: string[],
version?: string
): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600, ...(roles && { role: roles }) })
JSON.stringify({
sub,
exp: now + 3600,
...(roles && { role: roles }),
...(version && { 'rn.ver': version }),
})
)}`
const key = await crypto.subtle.importKey(
'raw',
@@ -224,6 +235,44 @@ describe('public endpoints', () => {
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
})
// Two catalogs, picked off the token's `rn.ver`: a build newer than GAME_VERSION gets
// the 2025 one. `Avatars.AdvancedFaceCustomizationEnabled` is a key only that catalog
// has, so its presence identifies which body was served.
const gameConfigKeys = async (version?: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/gameconfigs/v1/all`, {
headers: version === undefined ? {} : await bearer('42', undefined, version),
})
expect(res.status).toBe(200)
return new Set(((await res.json()) as Array<{ Key: string }>).map((e) => e.Key))
}
const KEY_2025_ONLY = 'Avatars.AdvancedFaceCustomizationEnabled'
test('GET /api/gameconfigs/v1/all serves the 2025 catalog to a newer build', async () => {
for (const version of ['20250718.01', '20250424.01', '20231207']) {
expect(await gameConfigKeys(version), version).toContain(KEY_2025_ONLY)
}
})
test('GET /api/gameconfigs/v1/all serves the 2023 catalog to the target build', async () => {
expect(await gameConfigKeys(GAME_VERSION)).not.toContain(KEY_2025_ONLY)
})
test('GET /api/gameconfigs/v1/all serves the 2023 catalog to an older build', async () => {
expect(await gameConfigKeys('20220101')).not.toContain(KEY_2025_ONLY)
})
test('GET /api/gameconfigs/v1/all falls back to the 2023 catalog without a token', async () => {
// No token, and a token with no `rn.ver`: neither is evidence of a newer client, so
// both get the body this route has always served.
expect(await gameConfigKeys()).not.toContain(KEY_2025_ONLY)
const res = await exports.default.fetch(`${ORIGIN}/api/gameconfigs/v1/all`, {
headers: await bearer('42'),
})
expect(((await res.json()) as Array<{ Key: string }>).map((e) => e.Key)).not.toContain(
KEY_2025_ONLY
)
})
test('GET /api/versioncheck/islandedversions is empty', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/islandedversions`)
expect(res.status).toBe(200)
@@ -1700,14 +1749,68 @@ describe('public endpoints', () => {
expect(await ids(featuredPage)).toEqual([202])
})
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' },
body: JSON.stringify({ Value: 'hello world' }),
describe('POST /api/sanitize/v1', () => {
const sanitize = async (body: Record<string, unknown>) =>
exports.default.fetch(`${ORIGIN}/api/sanitize/v1`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
test('masks a swear one character at a time, keeping the rest of the text', async () => {
// The body verbatim from the client, unread fields included — `ruleset` is
// lowercase among PascalCase keys, which is what the reader has to tolerate.
const res = await sanitize({
Value: 'fuck',
ReplacementChar: '*',
Context: 'RoomChat',
Intent: 1,
ruleset: 0,
PreRemoveBlockedCharacters: false,
})
expect(res.status).toBe(200)
expect(await res.json()).toBe('****')
// The shape of the message survives: only the swear is masked, and it comes back
// the length it went in.
expect(await (await sanitize({ Value: 'what the fuck man' })).json()).toBe(
'what the **** man'
)
})
test('leaves clean text alone', async () => {
expect(await (await sanitize({ Value: 'Grape Escape' })).json()).toBe('Grape Escape')
expect(await (await sanitize({ Value: '' })).json()).toBe('')
expect(await (await sanitize({})).json()).toBe('')
})
test('honours ReplacementChar, defaulting to *', async () => {
expect(await (await sanitize({ Value: 'fuck', ReplacementChar: '#' })).json()).toBe('####')
// No ReplacementChar, and an empty one, both fall back rather than deleting the word.
expect(await (await sanitize({ Value: 'fuck' })).json()).toBe('****')
expect(await (await sanitize({ Value: 'fuck', ReplacementChar: '' })).json()).toBe('****')
})
test('masks the whole word a swear is part of', async () => {
expect(await (await sanitize({ Value: 'a$$hole' })).json()).toBe('*******')
expect(await (await sanitize({ Value: 'this is fucking cool' })).json()).toBe(
'this is ******* cool'
)
})
test('masks a swear spaced out letter by letter', async () => {
expect(await (await sanitize({ Value: 'f u c k off' })).json()).toBe('******* off')
})
test('PreRemoveBlockedCharacters strips the characters used to break a word up', async () => {
// A zero-width space inside the word: left alone it is text the filter reads as
// two harmless halves, so the client asks for it to go first.
const value = 'fu\u200Bck'
expect(await (await sanitize({ Value: value })).json()).toBe(value)
expect(
await (await sanitize({ Value: value, PreRemoveBlockedCharacters: true })).json()
).toBe('****')
})
expect(san.status).toBe(200)
expect(await san.json()).toBe('hello world')
})
describe('POST /api/sanitize/v1/isPure', () => {
File diff suppressed because one or more lines are too long