Skip to main content
Version: Latest
UPDATED 2026-09-07 Chat's behaviour is unchanged, but the same filter now covers the whole app — posts, comments, profiles, search and the rest — so build one shared 422 handler instead of a chat-only one. See [mobile-content-moderation-guide.md](../moderation/mobile-content-moderation-guide.md). Two small additions here: the 422 now also carries field (which input to highlight, "content" for chat), and the list + matcher below are no longer chat-specific — reuse them in any composer.
## What's new <!-- Newest first, plain language, max 5 entries. Every session that changes this guide adds one line here and drops the oldest past 5 — see CLAUDE.md rule 8. --> - **2026-09-07** — Chat's behaviour is unchanged, but the same filter now covers the whole app — posts, comments, profiles, search and the rest — so build one shared 422 handler instead of a chat-only one. See [`mobile-content-moderation-guide.md`](../moderation/mobile-content-moderation-guide.md). Two small additions here: the 422 now also carries `field` (which input to highlight, `"content"` for chat), and the list + matcher below are no longer chat-specific — reuse them in any composer. ---
"reason": "PROFANITY", // may gain values (e.g. PII) later "field": "content", // which input tripped — added 2026-09-07, additive}
Every other surface (posts, comments, profile, events, search…):[`mobile-content-moderation-guide.md`](../moderation/mobile-content-moderation-guide.md). Full status + enforcement points: `docs/CHAT_IMPLEMENTATION_V2.md` §11.

Mobile Chat Moderation — Integration Guide

Blocked-word filtering for chat. This is a kids app, so messages containing disallowed language are rejected by the server on send and edit. The client also runs a lighter check in the composer for instant feedback. Companion to mobile-chat-guide.md (send/receive, sockets, receipts).

  • API base URL (local): http://localhost:3001
  • Auth: Authorization: Bearer <accessToken> on every endpoint (required)

What's new

  • 2026-09-07 — Chat's behaviour is unchanged, but the same filter now covers the whole app — posts, comments, profiles, search and the rest — so build one shared 422 handler instead of a chat-only one. See mobile-content-moderation-guide.md. Two small additions here: the 422 now also carries field (which input to highlight, "content" for chat), and the list + matcher below are no longer chat-specific — reuse them in any composer.

How it works in one paragraph

Two lists, one matcher. The server holds the comprehensive, authoritative blocklist and rejects any send/edit that hits it — this is the real boundary. The client ships a small superficial list and checks as the user types, so they see "you can't send that" before the network round trip. The client check is UX only; a determined user can bypass it, so the server always re-checks. Both lists feed the same matcher, which normalizes for evasions (leetspeak, spacing, repeats) before comparing.


Server behavior — the contract that matters

The guard runs on both user-authored content paths. No new endpoints; existing ones just gained a rejection case.

EndpointWhen blocked
POST /api/chat/conversations/:id/messagescontent (or a post-share caption) contains a blocked term
PATCH /api/chat/messages/:messageIdedited content contains a blocked term

Rejection response — HTTP 422 Unprocessable Content (the request is valid; its content violates policy — not a 400 malformed request):

{
"success": false,
"error": "That message wasn't sent. Please use kinder words.", // display-ready
"code": "MESSAGE_BLOCKED", // switch on this, not the status code
"reason": "PROFANITY", // may gain values (e.g. PII) later
"field": "content", // which input tripped — added 2026-09-07, additive
}

Client handling: branch on code === "MESSAGE_BLOCKED". error is a kid-appropriate, display-ready string — show it as-is or substitute your own copy. reason is a coarse category for analytics/UI, not the matched word.

Rules:

  • DM and group are identical — the check is on content, conversation type is irrelevant.
  • Media-only messages pass — empty/whitespace content is always clean, so a GIF or image with no caption is never blocked.
  • The response never echoes the offending word or reveals the list. Don't try to parse which word tripped it.
  • The blocked message is never persisted — no row is written, no socket event fires. Treat the 422 like any other send failure: roll back the optimistic bubble.

Client-side superficial check (composer)

Copy the whole block below into the app verbatim.

// chat-moderation.ts — mirror of @ksn/validators moderation.ts.
// Copied from the mobile chat moderation guide. UX-only: the server is the
// authoritative gate and re-checks every send/edit.

const LEET: Record<string, string> = {
"@": "a",
"4": "a",
"3": "e",
"1": "i",
"!": "i",
"|": "i",
"0": "o",
$: "s",
"5": "s",
"7": "t",
"+": "t",
};

function normalize(text: string): string {
return text
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "") // strip diacritics
.toLowerCase()
.replace(/[@431!|0$57+]/g, (c) => LEET[c] ?? c)
.replace(/(.)\1{2,}/g, "$1"); // baaad -> bad
}

function collapse(text: string): string {
return normalize(text).replace(/[^a-z0-9]/g, ""); // "b a d" -> "bad"
}

export function findBlockedTerms(text: string, terms: string[]): string[] {
const hay = collapse(text);
if (!hay) return [];
return terms.filter((t) => {
const needle = collapse(t);
return needle && hay.includes(needle);
});
}

// Superficial client list — a deliberate SUBSET of the server list. Terms are
// ROOT STEMS: the substring matcher auto-catches variants, so "fuck" also
// blocks fucking/motherfucker and "retard" covers retarded. The server holds
// the full, authoritative list.
export const CLIENT_BLOCKED_TERMS: string[] = [
// profanity
"shit",
"fuck",
"bitch",
"asshole",
"bastard",
"dumbass",
"jackass",
"dick",
"piss",
"pussy",
"cunt",
"slut",
"whore",
// top slurs
"nigger",
"nigga",
"faggot",
"retard",
];

Use it in the composer:

import { CLIENT_BLOCKED_TERMS, findBlockedTerms } from "./chat-moderation";

const isBlocked = findBlockedTerms(draft, CLIENT_BLOCKED_TERMS).length > 0;
// disable the send button + show a hint while isBlocked === true

Keep it in sync

This doc is kept in step with CLIENT_BLOCKED_TERMS in the API repo, so re-copy the block above whenever the guide updates. There is no runtime link between the repos — a stale mobile copy only means weaker client-side UX, since the server still rejects anything the mobile list missed. The comprehensive SERVER_BLOCKED_TERMS is intentionally not shipped to the client — keep it out of the app bundle and off the wire; don't fetch or reconstruct it.


What the matcher catches (evasion normalization)

So you know why a "clean-looking" string still gets rejected:

InputNormalizes toCaught?
badwordbadwordyes
b@dw0rdbadwordyes (leetspeak)
b a d w-o-r-dbadwordyes (spacing/punctuation)
baaadwooordbadwordyes (3+ repeats collapsed)
BADWORDbadwordyes (case-insensitive)
passwordpasswordno (substring differs)

Don't

  • Don't rely on the client check. It's a courtesy. The server 422 is the boundary — always handle it.
  • Don't echo the blocked word back to the user or log it in analytics.
  • Don't cache or display the server list. You only ever hold the small superficial list.

Reference

Matcher self-check (server repo, no test framework):

pnpm --filter @ksn/api exec tsx ../../packages/validators/src/moderation.selfcheck.ts

Every other surface (posts, comments, profile, events, search…): mobile-content-moderation-guide.md.

Full status + enforcement points: docs/CHAT_IMPLEMENTATION_V2.md §11.

⤓ Download .md