WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

docs: ATB-21 firehose ban enforcement design

Captures design decisions for ban enforcement in the firehose indexer:
skip new posts from banned users, soft-delete existing posts on ban,
restore on unban, with a BanEnforcer class composing into Indexer.

+119
+119
docs/plans/2026-02-16-atb21-firehose-ban-enforcement-design.md
··· 1 + # ATB-21: Firehose Ban Enforcement — Design 2 + 3 + **Date:** 2026-02-16 4 + **Issue:** [ATB-21](https://linear.app/atbb/issue/ATB-21/enforce-mod-actions-in-firehose-indexer) 5 + **Status:** Approved 6 + 7 + ## Problem 8 + 9 + AT Proto has no server-side ACLs — a banned user can still write posts to their PDS. The AppView's firehose indexer must act as the forum's gatekeeper: ignoring new posts from banned users and hiding existing posts when a ban is applied. 10 + 11 + ## Decisions 12 + 13 + | Question | Decision | Rationale | 14 + |---|---|---| 15 + | Skip vs soft-mark new posts | **Skip** (never index) | Banned users don't participate; no audit trail needed for content they never contributed to the forum | 16 + | Existing posts on ban | **Soft-delete** (`deleted = true`) | Preserves data for mod mistake recovery | 17 + | Unban restoration | **Restore** (`deleted = false`) | Allows mods to undo mistakes | 18 + | New column vs reuse `deleted` | **Reuse `deleted`** | No semantic ambiguity in practice; keeps schema simple | 19 + | Ban check performance | **DB query + existing index** | `mod_actions_subject_did_idx` makes this fast at forum scale; cache adds complexity for negligible gain | 20 + 21 + ## Architecture 22 + 23 + A new `BanEnforcer` class is composed into the existing `Indexer`. The `Indexer` remains responsible for event routing; `BanEnforcer` owns all ban-related DB logic. 24 + 25 + ``` 26 + FirehoseService → Indexer → BanEnforcer 27 + 28 + mod_actions (read: isBanned) 29 + posts (write: applyBan / liftBan) 30 + ``` 31 + 32 + ### BanEnforcer Interface 33 + 34 + ```typescript 35 + class BanEnforcer { 36 + constructor(private db: Database) {} 37 + 38 + // Query mod_actions for an active, non-expired ban on this DID. 39 + // Fail closed: returns true (banned) if DB throws. 40 + async isBanned(did: string): Promise<boolean> 41 + 42 + // Soft-delete all posts where did = subjectDid. 43 + async applyBan(subjectDid: string): Promise<void> 44 + 45 + // Restore all posts where did = subjectDid (set deleted = false). 46 + async liftBan(subjectDid: string): Promise<void> 47 + } 48 + ``` 49 + 50 + ### Indexer Changes 51 + 52 + Three handler methods are overridden: 53 + 54 + **`handlePostCreate`** 55 + Before `genericCreate`, call `banEnforcer.isBanned(event.did)`. If true, log and return early — the post is never inserted. 56 + 57 + **`handleModActionCreate`** 58 + After `genericCreate` succeeds, inspect `record.action`. If `space.atbb.modAction.ban` and `record.subject.did` is set, call `banEnforcer.applyBan(subjectDid)`. 59 + 60 + **`handleModActionDelete`** 61 + Because `genericDelete` hard-deletes the row, we must read before deleting: 62 + 1. Read the `mod_actions` row to capture `action` and `subjectDid` 63 + 2. Hard-delete the row (`genericDelete`) 64 + 3. If the deleted action was a `ban`, call `banEnforcer.liftBan(subjectDid)` 65 + 66 + Steps 1–3 are wrapped in a transaction. 67 + 68 + `handleModActionUpdate` is unchanged. 69 + 70 + ## Error Handling 71 + 72 + **`isBanned` query** 73 + - Queries `mod_actions` where `subjectDid = did AND action = 'space.atbb.modAction.ban' AND (expiresAt IS NULL OR expiresAt > NOW())` 74 + - Fail closed: DB error → log → return `true` (treat as banned, suppress the post) 75 + 76 + **`applyBan` / `liftBan`** 77 + - Re-throw on failure — do not silently swallow errors that leave posts in inconsistent state 78 + - Circuit breaker in `FirehoseService` will handle repeated failures 79 + 80 + **Race condition** 81 + Handled naturally by eventual consistency: 82 + - Ban indexed first → `isBanned` returns true → post skipped 83 + - Post indexed first → inserted normally → `applyBan` soft-deletes it moments later 84 + 85 + No special handling required. 86 + 87 + **Expired bans** 88 + `isBanned` respects `expiresAt`. Expired bans do not suppress new posts. Retroactive restoration of posts suppressed under an expired ban is out of scope. 89 + 90 + ## Test Plan 91 + 92 + ### BanEnforcer unit tests (`indexer-ban-enforcer.test.ts`) 93 + 94 + - `isBanned` → `true` for active ban 95 + - `isBanned` → `false` when no ban exists 96 + - `isBanned` → `false` for expired ban 97 + - `isBanned` → `true` for ban with no expiry 98 + - `isBanned` → `true` (fail closed) when DB throws 99 + - `applyBan` sets `deleted = true` on all posts for subject DID 100 + - `liftBan` sets `deleted = false` on all posts for subject DID 101 + 102 + ### Indexer integration tests (additions to `indexer.test.ts`) 103 + 104 + - Post from banned user is skipped (no insert) 105 + - Post from non-banned user is indexed normally 106 + - Ban mod action triggers `applyBan` after indexing 107 + - Non-ban mod action (e.g. `pin`) does NOT trigger `applyBan` 108 + - Deleting a ban record triggers `liftBan` 109 + - Deleting a non-ban mod action does NOT trigger `liftBan` 110 + - Race: post indexed first, then ban → post gets soft-deleted 111 + 112 + ## Files Affected 113 + 114 + | File | Change | 115 + |---|---| 116 + | `apps/appview/src/lib/ban-enforcer.ts` | New file | 117 + | `apps/appview/src/lib/__tests__/indexer-ban-enforcer.test.ts` | New file | 118 + | `apps/appview/src/lib/indexer.ts` | Compose `BanEnforcer`; override 3 handlers | 119 + | `apps/appview/src/lib/__tests__/indexer.test.ts` | Add integration tests |