feat: make ban filters real, and let the word filter reach a thread title - #267
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, andIdentityServicereturned early when it was missing, so every board's ban filters did nothing — whilemoderation-guide.mdandsingle-sign-on.mdtold administrators they did.banFiltersis now required onIdentityDepsand theif (!this.banFilters) returnis gone, so the service cannot be constructed in a state where filters are silently off. A Postgres board getsPostgresBanFilterRepository, a fixture board getsMemoryBanFilters, andconfiguredIdentity()reuses the container's instance.The installer's behaviour is a decision: it does not consult filters.
install.tscreates 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 createis 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 namedBAN_FILTERS_NOT_CONSULTEDrather 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
assertNotFilteredcall one at a time and re-ran the suite:registerprovisionFederated(email, ip)provisionFederated(generated username)login(ip, pre-hashing)login(username, email, post-auth)assertSignInAllowedMaking the dependency required also caught
test-container.ts, which had nobanFilters— 201 app tests failed until it got one.MEI-145 — the write half (
2a78c4a)BanFilterAdminRepository extends BanFilterRepositorywithlistForAdmin(),create()andremove().listAll()andBanFilterare 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.createvalidates throughassertUsableFilterbefore writing and catches the duplicate withinsert … on conflict do nothing returning id— driver-agnostic, atomic, and it surfaces as the sameValidationErrorthe caps throw, so the form has one error path.removeon a missing id is a no-op.One contract suite (
ban-filter-contract.fixture.ts, followingdriver-contracts.ts) runs 12 cases against both implementations — the Postgres one on the PGlite harnessban-repos.test.tsalready 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),toFormStateas 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_SERVICESsetsadminSessions: 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 fromdocs/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. Bothemailandusernamewalk 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
banFiltersfromidentityServices()— and the spec still passed, because registration and sign-in do not use the container'sidentity; they callconfiguredIdentity().getContainer().identityhas exactly one caller. Removing the wiring fromconfiguredIdentity()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.ipis deliberately not covered in the browser: the browser and the board share 127.0.0.1, so anyippattern 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
filterWordscall 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
/threadsresource 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.mdnow 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 runpnpm 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