diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts index f0870df..84e32e9 100644 --- a/apps/server/src/env.ts +++ b/apps/server/src/env.ts @@ -64,4 +64,13 @@ export const env = { // When true, only players holding the 'tester' privilege may create lobbies or // queue for matches. Everyone else is rejected. Off by default. TESTING_MODE: optionalBool('TESTING_MODE', false), + + // Moderation service bridge. Unset URL = legacy local pipeline (obscenity); + // set it to route free-form chat through the external moderation service. + // Outage policy: 'off' (chat 503s) or 'presets' (allowlist-only) when the + // service is unreachable — free-form never proceeds unmoderated. + MODERATION_SERVICE_URL: optional('MODERATION_SERVICE_URL', ''), + MODERATION_BEARER_TOKEN: optional('MODERATION_BEARER_TOKEN', ''), + MODERATION_TIMEOUT_MS: Number(optional('MODERATION_TIMEOUT_MS', '1500')), + MODERATION_OUTAGE_POLICY: optional('MODERATION_OUTAGE_POLICY', 'off'), } as const diff --git a/apps/server/src/features/chat/chat.service.ts b/apps/server/src/features/chat/chat.service.ts index ae214d1..50e61e2 100644 --- a/apps/server/src/features/chat/chat.service.ts +++ b/apps/server/src/features/chat/chat.service.ts @@ -1,36 +1,87 @@ +import { insertReportedLobbyMessage } from '../../infrastructure/gateways/chat.gateway.js' +import { + type ModerationBridge, + moderateRemote, + moderationBridge, +} from '../../infrastructure/gateways/moderation.gateway.js' +import { mqttService } from '../../infrastructure/mqtt/mqtt.service.js' import { getConfig } from '../../state/config.js' import type { Lobby } from '../../state/lobby.js' -import { mqttService } from '../../infrastructure/mqtt/mqtt.service.js' -import { insertReportedLobbyMessage } from '../../infrastructure/gateways/chat.gateway.js' +import { type ChatResult, decideDelivery } from './moderation-policy.js' import { normalizeForAllowlist } from './normalization.js' import { moderateMessage } from './obscenity.js' +export type { ChatResult } from './moderation-policy.js' + function isAllowlisted(message: string): boolean { const key = normalizeForAllowlist(message) if (key === null) return false return getConfig().chatAllowlist.has(key) } +// Outage diagnostics without per-message log spam: one line per cause per +// window is enough to tell an expired token (http_401) from a dead service. +let lastOutageLog = { cause: '', at: 0 } +function logOutage(cause: string): void { + const now = Date.now() + if (cause === lastOutageLog.cause && now - lastOutageLog.at < 30_000) return + lastOutageLog = { cause, at: now } + console.error( + `[chat] moderation service unreachable (${cause}) — failing closed`, + ) +} + export async function processAndPublishMessage( lobby: Lobby, playerId: string, displayName: string, message: string, -): Promise<{ ok: boolean; reason?: string }> { + // Injectable for tests; production uses the env-configured bridge (parsed + // once at boot). null = legacy local pipeline. + bridge: ModerationBridge | null = moderationBridge(), +): Promise { const normalized = normalizeForAllowlist(message) if (normalized === null) { return { ok: false, reason: 'empty' } } - if (!isAllowlisted(message)) { + // What gets published to other players. The moderation service may return a + // rewritten form; the original `message` is still what lands in the + // reported-lobby evidence buffer below. + let publishText = message + + if (bridge) { + const outcome = await moderateRemote( + { playerId, displayName, lobbyCode: lobby.code, message }, + bridge, + ) + if (outcome.status === 'unreachable') logOutage(outcome.cause) + + const delivery = decideDelivery( + outcome, + message, + isAllowlisted(message), + bridge.outagePolicy, + ) + if (delivery.action === 'reject') return delivery.rejection + publishText = delivery.text + } else if (!isAllowlisted(message)) { + // Legacy local pipeline (no moderation service configured). const result = await moderateMessage(message, playerId) if (!result.allowed) { return { ok: false, reason: 'moderated' } } } - await mqttService.publishChatMessage(lobby.code, playerId, displayName, message) + await mqttService.publishChatMessage( + lobby.code, + playerId, + displayName, + publishText, + ) + // Evidence trail keeps what the player actually typed — a rewrite must + // never launder the record a moderator later reviews. const sentAt = new Date() lobby.bufferMessage({ playerId, displayName, message, sentAt }) @@ -45,5 +96,8 @@ export async function processAndPublishMessage( }) } - return { ok: true } + return { + ok: true, + ...(publishText !== message ? { publishText } : {}), + } } diff --git a/apps/server/src/features/chat/moderation-policy.ts b/apps/server/src/features/chat/moderation-policy.ts new file mode 100644 index 0000000..23521bf --- /dev/null +++ b/apps/server/src/features/chat/moderation-policy.ts @@ -0,0 +1,89 @@ +import { + BAND_MUTED, + BAND_RATE_LIMITED, + type ModerationResult, + type OutagePolicy, +} from '../../infrastructure/gateways/moderation.gateway.js' + +// Pure mapping from a moderation-service outcome to a chat delivery decision. +// All the branching lives here, on plain data, so the whole verdict/outage +// matrix is testable with literals — chat.service.ts stays gather -> decide -> +// publish (mirroring the normalization.ts / obscenity.ts split). + +export type ChatRejection = + | { ok: false; reason: 'empty' | 'moderated' | 'moderation_unavailable' } + | { ok: false; reason: 'muted'; mutedUntil?: string } + | { ok: false; reason: 'rate_limited'; retryAfterMs?: number } + +export type ChatResult = + | { + ok: true + // Set only when moderation rewrote the message: the text other players + // actually received, so the sender's client can show what was delivered. + publishText?: string + } + | ChatRejection + +export type Delivery = + | { action: 'publish'; text: string } + | { action: 'reject'; rejection: ChatRejection } + +export function decideDelivery( + outcome: ModerationResult, + message: string, + allowlisted: boolean, + outagePolicy: OutagePolicy, +): Delivery { + if (outcome.status === 'unreachable') { + // Fail closed. The 'presets' outage policy still delivers curated + // allowlist messages — safe by construction, moderation not needed. + if (outagePolicy === 'presets' && allowlisted) { + return { action: 'publish', text: message } + } + return { + action: 'reject', + rejection: { ok: false, reason: 'moderation_unavailable' }, + } + } + + if (outcome.status === 'shed') { + // Service load-shedding and per-player rate limiting are one contract + // to the client: back off and retry. + return { + action: 'reject', + rejection: { + ok: false, + reason: 'rate_limited', + retryAfterMs: outcome.retryAfterMs, + }, + } + } + + const v = outcome.verdict + if (v.verdict === 'reject') { + // Only these two wire bands get special treatment; every other rejecting + // band — including ones future service versions invent — is a generic + // block. See the wire-contract note in moderation.gateway.ts. + if (v.band === BAND_MUTED) { + return { + action: 'reject', + rejection: { ok: false, reason: 'muted', mutedUntil: v.mutedUntil }, + } + } + if (v.band === BAND_RATE_LIMITED) { + return { + action: 'reject', + rejection: { + ok: false, + reason: 'rate_limited', + retryAfterMs: v.retryAfterMs, + }, + } + } + return { action: 'reject', rejection: { ok: false, reason: 'moderated' } } + } + + // Allowed. An empty rewrite is treated as no rewrite — never deliver a + // blanked-out message. + return { action: 'publish', text: v.publishText || message } +} diff --git a/apps/server/src/features/chat/normalization.ts b/apps/server/src/features/chat/normalization.ts index 1efe5af..105af95 100644 --- a/apps/server/src/features/chat/normalization.ts +++ b/apps/server/src/features/chat/normalization.ts @@ -1,5 +1,6 @@ -// The original message is always what gets published and logged. -// Normalization is only for allowlist key lookup. +// Normalization is only for allowlist key lookup. The published text is the +// player's original unless the moderation service supplies a rewrite; the +// reported-lobby evidence buffer always keeps the original. export function normalizeForAllowlist(message: string): string | null { const trimmed = message.trim() if (trimmed === '') return null diff --git a/apps/server/src/features/lobby/lobby.route.ts b/apps/server/src/features/lobby/lobby.route.ts index 294e92f..97cf3c5 100644 --- a/apps/server/src/features/lobby/lobby.route.ts +++ b/apps/server/src/features/lobby/lobby.route.ts @@ -151,7 +151,8 @@ export function createLobbyRouter(service: LobbyService): Router { const lobby = getLobby(code) if (!lobby) throw new AppError('Lobby not found', 404) - if (!lobby.hasPlayer(session.playerId)) throw new AppError('Not a member of this lobby', 403) + if (!lobby.hasPlayer(session.playerId)) + throw new AppError('Not a member of this lobby', 403) const { message } = req.body if (!message || typeof message !== 'string') { @@ -162,15 +163,42 @@ export function createLobbyRouter(service: LobbyService): Router { } const displayName = session.getDisplayName() - const result = await processAndPublishMessage(lobby, session.playerId, displayName, message) + const result = await processAndPublishMessage( + lobby, + session.playerId, + displayName, + message, + ) if (!result.ok) { - if (result.reason === 'empty') throw new AppError('Message cannot be empty', 400) - if (result.reason === 'moderated') throw new AppError('Message was rejected by moderation', 403) - throw new AppError('Failed to send message', 500) + switch (result.reason) { + case 'empty': + throw new AppError('Message cannot be empty', 400) + case 'moderated': + throw new AppError('Message was rejected by moderation', 403) + case 'muted': + throw new AppError( + result.mutedUntil + ? `You are muted until ${result.mutedUntil}` + : 'You are muted', + 403, + ) + case 'rate_limited': + // Headers set before next(err) survive: errorHandler writes + // status/body onto this same res (pinned by the route test). + res.setHeader( + 'Retry-After', + Math.ceil((result.retryAfterMs ?? 1000) / 1000), + ) + throw new AppError('Slow down and try again shortly', 429) + case 'moderation_unavailable': + throw new AppError('Chat is temporarily unavailable', 503) + } } - res.json({ ok: true }) + // publishText is present only when moderation rewrote the message — the + // sender's client uses it to show what other players actually received. + res.json({ ok: true, publishText: result.publishText }) } catch (err) { next(err) } @@ -184,7 +212,8 @@ export function createLobbyRouter(service: LobbyService): Router { const lobby = getLobby(code) if (!lobby) throw new AppError('Lobby not found', 404) - if (!lobby.hasPlayer(session.playerId)) throw new AppError('Not a member of this lobby', 403) + if (!lobby.hasPlayer(session.playerId)) + throw new AppError('Not a member of this lobby', 403) const { reportedPlayerId, type, message } = req.body @@ -194,11 +223,20 @@ export function createLobbyRouter(service: LobbyService): Router { if (!type || typeof type !== 'string' || type.length > 64) { throw new AppError('Missing or invalid type (max 64 characters)', 400) } - if (message !== undefined && (typeof message !== 'string' || message.length > 500)) { + if ( + message !== undefined && + (typeof message !== 'string' || message.length > 500) + ) { throw new AppError('Invalid message (max 500 characters)', 400) } - await submitReport(lobby, session.playerId, reportedPlayerId, type, message) + await submitReport( + lobby, + session.playerId, + reportedPlayerId, + type, + message, + ) res.json({ ok: true }) } catch (err) { diff --git a/apps/server/src/infrastructure/gateways/moderation.gateway.ts b/apps/server/src/infrastructure/gateways/moderation.gateway.ts new file mode 100644 index 0000000..108da2b --- /dev/null +++ b/apps/server/src/infrastructure/gateways/moderation.gateway.ts @@ -0,0 +1,160 @@ +import { env } from '../../env.js' + +// HTTP transport for the external moderation service. Transport only — the +// business decision (verdict -> chat outcome) lives in +// features/chat/moderation-policy.ts. Any transport/protocol failure is +// reported as `unreachable` and the caller applies the outage policy: free-form +// chat never proceeds unmoderated. + +// --------------------------------------------------------------------------- +// Wire contract (single source in this repo — mirror of the service's +// `Band` type in its pipeline/types.ts). +// +// `band` names which pipeline tier produced the verdict. The service's full +// vocabulary today: clean | preset | muted | rate_limited | threat_block | +// blocklist | safety_block | guard_block | guard_unavailable | review. +// The relay only gives special treatment to the two below; every other +// rejecting band is a generic "moderated" block, and unknown future bands +// degrade the same way by design (new tiers must not break old relays). +// --------------------------------------------------------------------------- + +export const BAND_MUTED = 'muted' +export const BAND_RATE_LIMITED = 'rate_limited' + +export type ModerationVerdict = { + verdict: 'allow' | 'reject' + band?: string + retryAfterMs?: number + mutedUntil?: string + // Present when the service rewrote the message. On allow, the relay + // publishes THIS instead of the original so the raw form never reaches + // other players. + publishText?: string +} + +// Why a transport call failed — for operator logs, never for control flow. +// Known values: 'bad_json' | 'bad_verdict' | 'fetch_error' | `http_${status}`, +// plus runtime error names (TimeoutError, TypeError, ...) — so honestly a string. +export type UnreachableCause = string + +export type ModerationResult = + | { status: 'verdict'; verdict: ModerationVerdict } + | { status: 'shed'; retryAfterMs: number } + | { status: 'unreachable'; cause: UnreachableCause } + +export type OutagePolicy = 'off' | 'presets' + +export type ModerationBridge = { + url: string + bearerToken?: string + timeoutMs: number + outagePolicy: OutagePolicy + fetchFn?: typeof fetch +} + +// Wire-body validation: the service is trusted infrastructure, but a version +// skew or proxy error page must not flow into MQTT as a "verdict". +function parseVerdict(body: unknown): ModerationVerdict | null { + if (typeof body !== 'object' || body === null) return null + const b = body as Record + if (b.verdict !== 'allow' && b.verdict !== 'reject') return null + if (b.band !== undefined && typeof b.band !== 'string') return null + if (b.retryAfterMs !== undefined && typeof b.retryAfterMs !== 'number') + return null + if (b.mutedUntil !== undefined && typeof b.mutedUntil !== 'string') + return null + if (b.publishText !== undefined && typeof b.publishText !== 'string') + return null + return b as ModerationVerdict +} + +function bridgeFromEnv(): ModerationBridge | null { + if (!env.MODERATION_SERVICE_URL) return null + // Guard the timeout: Number('') is 0 and Number('abc') is NaN — either one + // would abort every request and read as a permanent outage. + const timeoutMs = + Number.isFinite(env.MODERATION_TIMEOUT_MS) && env.MODERATION_TIMEOUT_MS > 0 + ? env.MODERATION_TIMEOUT_MS + : 1500 + const policy = env.MODERATION_OUTAGE_POLICY + if (policy !== 'off' && policy !== 'presets') { + console.error( + `[moderation] unrecognized MODERATION_OUTAGE_POLICY "${policy}" — using 'off' (fail closed)`, + ) + } + return { + // Trailing slash would build "//moderate", which routers don't match. + url: env.MODERATION_SERVICE_URL.replace(/\/+$/, ''), + ...(env.MODERATION_BEARER_TOKEN + ? { bearerToken: env.MODERATION_BEARER_TOKEN } + : {}), + timeoutMs, + outagePolicy: policy === 'presets' ? 'presets' : 'off', + } +} + +// Parsed once at boot; the default seam for processAndPublishMessage. +const DEFAULT_BRIDGE = bridgeFromEnv() + +export function moderationBridge(): ModerationBridge | null { + return DEFAULT_BRIDGE +} + +export async function moderateRemote( + payload: { + playerId: string + displayName: string + lobbyCode: string + message: string + }, + bridge: ModerationBridge, +): Promise { + const fetchFn = bridge.fetchFn ?? fetch + + let response: Response + try { + response = await fetchFn(`${bridge.url}/moderate`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(bridge.bearerToken + ? { authorization: `Bearer ${bridge.bearerToken}` } + : {}), + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(bridge.timeoutMs), + }) + } catch (err) { + return { + status: 'unreachable', + cause: err instanceof Error ? err.name : 'fetch_error', + } + } + + if (response.status === 429) { + let retryAfterMs = 1000 + try { + const body = (await response.json()) as { retryAfterMs?: unknown } + if (typeof body.retryAfterMs === 'number') + retryAfterMs = body.retryAfterMs + } catch { + // keep default + } + return { status: 'shed', retryAfterMs } + } + + if (!response.ok) { + // 503 model-not-loaded, 401 misconfig, 5xx — all fail closed. + return { status: 'unreachable', cause: `http_${response.status}` } + } + + let body: unknown + try { + body = await response.json() + } catch { + return { status: 'unreachable', cause: 'bad_json' } + } + const verdict = parseVerdict(body) + if (!verdict) return { status: 'unreachable', cause: 'bad_verdict' } + return { status: 'verdict', verdict } +} diff --git a/apps/server/src/tests/routes/chat.test.ts b/apps/server/src/tests/routes/chat.test.ts new file mode 100644 index 0000000..473728d --- /dev/null +++ b/apps/server/src/tests/routes/chat.test.ts @@ -0,0 +1,162 @@ +import request from 'supertest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { signJwt } from '../../features/auth/jwt.js' +import { setConfig } from '../../state/config.js' +import { createSession } from '../../state/index.js' +import { createTestApp } from './app.js' + +// The moderation bridge is parsed from env once at gateway module load, so the +// URL must exist before any import pulls the module graph in. The bridge has +// no fetchFn, so it falls through to global fetch — stubbed per test below. +vi.hoisted(() => { + process.env.MODERATION_SERVICE_URL = 'http://moderation.test' + process.env.MODERATION_BEARER_TOKEN = 'route-test-token' +}) + +const app = createTestApp() + +function stubModeration(status: number, body: unknown) { + vi.stubGlobal( + 'fetch', + (async () => + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + })) as unknown as typeof fetch, + ) +} + +function chatConfig() { + setConfig({ + tosVersion: 0, + mods: [], + chatAllowlist: new Set(), + chatEnabled: true, + }) +} + +function authHeader(playerId: string, steamName: string) { + createSession(steamName, { id: playerId, chatEnabled: true }) + const token = signJwt({ playerId, steamName }) + return `Bearer ${token}` +} + +async function createLobby(hostId: string, hostName: string) { + const res = await request(app) + .post('/api/lobbies') + .set('Authorization', authHeader(hostId, hostName)) + .send({ modId: 'mod1' }) + return res.body.lobby.code as string +} + +async function sendChat(code: string, auth: string, message = 'hello') { + return request(app) + .post(`/api/lobbies/${code}/chat`) + .set('Authorization', auth) + .send({ message }) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('POST /api/lobbies/:code/chat — moderation bridge mapping', () => { + it('returns 200 without publishText when the service allows unmodified', async () => { + chatConfig() + const auth = authHeader('host1', 'Alice') + const code = await createLobby('host1', 'Alice') + stubModeration(200, { verdict: 'allow', band: 'clean' }) + + const res = await sendChat(code, auth) + expect(res.status).toBe(200) + expect(res.body).toEqual({ ok: true }) + }) + + it('returns publishText in the body only when the service rewrote the message', async () => { + chatConfig() + const auth = authHeader('host1', 'Alice') + const code = await createLobby('host1', 'Alice') + stubModeration(200, { + verdict: 'allow', + band: 'clean', + publishText: 'suck my cocktail', + }) + + const res = await sendChat(code, auth, 'suck my cock') + expect(res.status).toBe(200) + expect(res.body).toEqual({ ok: true, publishText: 'suck my cocktail' }) + }) + + it('maps a moderated reject to 403', async () => { + chatConfig() + const auth = authHeader('host1', 'Alice') + const code = await createLobby('host1', 'Alice') + stubModeration(200, { verdict: 'reject', band: 'blocklist' }) + + const res = await sendChat(code, auth) + expect(res.status).toBe(403) + expect(res.body.error).toMatch(/rejected by moderation/) + }) + + it('maps a muted reject to 403 with the until-timestamp in the message', async () => { + chatConfig() + const auth = authHeader('host1', 'Alice') + const code = await createLobby('host1', 'Alice') + stubModeration(200, { + verdict: 'reject', + band: 'muted', + mutedUntil: '2026-08-01T00:00:00Z', + }) + + const res = await sendChat(code, auth) + expect(res.status).toBe(403) + expect(res.body.error).toBe('You are muted until 2026-08-01T00:00:00Z') + }) + + it('maps rate limiting to 429 and the Retry-After header survives the error middleware', async () => { + chatConfig() + const auth = authHeader('host1', 'Alice') + const code = await createLobby('host1', 'Alice') + stubModeration(200, { + verdict: 'reject', + band: 'rate_limited', + retryAfterMs: 2500, + }) + + const res = await sendChat(code, auth) + expect(res.status).toBe(429) + expect(res.headers['retry-after']).toBe('3') + }) + + it('maps service load-shedding (429) the same way', async () => { + chatConfig() + const auth = authHeader('host1', 'Alice') + const code = await createLobby('host1', 'Alice') + stubModeration(429, { retryAfterMs: 1000 }) + + const res = await sendChat(code, auth) + expect(res.status).toBe(429) + expect(res.headers['retry-after']).toBe('1') + }) + + it('fails closed to 503 when the service is unreachable', async () => { + chatConfig() + const auth = authHeader('host1', 'Alice') + const code = await createLobby('host1', 'Alice') + stubModeration(503, {}) + + const res = await sendChat(code, auth) + expect(res.status).toBe(503) + expect(res.body.error).toBe('Chat is temporarily unavailable') + }) + + it('still rejects empty messages before the bridge is consulted', async () => { + chatConfig() + const auth = authHeader('host1', 'Alice') + const code = await createLobby('host1', 'Alice') + stubModeration(200, { verdict: 'allow' }) + + const res = await sendChat(code, auth, ' ') + expect(res.status).toBe(400) + }) +}) diff --git a/apps/server/src/tests/services/moderation-bridge.test.ts b/apps/server/src/tests/services/moderation-bridge.test.ts new file mode 100644 index 0000000..fab0b90 --- /dev/null +++ b/apps/server/src/tests/services/moderation-bridge.test.ts @@ -0,0 +1,454 @@ +import { describe, expect, it, vi } from 'vitest' +import { processAndPublishMessage } from '../../features/chat/chat.service.js' +import { decideDelivery } from '../../features/chat/moderation-policy.js' +import { + type ModerationBridge, + type ModerationResult, + moderateRemote, +} from '../../infrastructure/gateways/moderation.gateway.js' +import { mqttService } from '../../infrastructure/mqtt/mqtt.service.js' +import { setConfig } from '../../state/config.js' +import { Lobby, createSession, lobbies } from '../../state/index.js' + +function fetchStub(status: number, body: unknown): typeof fetch { + return (async (_url: string | URL, init?: RequestInit) => { + ;(fetchStub as { lastInit?: RequestInit }).lastInit = init + return new Response( + typeof body === 'string' ? body : JSON.stringify(body), + { status, headers: { 'content-type': 'application/json' } }, + ) + }) as unknown as typeof fetch +} + +function bridge( + fetchFn: typeof fetch, + over?: Partial, +): ModerationBridge { + return { + url: 'http://moderation.test', + bearerToken: 't', + timeoutMs: 1500, + outagePolicy: 'off', + fetchFn, + ...over, + } +} + +const PAYLOAD = { + playerId: 'p1', + displayName: 'Tester', + lobbyCode: 'ABCD', + message: 'hello', +} + +describe('decideDelivery — the pure verdict/outage matrix', () => { + const allow = (publishText?: string): ModerationResult => ({ + status: 'verdict', + verdict: { verdict: 'allow', ...(publishText ? { publishText } : {}) }, + }) + + it.each([ + [ + 'allow, no rewrite', + allow(), + 'hi', + false, + 'off', + { action: 'publish', text: 'hi' }, + ], + [ + 'allow, rewrite', + allow('hi!'), + 'hi', + false, + 'off', + { action: 'publish', text: 'hi!' }, + ], + [ + 'allow, empty rewrite falls back to original', + { + status: 'verdict', + verdict: { verdict: 'allow', publishText: '' }, + }, + 'hi', + false, + 'off', + { action: 'publish', text: 'hi' }, + ], + [ + 'reject generic band', + { status: 'verdict', verdict: { verdict: 'reject', band: 'blocklist' } }, + 'hi', + false, + 'off', + { action: 'reject', rejection: { ok: false, reason: 'moderated' } }, + ], + [ + 'reject muted', + { + status: 'verdict', + verdict: { verdict: 'reject', band: 'muted', mutedUntil: 'T' }, + }, + 'hi', + false, + 'off', + { + action: 'reject', + rejection: { ok: false, reason: 'muted', mutedUntil: 'T' }, + }, + ], + [ + 'reject rate_limited band', + { + status: 'verdict', + verdict: { verdict: 'reject', band: 'rate_limited', retryAfterMs: 5 }, + }, + 'hi', + false, + 'off', + { + action: 'reject', + rejection: { ok: false, reason: 'rate_limited', retryAfterMs: 5 }, + }, + ], + [ + 'shed maps to rate_limited', + { status: 'shed', retryAfterMs: 9 }, + 'hi', + false, + 'off', + { + action: 'reject', + rejection: { ok: false, reason: 'rate_limited', retryAfterMs: 9 }, + }, + ], + [ + 'unreachable, policy off', + { status: 'unreachable', cause: 'x' }, + 'hi', + true, + 'off', + { + action: 'reject', + rejection: { ok: false, reason: 'moderation_unavailable' }, + }, + ], + [ + 'unreachable, presets + allowlisted', + { status: 'unreachable', cause: 'x' }, + 'gg', + true, + 'presets', + { action: 'publish', text: 'gg' }, + ], + [ + 'unreachable, presets + free text', + { status: 'unreachable', cause: 'x' }, + 'hi', + false, + 'presets', + { + action: 'reject', + rejection: { ok: false, reason: 'moderation_unavailable' }, + }, + ], + ] as const)( + '%s', + (_name, outcome, message, allowlisted, policy, expected) => { + expect( + decideDelivery( + outcome as ModerationResult, + message, + allowlisted, + policy, + ), + ).toEqual(expected) + }, + ) +}) + +describe('moderateRemote', () => { + it('passes through an allow verdict and sends bearer + abort signal', async () => { + const f = fetchStub(200, { verdict: 'allow', band: 'clean' }) + const r = await moderateRemote(PAYLOAD, bridge(f)) + expect(r).toMatchObject({ + status: 'verdict', + verdict: { verdict: 'allow' }, + }) + const init = (fetchStub as { lastInit?: RequestInit }).lastInit + expect(init?.signal).toBeInstanceOf(AbortSignal) + expect((init?.headers as Record).authorization).toBe( + 'Bearer t', + ) + }) + + it('omits the Authorization header when no bearer is configured', async () => { + const f = fetchStub(200, { verdict: 'allow' }) + const b = bridge(f) + b.bearerToken = undefined + await moderateRemote(PAYLOAD, b) + const init = (fetchStub as { lastInit?: RequestInit }).lastInit + expect(init?.headers as Record).not.toHaveProperty( + 'authorization', + ) + }) + + it('maps 429 to shed with retryAfterMs', async () => { + const r = await moderateRemote( + PAYLOAD, + bridge(fetchStub(429, { retryAfterMs: 2000 })), + ) + expect(r).toEqual({ status: 'shed', retryAfterMs: 2000 }) + }) + + it('maps 5xx/503/401 to unreachable (fail closed)', async () => { + for (const status of [500, 503, 401]) { + const r = await moderateRemote(PAYLOAD, bridge(fetchStub(status, {}))) + expect(r).toEqual({ status: 'unreachable', cause: `http_${status}` }) + } + }) + + it('maps network errors and timeouts to unreachable', async () => { + const rejecting: typeof fetch = async () => { + const err = new Error('timed out') + err.name = 'TimeoutError' + throw err + } + const r = await moderateRemote(PAYLOAD, bridge(rejecting)) + expect(r).toEqual({ status: 'unreachable', cause: 'TimeoutError' }) + }) + + it('rejects non-JSON bodies as bad_json', async () => { + const r = await moderateRemote( + PAYLOAD, + bridge(fetchStub(200, 'proxy error')), + ) + expect(r).toEqual({ status: 'unreachable', cause: 'bad_json' }) + }) + + it('rejects malformed verdict shapes as bad_verdict', async () => { + for (const body of [ + { nope: 1 }, + { verdict: 'maybe' }, + { verdict: 'allow', publishText: 5 }, + { verdict: 'reject', retryAfterMs: 'soon' }, + ]) { + const r = await moderateRemote(PAYLOAD, bridge(fetchStub(200, body))) + expect(r).toEqual({ status: 'unreachable', cause: 'bad_verdict' }) + } + }) +}) + +describe('moderationBridge — env parsing (single source: env.ts)', () => { + async function freshBridge(vars: Record) { + const prev: Record = {} + for (const [k, v] of Object.entries(vars)) { + prev[k] = process.env[k] + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + vi.resetModules() + const mod = await import( + '../../infrastructure/gateways/moderation.gateway.js' + ) + for (const [k, v] of Object.entries(prev)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + return mod.moderationBridge() + } + + it('returns null when MODERATION_SERVICE_URL is unset — chat is unchanged', async () => { + expect(await freshBridge({ MODERATION_SERVICE_URL: undefined })).toBeNull() + }) + + it('strips trailing slashes so the endpoint never becomes //moderate', async () => { + const b = await freshBridge({ + MODERATION_SERVICE_URL: 'http://mod.test///', + }) + expect(b?.url).toBe('http://mod.test') + }) + + it('falls back to 1500ms when the timeout is empty or garbage', async () => { + for (const bad of ['', 'abc', '0', '-5']) { + const b = await freshBridge({ + MODERATION_SERVICE_URL: 'http://mod.test', + MODERATION_TIMEOUT_MS: bad, + }) + expect(b?.timeoutMs).toBe(1500) + } + }) + + it('treats an unrecognized outage policy as off (fail closed)', async () => { + const b = await freshBridge({ + MODERATION_SERVICE_URL: 'http://mod.test', + MODERATION_OUTAGE_POLICY: 'preset', + }) + expect(b?.outagePolicy).toBe('off') + }) +}) + +describe('processAndPublishMessage — moderation service branch', () => { + function setupLobby(code = 'MODB') { + const lobby = new Lobby(code, 'test_mod', 'host1') + lobbies.set(code, lobby) + const session = createSession('Host', { + id: 'host1', + tosAcceptedVersion: 1, + chatEnabled: true, + }) + lobby.addPlayer(session) + return lobby + } + + it('publishes when the service allows', async () => { + const lobby = setupLobby() + const r = await processAndPublishMessage( + lobby, + 'host1', + 'Host', + 'hello there', + bridge(fetchStub(200, { verdict: 'allow', band: 'clean' })), + ) + expect(r).toEqual({ ok: true }) + }) + + it('publishes the rewrite to MQTT but keeps the ORIGINAL in the evidence buffer', async () => { + const lobby = setupLobby('MODG') + vi.mocked(mqttService.publishChatMessage).mockClear() + const r = await processAndPublishMessage( + lobby, + 'host1', + 'Host', + 'suck my cock', + bridge( + fetchStub(200, { + verdict: 'allow', + band: 'clean', + publishText: 'suck my cocktail', + }), + ), + ) + // Other players receive the rewrite... + expect(mqttService.publishChatMessage).toHaveBeenCalledWith( + 'MODG', + 'host1', + 'Host', + 'suck my cocktail', + ) + // ...the sender is told what was delivered... + expect(r).toEqual({ ok: true, publishText: 'suck my cocktail' }) + // ...and the moderation evidence trail keeps what was actually typed. + expect(lobby.messageBuffer.at(-1)?.message).toBe('suck my cock') + }) + + it('publishes the original when the verdict carries no rewrite', async () => { + const lobby = setupLobby('MODH') + vi.mocked(mqttService.publishChatMessage).mockClear() + const r = await processAndPublishMessage( + lobby, + 'host1', + 'Host', + 'hello there', + bridge(fetchStub(200, { verdict: 'allow', band: 'clean' })), + ) + expect(mqttService.publishChatMessage).toHaveBeenCalledWith( + 'MODH', + 'host1', + 'Host', + 'hello there', + ) + expect(r).toEqual({ ok: true }) + }) + + it('maps service rejections to reasons', async () => { + const lobby = setupLobby('MODC') + const r = await processAndPublishMessage( + lobby, + 'host1', + 'Host', + 'bad message', + bridge(fetchStub(200, { verdict: 'reject', band: 'blocklist' })), + ) + expect(r).toEqual({ ok: false, reason: 'moderated' }) + + const muted = await processAndPublishMessage( + lobby, + 'host1', + 'Host', + 'hi', + bridge( + fetchStub(200, { + verdict: 'reject', + band: 'muted', + mutedUntil: '2026-07-04T00:00:00Z', + }), + ), + ) + expect(muted).toEqual({ + ok: false, + reason: 'muted', + mutedUntil: '2026-07-04T00:00:00Z', + }) + }) + + it('maps a 429 shed to rate_limited', async () => { + const lobby = setupLobby('MODS') + const r = await processAndPublishMessage( + lobby, + 'host1', + 'Host', + 'hi', + bridge(fetchStub(429, { retryAfterMs: 2000 })), + ) + expect(r).toEqual({ ok: false, reason: 'rate_limited', retryAfterMs: 2000 }) + }) + + it('fails closed on outage with policy=off', async () => { + const lobby = setupLobby('MODD') + const r = await processAndPublishMessage( + lobby, + 'host1', + 'Host', + 'hello', + bridge(fetchStub(503, {})), + ) + expect(r).toEqual({ ok: false, reason: 'moderation_unavailable' }) + }) + + it('outage with policy=presets allows allowlisted, rejects free text', async () => { + setConfig({ tosVersion: 0, mods: [], chatAllowlist: new Set(['gg']) }) + const lobby = setupLobby('MODE') + const down = bridge(fetchStub(503, {}), { outagePolicy: 'presets' }) + + const preset = await processAndPublishMessage( + lobby, + 'host1', + 'Host', + 'GG!', + down, + ) + expect(preset).toEqual({ ok: true }) + + const free = await processAndPublishMessage( + lobby, + 'host1', + 'Host', + 'nice one', + down, + ) + expect(free).toEqual({ ok: false, reason: 'moderation_unavailable' }) + }) + + it('legacy path unchanged when no bridge is configured', async () => { + const lobby = setupLobby('MODF') + const r = await processAndPublishMessage( + lobby, + 'host1', + 'Host', + 'hello legacy', + null, + ) + expect(r).toEqual({ ok: true }) + }) +})