mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[api] 2025 gameconfig swap, implement sanitize
This commit is contained in:
+22
-2
@@ -553,8 +553,28 @@ export const SuccessValueEnvelope = z.object({ success: z.boolean(), value: z.nu
|
|||||||
|
|
||||||
// ---- Gameplay --------------------------------------------------------------
|
// ---- 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 swear’s 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. */
|
/** `POST /api/sanitize/v1/isPure` — whether the text is free of profanity. */
|
||||||
export const IsPureResponse = z.object({ IsPure: z.boolean() })
|
export const IsPureResponse = z.object({ IsPure: z.boolean() })
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { describeRoute } from 'hono-openapi'
|
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 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 gameConfigsV1All from '../../static/gameconfigs-v1-all.json'
|
||||||
import {
|
import {
|
||||||
AmplitudeConfig,
|
AmplitudeConfig,
|
||||||
@@ -129,15 +131,40 @@ export const configRoutes = new Hono<App>({ strict: false })
|
|||||||
}),
|
}),
|
||||||
(c) => c.json([])
|
(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(
|
.get(
|
||||||
'/api/gameconfigs/v1/all',
|
'/api/gameconfigs/v1/all',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Config'],
|
tags: ['Config'],
|
||||||
summary: 'Per-game configuration',
|
summary: 'Per-game configuration',
|
||||||
description: 'An opaque static catalog of per-game settings, served verbatim.',
|
description:
|
||||||
responses: { 200: json(JsonObject, 'The game config catalog') },
|
'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 token’s ' +
|
||||||
|
'`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 caller’s 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
|
// The property bag the client would attach to its Statsig user. The reference server
|
||||||
|
|||||||
@@ -19,39 +19,82 @@ import {
|
|||||||
stringParam,
|
stringParam,
|
||||||
UNAUTHORIZED_RESPONSE,
|
UNAUTHORIZED_RESPONSE,
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
import { containsSwears } from '../sanitize'
|
import {
|
||||||
|
censorSwears,
|
||||||
|
containsSwears,
|
||||||
|
DEFAULT_REPLACEMENT_CHAR,
|
||||||
|
removeBlockedCharacters,
|
||||||
|
} from '../sanitize'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { App } from '../context'
|
import type { App } from '../context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The text to check, from the JSON body the client posts (`{ "Value": "..." }`). A body
|
* A sanitize request, as the client posts it:
|
||||||
* 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.
|
* ```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> {
|
async function sanitizeRequest(
|
||||||
const body = await c.req.json<{ Value?: unknown }>().catch(() => ({}) as { Value?: unknown })
|
c: Context<App>
|
||||||
return typeof body.Value === 'string' ? body.Value : ''
|
): 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
|
// Text sanitization, keepsakes, objectives/events/rewards, and the misc analytics
|
||||||
// sinks the client hits during load.
|
// sinks the client hits during load.
|
||||||
export const gameplayRoutes = new Hono<App>({ strict: false })
|
export const gameplayRoutes = new Hono<App>({ strict: false })
|
||||||
// Text sanitization (display names, room names, chat). `v1` echoes the input
|
// Text sanitization (display names, room names, chat). `v1` masks the swears in the
|
||||||
// value back; `isPure` reports the text is clean.
|
// text and hands it back; `isPure` answers the same question as a yes/no.
|
||||||
.post(
|
.post(
|
||||||
'/api/sanitize/v1',
|
'/api/sanitize/v1',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Gameplay'],
|
tags: ['Gameplay'],
|
||||||
summary: 'Sanitize a string',
|
summary: 'Sanitize a string',
|
||||||
description:
|
description:
|
||||||
'Runs display names, room names and chat through the profanity filter. There is ' +
|
'Masks any swear in the posted `Value` and returns the cleaned text as a bare JSON ' +
|
||||||
'no filter here — the input `Value` is echoed back verbatim as a bare JSON string ' +
|
'string. Each character of a swear becomes the request’s `ReplacementChar` (`*` when ' +
|
||||||
'(an empty string if the body has no `Value`).',
|
'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 ' +
|
||||||
|
'reference’s filtering policies, and this server has one.',
|
||||||
requestBody: jsonBody(SanitizeRequest, 'The text to clean'),
|
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
|
// 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,
|
// 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) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(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) })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Profanity } from '@2toad/profanity'
|
import { CensorType, Profanity } from '@2toad/profanity'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The profanity filter behind `POST /api/sanitize/v1/isPure`.
|
* The profanity filter behind `POST /api/sanitize/v1/isPure`.
|
||||||
@@ -28,12 +28,24 @@ const EXTRA_WORDS: string[] = ['kys', 'molest']
|
|||||||
*/
|
*/
|
||||||
const ALLOWED_WORDS: string[] = []
|
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
|
* 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
|
* regex, which is the whole reason a check costs microseconds at request time. Module
|
||||||
* scope is where that cost belongs.
|
* 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.addWords(EXTRA_WORDS)
|
||||||
filter.whitelist.addWords(ALLOWED_WORDS)
|
filter.whitelist.addWords(ALLOWED_WORDS)
|
||||||
|
|
||||||
@@ -47,3 +59,80 @@ filter.whitelist.addWords(ALLOWED_WORDS)
|
|||||||
export function containsSwears(value: string): boolean {
|
export function containsSwears(value: string): boolean {
|
||||||
return value !== '' && filter.exists(value)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
// `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
|
// off, the token carries none, which is what a plain player's looks like to the
|
||||||
// role-gated routes.
|
// role-gated routes. `version` mints the `rn.ver` claim auth stamps from the build the
|
||||||
async function bearer(sub = '42', roles?: string[]): Promise<Record<string, string>> {
|
// 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 now = Math.floor(Date.now() / 1000)
|
||||||
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
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(
|
const key = await crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
@@ -224,6 +235,44 @@ describe('public endpoints', () => {
|
|||||||
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
|
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 () => {
|
test('GET /api/versioncheck/islandedversions is empty', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/islandedversions`)
|
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/islandedversions`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -1700,14 +1749,68 @@ describe('public endpoints', () => {
|
|||||||
expect(await ids(featuredPage)).toEqual([202])
|
expect(await ids(featuredPage)).toEqual([202])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /api/sanitize/v1 echoes the value', async () => {
|
describe('POST /api/sanitize/v1', () => {
|
||||||
const san = await exports.default.fetch(`${ORIGIN}/api/sanitize/v1`, {
|
const sanitize = async (body: Record<string, unknown>) =>
|
||||||
method: 'POST',
|
exports.default.fetch(`${ORIGIN}/api/sanitize/v1`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
method: 'POST',
|
||||||
body: JSON.stringify({ Value: 'hello world' }),
|
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', () => {
|
describe('POST /api/sanitize/v1/isPure', () => {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user