Skip to main content
Version: Latest

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-10-06 — Fixed false 422s on innocent text: the filter used to glue words together before matching, so "What is in this pic?" read as a slur. A term now only matches inside one word (or letters spaced out one by one, like b a d). Re-copy the findBlockedTerms block below into the app — nothing else changes, same 422 shape.
  • 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, "");
}

// Each word on its own, plus runs of 2+ single chars joined ("b a d" -> "bad").
// A term never matches ACROSS two words ("this pic" is not "spic").
function haystacks(text: string): string[] {
const out: string[] = [];
let run = "";
const flush = () => {
if (run.length > 1) out.push(run);
run = "";
};
for (const tok of normalize(text).split(/[^a-z0-9]+/)) {
if (tok.length === 1) {
run += tok;
continue;
}
flush();
if (tok) out.push(tok);
}
flush();
return out;
}

export function findBlockedTerms(text: string, terms: string[]): string[] {
const stacks = haystacks(text);
if (!stacks.length) return [];
return terms.filter((t) => {
const needle = collapse(t);
return needle && stacks.some((s) => s.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 (single letters spaced out)
baaadwooordbadwordyes (3+ repeats collapsed)
BADWORDbadwordyes (case-insensitive)
passwordpasswordno (substring differs)
bad wordtwo wordsno (a term never spans two words)

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