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 implementation plan

Step-by-step TDD plan: BanEnforcer class, three Indexer handler
overrides (post create skip, ban retroactive soft-delete, unban restore),
and race condition test coverage.

+857
+857
docs/plans/2026-02-16-atb21-firehose-ban-enforcement.md
··· 1 + # ATB-21: Firehose Ban Enforcement Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Enforce bans in the firehose indexer — skip new posts from banned users, soft-delete existing posts on ban, restore them on unban. 6 + 7 + **Architecture:** A new `BanEnforcer` class encapsulates all ban-related DB queries and is composed into the existing `Indexer`. Three `Indexer` handler methods are overridden: `handlePostCreate` (skip if banned), `handleModActionCreate` (retroactive soft-delete on ban), `handleModActionDelete` (restore posts on unban). 8 + 9 + **Tech Stack:** TypeScript, Drizzle ORM, Vitest, Hono (no new deps needed) 10 + 11 + **Design doc:** `docs/plans/2026-02-16-atb21-firehose-ban-enforcement-design.md` 12 + 13 + --- 14 + 15 + ## Environment Setup 16 + 17 + Run all commands from the repo root inside a devenv shell: 18 + ```bash 19 + devenv shell 20 + ``` 21 + 22 + pnpm is at `.devenv/profile/bin/pnpm`. Either enter the devenv shell (which puts it on PATH) or prefix commands with `PATH=.devenv/profile/bin:$PATH pnpm ...`. 23 + 24 + Run tests with: 25 + ```bash 26 + pnpm --filter @atbb/appview test 27 + ``` 28 + 29 + --- 30 + 31 + ## Task 1: Create BanEnforcer with unit tests 32 + 33 + **Files:** 34 + - Create: `apps/appview/src/lib/ban-enforcer.ts` 35 + - Create: `apps/appview/src/lib/__tests__/indexer-ban-enforcer.test.ts` 36 + 37 + ### Step 1: Write the failing tests 38 + 39 + Create `apps/appview/src/lib/__tests__/indexer-ban-enforcer.test.ts`: 40 + 41 + ```typescript 42 + import { describe, it, expect, beforeEach, vi } from "vitest"; 43 + import { BanEnforcer } from "../ban-enforcer.js"; 44 + import type { Database } from "@atbb/db"; 45 + 46 + const createMockDb = () => { 47 + const mockSelect = vi.fn(); 48 + const mockUpdate = vi.fn(); 49 + 50 + return { 51 + select: mockSelect, 52 + update: mockUpdate, 53 + } as unknown as Database; 54 + }; 55 + 56 + describe("BanEnforcer", () => { 57 + let mockDb: Database; 58 + let enforcer: BanEnforcer; 59 + 60 + beforeEach(() => { 61 + vi.clearAllMocks(); 62 + mockDb = createMockDb(); 63 + enforcer = new BanEnforcer(mockDb); 64 + }); 65 + 66 + describe("isBanned", () => { 67 + it("returns true when an active ban exists (no expiry)", async () => { 68 + (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({ 69 + from: vi.fn().mockReturnValue({ 70 + where: vi.fn().mockReturnValue({ 71 + limit: vi.fn().mockResolvedValue([{ id: 1n }]), 72 + }), 73 + }), 74 + }); 75 + 76 + expect(await enforcer.isBanned("did:plc:banned123")).toBe(true); 77 + }); 78 + 79 + it("returns false when no ban exists", async () => { 80 + (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({ 81 + from: vi.fn().mockReturnValue({ 82 + where: vi.fn().mockReturnValue({ 83 + limit: vi.fn().mockResolvedValue([]), 84 + }), 85 + }), 86 + }); 87 + 88 + expect(await enforcer.isBanned("did:plc:user123")).toBe(false); 89 + }); 90 + 91 + it("returns false when only an expired ban exists", async () => { 92 + // The SQL query filters out expired bans, so the DB returns empty 93 + (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({ 94 + from: vi.fn().mockReturnValue({ 95 + where: vi.fn().mockReturnValue({ 96 + limit: vi.fn().mockResolvedValue([]), 97 + }), 98 + }), 99 + }); 100 + 101 + expect(await enforcer.isBanned("did:plc:user123")).toBe(false); 102 + }); 103 + 104 + it("returns true (fail closed) when DB throws", async () => { 105 + (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({ 106 + from: vi.fn().mockReturnValue({ 107 + where: vi.fn().mockReturnValue({ 108 + limit: vi.fn().mockRejectedValue(new Error("DB connection lost")), 109 + }), 110 + }), 111 + }); 112 + 113 + expect(await enforcer.isBanned("did:plc:user123")).toBe(true); 114 + }); 115 + }); 116 + 117 + describe("applyBan", () => { 118 + it("soft-deletes all posts for the subject DID", async () => { 119 + const mockWhere = vi.fn().mockResolvedValue(undefined); 120 + const mockSet = vi.fn().mockReturnValue({ where: mockWhere }); 121 + (mockDb.update as ReturnType<typeof vi.fn>).mockReturnValue({ set: mockSet }); 122 + 123 + await enforcer.applyBan("did:plc:banned123"); 124 + 125 + expect(mockSet).toHaveBeenCalledWith({ deleted: true }); 126 + expect(mockWhere).toHaveBeenCalled(); 127 + }); 128 + }); 129 + 130 + describe("liftBan", () => { 131 + it("restores all posts for the subject DID", async () => { 132 + const mockWhere = vi.fn().mockResolvedValue(undefined); 133 + const mockSet = vi.fn().mockReturnValue({ where: mockWhere }); 134 + (mockDb.update as ReturnType<typeof vi.fn>).mockReturnValue({ set: mockSet }); 135 + 136 + await enforcer.liftBan("did:plc:unbanned123"); 137 + 138 + expect(mockSet).toHaveBeenCalledWith({ deleted: false }); 139 + expect(mockWhere).toHaveBeenCalled(); 140 + }); 141 + }); 142 + }); 143 + ``` 144 + 145 + ### Step 2: Run tests to confirm they fail 146 + 147 + ```bash 148 + pnpm --filter @atbb/appview test src/lib/__tests__/indexer-ban-enforcer.test.ts 149 + ``` 150 + 151 + Expected: **FAIL** — `Cannot find module '../ban-enforcer.js'` 152 + 153 + ### Step 3: Implement BanEnforcer 154 + 155 + Create `apps/appview/src/lib/ban-enforcer.ts`: 156 + 157 + ```typescript 158 + import type { DbOrTransaction } from "@atbb/db"; 159 + import { modActions, posts } from "@atbb/db"; 160 + import { and, eq, gt, isNull, or } from "drizzle-orm"; 161 + 162 + /** 163 + * Encapsulates ban enforcement logic for the firehose indexer. 164 + * 165 + * Used by the Indexer to: 166 + * - Check ban status before indexing posts (fail closed) 167 + * - Soft-delete existing posts when a ban is applied 168 + * - Restore posts when a ban is lifted 169 + */ 170 + export class BanEnforcer { 171 + constructor(private db: DbOrTransaction) {} 172 + 173 + /** 174 + * Returns true if the DID has an active (non-expired) ban. 175 + * Fails closed: returns true if the DB query throws. 176 + */ 177 + async isBanned(did: string, dbOrTx: DbOrTransaction = this.db): Promise<boolean> { 178 + try { 179 + const now = new Date(); 180 + const result = await dbOrTx 181 + .select({ id: modActions.id }) 182 + .from(modActions) 183 + .where( 184 + and( 185 + eq(modActions.subjectDid, did), 186 + eq(modActions.action, "space.atbb.modAction.ban"), 187 + or(isNull(modActions.expiresAt), gt(modActions.expiresAt, now)) 188 + ) 189 + ) 190 + .limit(1); 191 + 192 + return result.length > 0; 193 + } catch (error) { 194 + console.error( 195 + "Failed to check ban status - denying indexing (fail closed)", 196 + { 197 + did, 198 + error: error instanceof Error ? error.message : String(error), 199 + } 200 + ); 201 + return true; // fail closed 202 + } 203 + } 204 + 205 + /** 206 + * Soft-deletes all posts for the given DID. 207 + * Called when a ban mod action is indexed. 208 + */ 209 + async applyBan(subjectDid: string, dbOrTx: DbOrTransaction = this.db): Promise<void> { 210 + await dbOrTx 211 + .update(posts) 212 + .set({ deleted: true }) 213 + .where(eq(posts.did, subjectDid)); 214 + 215 + console.log( 216 + `[BAN] Applied ban: soft-deleted all posts for ${subjectDid}` 217 + ); 218 + } 219 + 220 + /** 221 + * Restores all posts for the given DID. 222 + * Called when a ban mod action record is deleted (unban). 223 + */ 224 + async liftBan(subjectDid: string, dbOrTx: DbOrTransaction = this.db): Promise<void> { 225 + await dbOrTx 226 + .update(posts) 227 + .set({ deleted: false }) 228 + .where(eq(posts.did, subjectDid)); 229 + 230 + console.log( 231 + `[UNBAN] Lifted ban: restored all posts for ${subjectDid}` 232 + ); 233 + } 234 + } 235 + ``` 236 + 237 + ### Step 4: Run tests to confirm they pass 238 + 239 + ```bash 240 + pnpm --filter @atbb/appview test src/lib/__tests__/indexer-ban-enforcer.test.ts 241 + ``` 242 + 243 + Expected: **PASS** — 7 tests 244 + 245 + ### Step 5: Commit 246 + 247 + ```bash 248 + git add apps/appview/src/lib/ban-enforcer.ts apps/appview/src/lib/__tests__/indexer-ban-enforcer.test.ts 249 + git commit -m "feat: add BanEnforcer class for firehose ban enforcement (ATB-21)" 250 + ``` 251 + 252 + --- 253 + 254 + ## Task 2: Override handlePostCreate in Indexer 255 + 256 + **Files:** 257 + - Modify: `apps/appview/src/lib/indexer.ts` 258 + - Modify: `apps/appview/src/lib/__tests__/indexer.test.ts` 259 + 260 + The `Indexer` must compose `BanEnforcer` and skip indexing posts from banned users. 261 + 262 + ### Step 1: Mock BanEnforcer in the indexer test file and add failing tests 263 + 264 + At the top of `apps/appview/src/lib/__tests__/indexer.test.ts`, add a mock for `BanEnforcer` **before** any other imports (alongside the existing `vi.mock` calls for database): 265 + 266 + ```typescript 267 + // Add after existing vi.mock calls at the top of the file 268 + 269 + vi.mock("../ban-enforcer.js", () => ({ 270 + BanEnforcer: vi.fn().mockImplementation(() => ({ 271 + isBanned: vi.fn().mockResolvedValue(false), 272 + applyBan: vi.fn().mockResolvedValue(undefined), 273 + liftBan: vi.fn().mockResolvedValue(undefined), 274 + })), 275 + })); 276 + ``` 277 + 278 + Then add a new describe block in `indexer.test.ts`. The existing test structure uses `createMockDb()` and `new Indexer(mockDb)`. Add at the end of the `describe("Indexer")` block: 279 + 280 + ```typescript 281 + describe("Ban enforcement — handlePostCreate", () => { 282 + it("skips indexing when the user is banned", async () => { 283 + const { BanEnforcer } = await import("../ban-enforcer.js"); 284 + const mockBanEnforcer = vi.mocked(BanEnforcer).mock.results[0].value; 285 + mockBanEnforcer.isBanned.mockResolvedValue(true); 286 + 287 + const event = { 288 + did: "did:plc:banned123", 289 + time_us: 1234567890, 290 + kind: "commit", 291 + commit: { 292 + rev: "abc", 293 + operation: "create", 294 + collection: "space.atbb.post", 295 + rkey: "post1", 296 + cid: "cid123", 297 + record: { 298 + $type: "space.atbb.post", 299 + text: "Hello world", 300 + createdAt: "2024-01-01T00:00:00Z", 301 + }, 302 + }, 303 + } as any; 304 + 305 + await indexer.handlePostCreate(event); 306 + 307 + // The DB insert should NOT have been called 308 + expect(mockDb.insert).not.toHaveBeenCalled(); 309 + }); 310 + 311 + it("indexes the post normally when the user is not banned", async () => { 312 + const { BanEnforcer } = await import("../ban-enforcer.js"); 313 + const mockBanEnforcer = vi.mocked(BanEnforcer).mock.results[0].value; 314 + mockBanEnforcer.isBanned.mockResolvedValue(false); 315 + 316 + // Set up select to return a user (ensureUser) and no parent/root posts 317 + (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({ 318 + from: vi.fn().mockReturnValue({ 319 + where: vi.fn().mockReturnValue({ 320 + limit: vi.fn().mockResolvedValue([{ did: "did:plc:user123" }]), 321 + }), 322 + }), 323 + }); 324 + 325 + const event = { 326 + did: "did:plc:user123", 327 + time_us: 1234567890, 328 + kind: "commit", 329 + commit: { 330 + rev: "abc", 331 + operation: "create", 332 + collection: "space.atbb.post", 333 + rkey: "post1", 334 + cid: "cid123", 335 + record: { 336 + $type: "space.atbb.post", 337 + text: "Hello world", 338 + createdAt: "2024-01-01T00:00:00Z", 339 + }, 340 + }, 341 + } as any; 342 + 343 + await indexer.handlePostCreate(event); 344 + 345 + expect(mockDb.transaction).toHaveBeenCalled(); 346 + }); 347 + }); 348 + ``` 349 + 350 + ### Step 2: Run tests to confirm they fail 351 + 352 + ```bash 353 + pnpm --filter @atbb/appview test src/lib/__tests__/indexer.test.ts 354 + ``` 355 + 356 + Expected: **FAIL** — the "skips indexing" test fails because `handlePostCreate` doesn't check ban status yet. 357 + 358 + ### Step 3: Modify Indexer to compose BanEnforcer and override handlePostCreate 359 + 360 + In `apps/appview/src/lib/indexer.ts`: 361 + 362 + **Add import** (alongside existing imports at the top): 363 + ```typescript 364 + import { BanEnforcer } from "./ban-enforcer.js"; 365 + ``` 366 + 367 + **Add field** (in the class body, alongside the collection configs): 368 + ```typescript 369 + private banEnforcer: BanEnforcer; 370 + ``` 371 + 372 + **Update constructor:** 373 + ```typescript 374 + constructor(private db: Database) { 375 + this.banEnforcer = new BanEnforcer(db); 376 + } 377 + ``` 378 + 379 + **Replace `handlePostCreate`:** 380 + ```typescript 381 + async handlePostCreate(event: CommitCreateEvent<"space.atbb.post">) { 382 + const banned = await this.banEnforcer.isBanned(event.did); 383 + if (banned) { 384 + console.log( 385 + `[SKIP] Post from banned user: ${event.did}/${event.commit.rkey}` 386 + ); 387 + return; 388 + } 389 + await this.genericCreate(this.postConfig, event); 390 + } 391 + ``` 392 + 393 + ### Step 4: Run tests to confirm they pass 394 + 395 + ```bash 396 + pnpm --filter @atbb/appview test src/lib/__tests__/indexer.test.ts 397 + ``` 398 + 399 + Expected: **PASS** 400 + 401 + ### Step 5: Commit 402 + 403 + ```bash 404 + git add apps/appview/src/lib/indexer.ts apps/appview/src/lib/__tests__/indexer.test.ts 405 + git commit -m "feat: skip indexing posts from banned users in firehose (ATB-21)" 406 + ``` 407 + 408 + --- 409 + 410 + ## Task 3: Override handleModActionCreate — retroactive ban enforcement 411 + 412 + **Files:** 413 + - Modify: `apps/appview/src/lib/indexer.ts` 414 + - Modify: `apps/appview/src/lib/__tests__/indexer.test.ts` 415 + 416 + When a ban mod action is indexed, all existing posts from the subject DID must be soft-deleted. 417 + 418 + ### Step 1: Add failing tests 419 + 420 + Add a new describe block in `indexer.test.ts`: 421 + 422 + ```typescript 423 + describe("Ban enforcement — handleModActionCreate", () => { 424 + it("calls applyBan when a ban mod action is created", async () => { 425 + const { BanEnforcer } = await import("../ban-enforcer.js"); 426 + const mockBanEnforcer = vi.mocked(BanEnforcer).mock.results[0].value; 427 + 428 + // Set up select to return a forum (getForumIdByDid) 429 + (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({ 430 + from: vi.fn().mockReturnValue({ 431 + where: vi.fn().mockReturnValue({ 432 + limit: vi.fn().mockResolvedValue([{ id: 1n }]), 433 + }), 434 + }), 435 + }); 436 + 437 + const event = { 438 + did: "did:plc:forum", 439 + time_us: 1234567890, 440 + kind: "commit", 441 + commit: { 442 + rev: "abc", 443 + operation: "create", 444 + collection: "space.atbb.modAction", 445 + rkey: "action1", 446 + cid: "cid123", 447 + record: { 448 + $type: "space.atbb.modAction", 449 + action: "space.atbb.modAction.ban", 450 + subject: { did: "did:plc:target123" }, 451 + createdBy: "did:plc:mod", 452 + createdAt: "2024-01-01T00:00:00Z", 453 + }, 454 + }, 455 + } as any; 456 + 457 + await indexer.handleModActionCreate(event); 458 + 459 + expect(mockBanEnforcer.applyBan).toHaveBeenCalledWith("did:plc:target123"); 460 + }); 461 + 462 + it("does NOT call applyBan for non-ban actions (e.g. pin)", async () => { 463 + const { BanEnforcer } = await import("../ban-enforcer.js"); 464 + const mockBanEnforcer = vi.mocked(BanEnforcer).mock.results[0].value; 465 + 466 + (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({ 467 + from: vi.fn().mockReturnValue({ 468 + where: vi.fn().mockReturnValue({ 469 + limit: vi.fn().mockResolvedValue([{ id: 1n }]), 470 + }), 471 + }), 472 + }); 473 + 474 + const event = { 475 + did: "did:plc:forum", 476 + time_us: 1234567890, 477 + kind: "commit", 478 + commit: { 479 + rev: "abc", 480 + operation: "create", 481 + collection: "space.atbb.modAction", 482 + rkey: "action2", 483 + cid: "cid124", 484 + record: { 485 + $type: "space.atbb.modAction", 486 + action: "space.atbb.modAction.pin", 487 + subject: { post: { uri: "at://did:plc:user/space.atbb.post/abc", cid: "cid" } }, 488 + createdBy: "did:plc:mod", 489 + createdAt: "2024-01-01T00:00:00Z", 490 + }, 491 + }, 492 + } as any; 493 + 494 + await indexer.handleModActionCreate(event); 495 + 496 + expect(mockBanEnforcer.applyBan).not.toHaveBeenCalled(); 497 + }); 498 + }); 499 + ``` 500 + 501 + ### Step 2: Run tests to confirm they fail 502 + 503 + ```bash 504 + pnpm --filter @atbb/appview test src/lib/__tests__/indexer.test.ts 505 + ``` 506 + 507 + Expected: **FAIL** — `applyBan` is not called yet. 508 + 509 + ### Step 3: Override handleModActionCreate in Indexer 510 + 511 + In `apps/appview/src/lib/indexer.ts`, replace `handleModActionCreate`: 512 + 513 + ```typescript 514 + async handleModActionCreate( 515 + event: CommitCreateEvent<"space.atbb.modAction"> 516 + ) { 517 + await this.genericCreate(this.modActionConfig, event); 518 + 519 + const record = event.commit.record as unknown as ModAction.Record; 520 + if ( 521 + record.action === "space.atbb.modAction.ban" && 522 + record.subject.did 523 + ) { 524 + await this.banEnforcer.applyBan(record.subject.did); 525 + } 526 + } 527 + ``` 528 + 529 + ### Step 4: Run tests to confirm they pass 530 + 531 + ```bash 532 + pnpm --filter @atbb/appview test src/lib/__tests__/indexer.test.ts 533 + ``` 534 + 535 + Expected: **PASS** 536 + 537 + ### Step 5: Commit 538 + 539 + ```bash 540 + git add apps/appview/src/lib/indexer.ts apps/appview/src/lib/__tests__/indexer.test.ts 541 + git commit -m "feat: soft-delete existing posts when ban is indexed (ATB-21)" 542 + ``` 543 + 544 + --- 545 + 546 + ## Task 4: Override handleModActionDelete — unban restoration 547 + 548 + **Files:** 549 + - Modify: `apps/appview/src/lib/indexer.ts` 550 + - Modify: `apps/appview/src/lib/__tests__/indexer.test.ts` 551 + 552 + When a ban record is deleted from the AT Proto repo (unban), restore all soft-deleted posts. This requires reading the record *before* deleting it, all within a transaction. 553 + 554 + ### Step 1: Add failing tests 555 + 556 + Add a new describe block in `indexer.test.ts`: 557 + 558 + ```typescript 559 + describe("Ban enforcement — handleModActionDelete", () => { 560 + it("calls liftBan when a ban record is deleted", async () => { 561 + const { BanEnforcer } = await import("../ban-enforcer.js"); 562 + const mockBanEnforcer = vi.mocked(BanEnforcer).mock.results[0].value; 563 + 564 + // Transaction mock: select returns a ban record, delete succeeds 565 + (mockDb.transaction as ReturnType<typeof vi.fn>).mockImplementation( 566 + async (callback) => { 567 + const tx = { 568 + select: vi.fn().mockReturnValue({ 569 + from: vi.fn().mockReturnValue({ 570 + where: vi.fn().mockReturnValue({ 571 + limit: vi.fn().mockResolvedValue([ 572 + { 573 + action: "space.atbb.modAction.ban", 574 + subjectDid: "did:plc:target123", 575 + }, 576 + ]), 577 + }), 578 + }), 579 + }), 580 + delete: vi.fn().mockReturnValue({ 581 + where: vi.fn().mockResolvedValue(undefined), 582 + }), 583 + }; 584 + return callback(tx); 585 + } 586 + ); 587 + 588 + const event = { 589 + did: "did:plc:forum", 590 + time_us: 1234567890, 591 + kind: "commit", 592 + commit: { 593 + rev: "abc", 594 + operation: "delete", 595 + collection: "space.atbb.modAction", 596 + rkey: "action1", 597 + }, 598 + } as any; 599 + 600 + await indexer.handleModActionDelete(event); 601 + 602 + expect(mockBanEnforcer.liftBan).toHaveBeenCalledWith( 603 + "did:plc:target123", 604 + expect.anything() // the transaction 605 + ); 606 + }); 607 + 608 + it("does NOT call liftBan when a non-ban record is deleted", async () => { 609 + const { BanEnforcer } = await import("../ban-enforcer.js"); 610 + const mockBanEnforcer = vi.mocked(BanEnforcer).mock.results[0].value; 611 + 612 + (mockDb.transaction as ReturnType<typeof vi.fn>).mockImplementation( 613 + async (callback) => { 614 + const tx = { 615 + select: vi.fn().mockReturnValue({ 616 + from: vi.fn().mockReturnValue({ 617 + where: vi.fn().mockReturnValue({ 618 + limit: vi.fn().mockResolvedValue([ 619 + { 620 + action: "space.atbb.modAction.pin", 621 + subjectDid: null, 622 + }, 623 + ]), 624 + }), 625 + }), 626 + }), 627 + delete: vi.fn().mockReturnValue({ 628 + where: vi.fn().mockResolvedValue(undefined), 629 + }), 630 + }; 631 + return callback(tx); 632 + } 633 + ); 634 + 635 + const event = { 636 + did: "did:plc:forum", 637 + time_us: 1234567890, 638 + kind: "commit", 639 + commit: { 640 + rev: "abc", 641 + operation: "delete", 642 + collection: "space.atbb.modAction", 643 + rkey: "action2", 644 + }, 645 + } as any; 646 + 647 + await indexer.handleModActionDelete(event); 648 + 649 + expect(mockBanEnforcer.liftBan).not.toHaveBeenCalled(); 650 + }); 651 + }); 652 + ``` 653 + 654 + ### Step 2: Run tests to confirm they fail 655 + 656 + ```bash 657 + pnpm --filter @atbb/appview test src/lib/__tests__/indexer.test.ts 658 + ``` 659 + 660 + Expected: **FAIL** — `liftBan` is not called yet. 661 + 662 + ### Step 3: Override handleModActionDelete in Indexer 663 + 664 + In `apps/appview/src/lib/indexer.ts`, replace `handleModActionDelete`: 665 + 666 + ```typescript 667 + async handleModActionDelete( 668 + event: CommitDeleteEvent<"space.atbb.modAction"> 669 + ) { 670 + try { 671 + await this.db.transaction(async (tx) => { 672 + // 1. Read before delete to capture action type and subject 673 + const [existing] = await tx 674 + .select({ 675 + action: modActions.action, 676 + subjectDid: modActions.subjectDid, 677 + }) 678 + .from(modActions) 679 + .where( 680 + and( 681 + eq(modActions.did, event.did), 682 + eq(modActions.rkey, event.commit.rkey) 683 + ) 684 + ) 685 + .limit(1); 686 + 687 + // 2. Hard delete the record 688 + await tx 689 + .delete(modActions) 690 + .where( 691 + and( 692 + eq(modActions.did, event.did), 693 + eq(modActions.rkey, event.commit.rkey) 694 + ) 695 + ); 696 + 697 + // 3. Restore posts if the deleted record was a ban 698 + if ( 699 + existing?.action === "space.atbb.modAction.ban" && 700 + existing?.subjectDid 701 + ) { 702 + await this.banEnforcer.liftBan(existing.subjectDid, tx); 703 + } 704 + }); 705 + 706 + console.log( 707 + `[DELETE] ModAction: ${event.did}/${event.commit.rkey}` 708 + ); 709 + } catch (error) { 710 + console.error( 711 + `Failed to delete modAction: ${event.did}/${event.commit.rkey}`, 712 + error 713 + ); 714 + throw error; 715 + } 716 + } 717 + ``` 718 + 719 + ### Step 4: Run tests to confirm they pass 720 + 721 + ```bash 722 + pnpm --filter @atbb/appview test src/lib/__tests__/indexer.test.ts 723 + ``` 724 + 725 + Expected: **PASS** 726 + 727 + ### Step 5: Commit 728 + 729 + ```bash 730 + git add apps/appview/src/lib/indexer.ts apps/appview/src/lib/__tests__/indexer.test.ts 731 + git commit -m "feat: restore posts when ban record is deleted (ATB-21)" 732 + ``` 733 + 734 + --- 735 + 736 + ## Task 5: Add race condition test 737 + 738 + **Files:** 739 + - Modify: `apps/appview/src/lib/__tests__/indexer.test.ts` 740 + 741 + The race condition (post indexed before ban) is handled naturally by `applyBan` soft-deleting after the fact. This test documents and verifies that contract. 742 + 743 + ### Step 1: Add the race condition test 744 + 745 + Add inside the `Ban enforcement — handleModActionCreate` describe block in `indexer.test.ts`: 746 + 747 + ```typescript 748 + it("race condition: post indexed before ban — ban retroactively hides it", async () => { 749 + const { BanEnforcer } = await import("../ban-enforcer.js"); 750 + const mockBanEnforcer = vi.mocked(BanEnforcer).mock.results[0].value; 751 + 752 + // Step 1: Post is indexed before ban arrives (isBanned = false at that moment) 753 + mockBanEnforcer.isBanned.mockResolvedValueOnce(false); 754 + 755 + (mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({ 756 + from: vi.fn().mockReturnValue({ 757 + where: vi.fn().mockReturnValue({ 758 + limit: vi.fn().mockResolvedValue([{ id: 1n }]), 759 + }), 760 + }), 761 + }); 762 + 763 + const postEvent = { 764 + did: "did:plc:target123", 765 + time_us: 1234567890, 766 + kind: "commit", 767 + commit: { 768 + rev: "abc", 769 + operation: "create", 770 + collection: "space.atbb.post", 771 + rkey: "post1", 772 + cid: "cid123", 773 + record: { 774 + $type: "space.atbb.post", 775 + text: "Hello world", 776 + createdAt: "2024-01-01T00:00:00Z", 777 + }, 778 + }, 779 + } as any; 780 + 781 + await indexer.handlePostCreate(postEvent); 782 + expect(mockDb.transaction).toHaveBeenCalled(); // post was indexed 783 + 784 + // Step 2: Ban arrives — applyBan is called, retroactively hides the post 785 + const banEvent = { 786 + did: "did:plc:forum", 787 + time_us: 1234567891, 788 + kind: "commit", 789 + commit: { 790 + rev: "def", 791 + operation: "create", 792 + collection: "space.atbb.modAction", 793 + rkey: "action1", 794 + cid: "cid124", 795 + record: { 796 + $type: "space.atbb.modAction", 797 + action: "space.atbb.modAction.ban", 798 + subject: { did: "did:plc:target123" }, 799 + createdBy: "did:plc:mod", 800 + createdAt: "2024-01-01T00:00:01Z", 801 + }, 802 + }, 803 + } as any; 804 + 805 + await indexer.handleModActionCreate(banEvent); 806 + expect(mockBanEnforcer.applyBan).toHaveBeenCalledWith("did:plc:target123"); 807 + }); 808 + ``` 809 + 810 + ### Step 2: Run tests to confirm they pass 811 + 812 + ```bash 813 + pnpm --filter @atbb/appview test src/lib/__tests__/indexer.test.ts 814 + ``` 815 + 816 + Expected: **PASS** 817 + 818 + ### Step 3: Run full test suite 819 + 820 + ```bash 821 + pnpm --filter @atbb/appview test 822 + ``` 823 + 824 + Expected: **all tests pass** 825 + 826 + ### Step 4: Commit 827 + 828 + ```bash 829 + git add apps/appview/src/lib/__tests__/indexer.test.ts 830 + git commit -m "test: add race condition coverage for firehose ban enforcement (ATB-21)" 831 + ``` 832 + 833 + --- 834 + 835 + ## Task 6: Final verification 836 + 837 + ### Step 1: Build to confirm TypeScript compiles 838 + 839 + ```bash 840 + pnpm build 841 + ``` 842 + 843 + Expected: clean build, no type errors. 844 + 845 + ### Step 2: Run all tests one final time 846 + 847 + ```bash 848 + pnpm test 849 + ``` 850 + 851 + Expected: **all tests pass** 852 + 853 + ### Step 3: Update Linear and plan doc 854 + 855 + - Mark ATB-21 as **Done** in Linear 856 + - Add a comment to ATB-21 summarizing: `BanEnforcer` class, three handler overrides, DB-query-per-post-create with `mod_actions_subject_did_idx` 857 + - Mark the plan doc checklist item in `docs/atproto-forum-plan.md`