Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/server/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
66 changes: 60 additions & 6 deletions apps/server/src/features/chat/chat.service.ts
Original file line number Diff line number Diff line change
@@ -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<ChatResult> {
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 })

Expand All @@ -45,5 +96,8 @@ export async function processAndPublishMessage(
})
}

return { ok: true }
return {
ok: true,
...(publishText !== message ? { publishText } : {}),
}
}
89 changes: 89 additions & 0 deletions apps/server/src/features/chat/moderation-policy.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
5 changes: 3 additions & 2 deletions apps/server/src/features/chat/normalization.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
56 changes: 47 additions & 9 deletions apps/server/src/features/lobby/lobby.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -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)
}
Expand All @@ -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

Expand All @@ -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) {
Expand Down
Loading