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.
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 carriesfield(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.
| Endpoint | When blocked |
|---|---|
POST /api/chat/conversations/:id/messages | content (or a post-share caption) contains a blocked term |
PATCH /api/chat/messages/:messageId | edited 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:
| Input | Normalizes to | Caught? |
|---|---|---|
badword | badword | yes |
b@dw0rd | badword | yes (leetspeak) |
b a d w-o-r-d | badword | yes (spacing/punctuation) |
baaadwooord | badword | yes (3+ repeats collapsed) |
BADWORD | badword | yes (case-insensitive) |
password | password | no (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.