# 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

<!-- 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.

---

## 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):

```jsonc
{
  "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.

```ts
// 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:

```ts
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):

```bash
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`](../moderation/mobile-content-moderation-guide.md).

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