Skip to content

feat(chat): optional moderation-service bridge for free-form chat#40

Draft
ChronoFinale wants to merge 1 commit into
Balatro-Multiplayer:mqttfrom
ChronoFinale:feat/chat-moderation
Draft

feat(chat): optional moderation-service bridge for free-form chat#40
ChronoFinale wants to merge 1 commit into
Balatro-Multiplayer:mqttfrom
ChronoFinale:feat/chat-moderation

Conversation

@ChronoFinale

@ChronoFinale ChronoFinale commented Jul 14, 2026

Copy link
Copy Markdown

Design record & discussion: #41 — the goal, the decision history, and the open questions live there.

What

Adds a forward-only bridge from the chat path to an external moderation service. With MODERATION_SERVICE_URL unset (the default), nothing changes — chat runs the existing local pipeline exactly as before. The bridge is dormant until env vars opt in.

The v1 rollout, across repos

Piece Where Status
Relay bridge (this PR) POST /moderate before publish; verdict → HTTP mapping this PR
Client UX — instant echo, "not delivered", "delivered as …" Balatro-Multiplayer/BalatroMultiplayerAPI#12 draft, stacked on Balatro-Multiplayer/BalatroMultiplayerAPI#10
Moderation service — deterministic tiers + small in-process guard model separate repo (private for now — happy to add access), live at api.mod.balatroleague.com, tested end-to-end in-game against this exact bridge deployed

Not-a-PR ops to go live here: set the four env vars below on the relay. Rollout can start in shadow mode service-side (logs would-blocks, blocks nothing) to eyeball calls on real traffic before enforcement.

Structure (follows the repo layering)

  • infrastructure/gateways/moderation.gateway.ts — transport only. Config comes from env.ts, parsed once at boot with validation (timeout guarded against Number('')→0 / NaN, trailing-slash URLs normalized, unknown outage policy warns + fails closed). Responses are validated with a type guard, never blind-cast. Owns the wire contract: the service's band vocabulary is documented here, and the two bands the relay treats specially are named constants (BAND_MUTED, BAND_RATE_LIMITED) — unknown future bands degrade to a generic block by design.
  • features/chat/moderation-policy.ts — the pure decision: verdict/outage → publish (with optional rewrite) or a typed rejection. ChatResult is a discriminated union with literal reasons, so the route's mapping is compiler-checked.
  • chat.service.ts — stays thin: gather → decide → publish.

Behavior

  • allow — publish as-is, or the service's rewritten form (publishText), which is also returned to the sender ("delivered as …"). The reported-lobby evidence buffer keeps the original text — a rewrite never launders the record moderators review.
  • reject403 (moderated / muted with until-timestamp) or 429 with Retry-After (per-player rate limiting and service load-shedding are one client contract)
  • unreachable — fail closed: 503 (cause logged server-side, throttled), or allowlist-only when MODERATION_OUTAGE_POLICY=presets (curated presets are safe by construction, so preset chat survives an outage)

No automatic retries anywhere — the sender gets instant feedback, so the player is the retry loop; auto-retries would amplify load exactly when the service is struggling.

Config

Var Default Meaning
MODERATION_SERVICE_URL unset unset = local pipeline, unchanged
MODERATION_BEARER_TOKEN unset S2S auth
MODERATION_TIMEOUT_MS 1500 per-request deadline, then outage policy applies
MODERATION_OUTAGE_POLICY off off = chat 503s on outage; presets = allowlist-only

Testing

  • Pure decision table for the whole verdict/outage matrix (literals, exact equality)
  • Gateway wire handling: malformed/non-JSON bodies, abort signal actually attached, bearer omitted when unset
  • Env parsing: unset URL ⇒ null bridge (the "nothing changes" guarantee), trailing slash, garbage timeout
  • Service level: rewrite goes to MQTT, original stays in the evidence buffer
  • Supertest route suite pinning the HTTP mappings — incl. Retry-After surviving the error middleware

Full suite: 415/415 green on top of current mqtt.

@ChronoFinale
ChronoFinale force-pushed the feat/chat-moderation branch from bda75bd to ddd39a2 Compare July 14, 2026 23:35
Adds a forward-only bridge to an external moderation service. With
MODERATION_SERVICE_URL unset (the default) nothing changes — chat runs the
existing local pipeline exactly as before.

Layered per the repo structure:
- infrastructure/gateways/moderation.gateway.ts — transport only: config from
  env.ts (parsed once at boot; timeout/URL/policy validated), POST /moderate,
  wire-body type guard. Any transport/protocol failure maps to `unreachable`.
- features/chat/moderation-policy.ts — the pure decision: verdict/outage ->
  publish (with optional rewrite) or a typed rejection. ChatResult is a
  discriminated union with literal reasons, so the route mapping is
  compiler-checked.
- chat.service.ts stays gather -> decide -> publish. The reported-lobby
  evidence buffer keeps the ORIGINAL text — a rewrite goes to MQTT and to the
  sender (publishText) but never launders the record moderators review.

Behavior on the wire:
- allow: publish as-is or the service's rewritten form; publishText returned
  to the sender so their client can show what was delivered
- reject: 403 (moderated / muted with until-timestamp) or 429 with Retry-After
  (rate-limited and service load-shedding are one client contract)
- unreachable: fail closed — 503 (cause logged, throttled), or allowlist-only
  when MODERATION_OUTAGE_POLICY=presets (curated presets are safe by
  construction)

Tests: pure decision table (moderation-policy), gateway wire handling incl.
malformed bodies and abort signal, env parsing (unset URL => null bridge,
trailing slash, garbage timeout), service-level bridge behavior incl.
evidence-buffer originals, and a supertest route suite pinning the HTTP
mappings (Retry-After surviving the error middleware, muted message, 503).

Config: MODERATION_SERVICE_URL, MODERATION_BEARER_TOKEN,
MODERATION_TIMEOUT_MS (default 1500), MODERATION_OUTAGE_POLICY (off|presets).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant