Skip to content

feat: make ban filters real, and let the word filter reach a thread title - #267

Merged
jouwdan merged 5 commits into
mainfrom
claude/linear-tickets-mei-144-148-w9r2xx
Aug 27, 2026
Merged

feat: make ban filters real, and let the word filter reach a thread title#267
jouwdan merged 5 commits into
mainfrom
claude/linear-tickets-mei-144-148-w9r2xx

Conversation

@jouwdan

@jouwdan jouwdan commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Closes MEI-144, MEI-145, MEI-146, MEI-147, MEI-148. Five commits, one per story, meant to be read in order.

MEI-144 — ban filters enforce on every board (dfca868)

The matcher, the table, the repositories and six call sites all existed. No production construction site supplied a BanFilterRepository, and IdentityService returned early when it was missing, so every board's ban filters did nothing — while moderation-guide.md and single-sign-on.md told administrators they did.

banFilters is now required on IdentityDeps and the if (!this.banFilters) return is gone, so the service cannot be constructed in a state where filters are silently off. A Postgres board gets PostgresBanFilterRepository, a fixture board gets MemoryBanFilters, and configuredIdentity() reuses the container's instance.

The installer's behaviour is a decision: it does not consult filters. install.ts creates the very first administrator, and a filter matching them would lock the board out of its own installation with no interface to remove the filter. meith user create is the same call — the operator's rescue path, and an operator with shell access is not the threat ban filters exist for. Both sites pass a named BAN_FILTERS_NOT_CONSULTED rather than omitting the dependency, so the bypass is visible at the call site and cannot be inherited by accident.

Six call sites, six tests, each demonstrated to fail without its check. I removed each assertNotFiltered call one at a time and re-ran the suite:

service.ts Route Test that failed
145 register filtered email / username / IP at registration
203 provisionFederated (email, ip) filtered address when a federated provider provisions
212 provisionFederated (generated username) filtered username, clean address
338 login (ip, pre-hashing) filtered IP before spending hashing budget
375 login (username, email, post-auth) existing account once a filter matches it
491 assertSignInAllowed filtered account at a session hand-off

Making the dependency required also caught test-container.ts, which had no banFilters — 201 app tests failed until it got one.

MEI-145 — the write half (2a78c4a)

BanFilterAdminRepository extends BanFilterRepository with listForAdmin(), create() and remove(). listAll() and BanFilter are unchanged, so the query on every registration and sign-in still reads three columns while the screen's listing reads the note, author and date the table has carried since the initial schema. No migration.

create validates through assertUsableFilter before writing and catches the duplicate with insert … on conflict do nothing returning id — driver-agnostic, atomic, and it surfaces as the same ValidationError the caps throw, so the form has one error path. remove on a missing id is a no-op.

One contract suite (ban-filter-contract.fixture.ts, following driver-contracts.ts) runs 12 cases against both implementations — the Postgres one on the PGlite harness ban-repos.test.ts already uses.

MEI-146 — the screen (f22d559)

/admin/users/ban-filters, shaped after /admin/groups/promotions: plain form posts to server actions, so it works with JavaScript disabled. requireAdmin() in both actions, revalidatePath, recordAdminAction (user.ban_filter_added / user.ban_filter_removed), toFormState as the single error path.

A pattern matching the acting administrator is refused — their username, their address, or the network they are on — because saving it is a lockout with no way back through the interface. A duplicate, over-long or over-wild pattern renders as a message on the form.

On fixture mode: the ticket asked whether the screen should work there or show the standard message. Neither applies — ABSENT_SERVICES sets adminSessions: null, so no admin screen is reachable in fixture mode. Rather than add an unreachable null branch, there is one accessor returning the container's repository, which exists in both modes because of story 1.

New docs page docs/guides/community/ban-filters.md: the three kinds and what each compares against, globs versus regular expressions with .* called out, the caps, the self-lockout refusal, every route that consults a filter and the two that deliberately do not, and why the refusal message is vague. Registered in the manifest, linked from docs/README.md, README.md's table regenerated.

MEI-147 — proved in a browser, with script off (cf7d25b)

e2e/ban-filters-no-js.spec.ts: an administrator adds a filter through the screen, a matching registration is refused, a non-matching registration still succeeds, the filter is removed, and the previously blocked address registers. Both email and username walk that path. A third test proves the screen refuses a filter matching the administrator. 3 passed in 25s, no sleeps and no retries.

Proving it can fail, and the first attempt was wrong. I first dropped banFilters from identityServices() — and the spec still passed, because registration and sign-in do not use the container's identity; they call configuredIdentity(). getContainer().identity has exactly one caller. Removing the wiring from configuredIdentity() as well, both filtering tests failed on the refusal message not being visible: the blocked registration silently succeeded, which is precisely the original bug. Wiring restored, board rebuilt, green again.

ip is deliberately not covered in the browser: the browser and the board share 127.0.0.1, so any ip pattern matching the visitor also matches the administrator and story 3's guard correctly refuses to save it. Driving it would mean defeating a guard this suite exists to prove. It stays covered by story 1's unit tests.

MEI-148 — the word filter reaches a thread title (5517545)

All seven filterWords call sites guarded a body, an excerpt or a summary. Titles are now filtered where a title becomes a view model: the thread heading and breadcrumb, forum listings and last-post lines, the board index, latest threads and posts, discovery, search results, followed threads, who-is-online, the reply form heading, feed entry titles, and the <title>, OpenGraph and structured data.

Two surfaces keep the stored title on purpose, and the docs say so: the moderation queue, reports and mod control panel show the words being judged; and the REST API's /threads resource is a record a client may write back, where a filtered title becoming the stored one would be a real loss. The search results and subscription lists the API serves are display text and are filtered. Notification subjects are out of scope and flagged as a separate question, since a subject also becomes an e-mail and a push payload.

Worth knowing: the board has no rename-a-thread control, so the ticket's fallback of "a moderator can rename it in two clicks" is not actually available — a filter rule is the only way to take a word out of a title that already exists. word-filter.md now says that.

15 tests across two files cover every filtered surface, the two API surfaces, and the unfiltered case.

Checks

pnpm lint, pnpm typecheck, pnpm typecheck:app, pnpm i18n:check, pnpm docs:index:check, pnpm docs:links:check, pnpm site:docs:check, pnpm comments:check, pnpm depcruise, and the touched suites — apps/community (2,131), packages/accounts + the db ban suites (426), and the browser spec. Per the tickets I did not run pnpm verify, the full unit suite, or the full e2e suite; those are run centrally. No version moved.

🤖 Generated with Claude Code

https://claude.ai/code/session_016zJzrmeiQvSnyuQbEThdwv


Generated by Claude Code

claude added 5 commits August 27, 2026 08:28
The matcher, the table, the repositories and the six call sites all
existed, but no production construction site supplied a
BanFilterRepository and IdentityService returned early when it was
missing. Every board's ban filters did nothing.

banFilters is now a required dependency and the early return is gone,
so an IdentityService cannot be built in a state where filters are
silently off. A Postgres board gets PostgresBanFilterRepository, a
fixture board gets MemoryBanFilters, and the two sites that create an
account on an operator's behalf — the installer's first administrator
and the CLI's user create — name their opt-out with
BAN_FILTERS_NOT_CONSULTED rather than omitting the dependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016zJzrmeiQvSnyuQbEThdwv
The port could only list. BanFilterAdminRepository extends it with
listForAdmin, create and remove, implemented by both
PostgresBanFilterRepository and MemoryBanFilters so a fixture board
behaves like a Postgres one.

listAll and BanFilter are untouched: the query that runs on every
registration and sign-in still reads three columns, while the screen's
listing reads the note, the author and the date the table has carried
since the initial schema. create validates through assertUsableFilter
before writing, and the unique index on (type, pattern) surfaces as a
ValidationError rather than a constraint violation, so an interface has
one error path. Removing a filter that is already gone is not an error.

One contract suite runs against both implementations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016zJzrmeiQvSnyuQbEThdwv
Until now the only way to add a ban filter was SQL. The screen lists
every filter with its note, who added it and when, adds one through a
type selector, a pattern field and an optional note, and removes one
per row. It is plain form posts to server actions, so it works with
JavaScript disabled.

Both actions call requireAdmin first and both write to the admin log.
A pattern matching the acting administrator's own username, address or
network is refused: saving it would lock them out with no way back
through the interface. A duplicate, over-long or over-wild pattern
comes back as a message on the form rather than a 500.

The screen says on the page that patterns are globs rather than regular
expressions, which is the mistake somebody typing `.*` is about to
make, and docs/guides/community/ban-filters.md says it at length along
with what the three kinds match against and which routes into an
account consult a filter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016zJzrmeiQvSnyuQbEThdwv
… away

Unit tests are what failed to catch this feature being switched off for
its whole life, because the only place supplying a repository was the
test. This spec cannot be fooled that way: a real browser with script
off, a real database, an administrator adding a filter through the
screen, and a would-be member refused at registration.

Both an e-mail filter and a username filter walk the whole path — add
through the screen, matching registration refused, non-matching
registration still succeeds, filter removed, previously blocked
registration succeeds. A third case proves the screen refuses a filter
that would match the administrator adding it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016zJzrmeiQvSnyuQbEThdwv
The word filter guarded seven call sites and every one of them was a
body, an excerpt or a summary. A board that filtered a slur out of post
bodies still printed it as the thread heading, in every forum listing,
in search results beside the filtered excerpt, and in the feed a search
engine reads — the most visible surface a word can occupy was the one
surface the filter never reached.

Titles are now filtered where a title becomes a view model: the thread
heading and its breadcrumb, forum listings and the last-post lines, the
board index, the latest-threads and latest-posts panels, discovery,
search results, followed threads, who-is-online, the reply form
heading, feed entry titles, and the page title, OpenGraph and
structured data.

Two surfaces keep the stored title on purpose. The moderation queue,
the reports and the mod control panel show the words being judged. The
REST API's thread resource is a record a client may write back, so a
filtered title must never become the stored one; the search results and
subscription lists it serves are display text and are filtered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016zJzrmeiQvSnyuQbEThdwv
@jouwdan
jouwdan merged commit 48d7b40 into main Aug 27, 2026
15 checks passed
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.

2 participants