ATB-21: Firehose Ban Enforcement Implementation Plan#
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Enforce bans in the firehose indexer — skip new posts from banned users, soft-delete existing posts on ban, restore them on unban.
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).
Tech Stack: TypeScript, Drizzle ORM, Vitest, Hono (no new deps needed)
Design doc: docs/plans/2026-02-16-atb21-firehose-ban-enforcement-design.md
Environment Setup#
Run all commands from the repo root inside a devenv shell:
devenv shell
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 ....
Run tests with:
pnpm --filter @atbb/appview test
Task 1: Create BanEnforcer with unit tests#
Files:
- Create:
apps/appview/src/lib/ban-enforcer.ts - Create:
apps/appview/src/lib/__tests__/indexer-ban-enforcer.test.ts
Step 1: Write the failing tests#
Create apps/appview/src/lib/__tests__/indexer-ban-enforcer.test.ts:
import { describe, it, expect, beforeEach, vi } from "vitest";
import { BanEnforcer } from "../ban-enforcer.js";
import type { Database } from "@atbb/db";
const createMockDb = () => {
const mockSelect = vi.fn();
const mockUpdate = vi.fn();
return {
select: mockSelect,
update: mockUpdate,
} as unknown as Database;
};
describe("BanEnforcer", () => {
let mockDb: Database;
let enforcer: BanEnforcer;
beforeEach(() => {
vi.clearAllMocks();
mockDb = createMockDb();
enforcer = new BanEnforcer(mockDb);
});
describe("isBanned", () => {
it("returns true when an active ban exists (no expiry)", async () => {
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: 1n }]),
}),
}),
});
expect(await enforcer.isBanned("did:plc:banned123")).toBe(true);
});
it("returns false when no ban exists", async () => {
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
});
expect(await enforcer.isBanned("did:plc:user123")).toBe(false);
});
it("returns false when only an expired ban exists", async () => {
// The SQL query filters out expired bans, so the DB returns empty
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
});
expect(await enforcer.isBanned("did:plc:user123")).toBe(false);
});
it("returns true (fail closed) when DB throws", async () => {
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockRejectedValue(new Error("DB connection lost")),
}),
}),
});
expect(await enforcer.isBanned("did:plc:user123")).toBe(true);
});
});
describe("applyBan", () => {
it("soft-deletes all posts for the subject DID", async () => {
const mockWhere = vi.fn().mockResolvedValue(undefined);
const mockSet = vi.fn().mockReturnValue({ where: mockWhere });
(mockDb.update as ReturnType<typeof vi.fn>).mockReturnValue({ set: mockSet });
await enforcer.applyBan("did:plc:banned123");
expect(mockSet).toHaveBeenCalledWith({ deleted: true });
expect(mockWhere).toHaveBeenCalled();
});
});
describe("liftBan", () => {
it("restores all posts for the subject DID", async () => {
const mockWhere = vi.fn().mockResolvedValue(undefined);
const mockSet = vi.fn().mockReturnValue({ where: mockWhere });
(mockDb.update as ReturnType<typeof vi.fn>).mockReturnValue({ set: mockSet });
await enforcer.liftBan("did:plc:unbanned123");
expect(mockSet).toHaveBeenCalledWith({ deleted: false });
expect(mockWhere).toHaveBeenCalled();
});
});
});
Step 2: Run tests to confirm they fail#
pnpm --filter @atbb/appview test src/lib/__tests__/indexer-ban-enforcer.test.ts
Expected: FAIL — Cannot find module '../ban-enforcer.js'
Step 3: Implement BanEnforcer#
Create apps/appview/src/lib/ban-enforcer.ts:
import type { DbOrTransaction } from "@atbb/db";
import { modActions, posts } from "@atbb/db";
import { and, eq, gt, isNull, or } from "drizzle-orm";
/**
* Encapsulates ban enforcement logic for the firehose indexer.
*
* Used by the Indexer to:
* - Check ban status before indexing posts (fail closed)
* - Soft-delete existing posts when a ban is applied
* - Restore posts when a ban is lifted
*/
export class BanEnforcer {
constructor(private db: DbOrTransaction) {}
/**
* Returns true if the DID has an active (non-expired) ban.
* Fails closed: returns true if the DB query throws.
*/
async isBanned(did: string, dbOrTx: DbOrTransaction = this.db): Promise<boolean> {
try {
const now = new Date();
const result = await dbOrTx
.select({ id: modActions.id })
.from(modActions)
.where(
and(
eq(modActions.subjectDid, did),
eq(modActions.action, "space.atbb.modAction.ban"),
or(isNull(modActions.expiresAt), gt(modActions.expiresAt, now))
)
)
.limit(1);
return result.length > 0;
} catch (error) {
console.error(
"Failed to check ban status - denying indexing (fail closed)",
{
did,
error: error instanceof Error ? error.message : String(error),
}
);
return true; // fail closed
}
}
/**
* Soft-deletes all posts for the given DID.
* Called when a ban mod action is indexed.
*/
async applyBan(subjectDid: string, dbOrTx: DbOrTransaction = this.db): Promise<void> {
await dbOrTx
.update(posts)
.set({ deleted: true })
.where(eq(posts.did, subjectDid));
console.log(
`[BAN] Applied ban: soft-deleted all posts for ${subjectDid}`
);
}
/**
* Restores all posts for the given DID.
* Called when a ban mod action record is deleted (unban).
*/
async liftBan(subjectDid: string, dbOrTx: DbOrTransaction = this.db): Promise<void> {
await dbOrTx
.update(posts)
.set({ deleted: false })
.where(eq(posts.did, subjectDid));
console.log(
`[UNBAN] Lifted ban: restored all posts for ${subjectDid}`
);
}
}
Step 4: Run tests to confirm they pass#
pnpm --filter @atbb/appview test src/lib/__tests__/indexer-ban-enforcer.test.ts
Expected: PASS — 7 tests
Step 5: Commit#
git add apps/appview/src/lib/ban-enforcer.ts apps/appview/src/lib/__tests__/indexer-ban-enforcer.test.ts
git commit -m "feat: add BanEnforcer class for firehose ban enforcement (ATB-21)"
Task 2: Override handlePostCreate in Indexer#
Files:
- Modify:
apps/appview/src/lib/indexer.ts - Modify:
apps/appview/src/lib/__tests__/indexer.test.ts
The Indexer must compose BanEnforcer and skip indexing posts from banned users.
Step 1: Mock BanEnforcer in the indexer test file and add failing tests#
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):
// Add after existing vi.mock calls at the top of the file
vi.mock("../ban-enforcer.js", () => ({
BanEnforcer: vi.fn().mockImplementation(() => ({
isBanned: vi.fn().mockResolvedValue(false),
applyBan: vi.fn().mockResolvedValue(undefined),
liftBan: vi.fn().mockResolvedValue(undefined),
})),
}));
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:
describe("Ban enforcement — handlePostCreate", () => {
it("skips indexing when the user is banned", async () => {
const { BanEnforcer } = await import("../ban-enforcer.js");
const mockBanEnforcer = vi.mocked(BanEnforcer).mock.results[0].value;
mockBanEnforcer.isBanned.mockResolvedValue(true);
const event = {
did: "did:plc:banned123",
time_us: 1234567890,
kind: "commit",
commit: {
rev: "abc",
operation: "create",
collection: "space.atbb.post",
rkey: "post1",
cid: "cid123",
record: {
$type: "space.atbb.post",
text: "Hello world",
createdAt: "2024-01-01T00:00:00Z",
},
},
} as any;
await indexer.handlePostCreate(event);
// The DB insert should NOT have been called
expect(mockDb.insert).not.toHaveBeenCalled();
});
it("indexes the post normally when the user is not banned", async () => {
const { BanEnforcer } = await import("../ban-enforcer.js");
const mockBanEnforcer = vi.mocked(BanEnforcer).mock.results[0].value;
mockBanEnforcer.isBanned.mockResolvedValue(false);
// Set up select to return a user (ensureUser) and no parent/root posts
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ did: "did:plc:user123" }]),
}),
}),
});
const event = {
did: "did:plc:user123",
time_us: 1234567890,
kind: "commit",
commit: {
rev: "abc",
operation: "create",
collection: "space.atbb.post",
rkey: "post1",
cid: "cid123",
record: {
$type: "space.atbb.post",
text: "Hello world",
createdAt: "2024-01-01T00:00:00Z",
},
},
} as any;
await indexer.handlePostCreate(event);
expect(mockDb.transaction).toHaveBeenCalled();
});
});
Step 2: Run tests to confirm they fail#
pnpm --filter @atbb/appview test src/lib/__tests__/indexer.test.ts
Expected: FAIL — the "skips indexing" test fails because handlePostCreate doesn't check ban status yet.
Step 3: Modify Indexer to compose BanEnforcer and override handlePostCreate#
In apps/appview/src/lib/indexer.ts:
Add import (alongside existing imports at the top):
import { BanEnforcer } from "./ban-enforcer.js";
Add field (in the class body, alongside the collection configs):
private banEnforcer: BanEnforcer;
Update constructor:
constructor(private db: Database) {
this.banEnforcer = new BanEnforcer(db);
}
Replace handlePostCreate:
async handlePostCreate(event: CommitCreateEvent<"space.atbb.post">) {
const banned = await this.banEnforcer.isBanned(event.did);
if (banned) {
console.log(
`[SKIP] Post from banned user: ${event.did}/${event.commit.rkey}`
);
return;
}
await this.genericCreate(this.postConfig, event);
}
Step 4: Run tests to confirm they pass#
pnpm --filter @atbb/appview test src/lib/__tests__/indexer.test.ts
Expected: PASS
Step 5: Commit#
git add apps/appview/src/lib/indexer.ts apps/appview/src/lib/__tests__/indexer.test.ts
git commit -m "feat: skip indexing posts from banned users in firehose (ATB-21)"
Task 3: Override handleModActionCreate — retroactive ban enforcement#
Files:
- Modify:
apps/appview/src/lib/indexer.ts - Modify:
apps/appview/src/lib/__tests__/indexer.test.ts
When a ban mod action is indexed, all existing posts from the subject DID must be soft-deleted.
Step 1: Add failing tests#
Add a new describe block in indexer.test.ts:
describe("Ban enforcement — handleModActionCreate", () => {
it("calls applyBan when a ban mod action is created", async () => {
const { BanEnforcer } = await import("../ban-enforcer.js");
const mockBanEnforcer = vi.mocked(BanEnforcer).mock.results[0].value;
// Set up select to return a forum (getForumIdByDid)
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: 1n }]),
}),
}),
});
const event = {
did: "did:plc:forum",
time_us: 1234567890,
kind: "commit",
commit: {
rev: "abc",
operation: "create",
collection: "space.atbb.modAction",
rkey: "action1",
cid: "cid123",
record: {
$type: "space.atbb.modAction",
action: "space.atbb.modAction.ban",
subject: { did: "did:plc:target123" },
createdBy: "did:plc:mod",
createdAt: "2024-01-01T00:00:00Z",
},
},
} as any;
await indexer.handleModActionCreate(event);
expect(mockBanEnforcer.applyBan).toHaveBeenCalledWith("did:plc:target123");
});
it("does NOT call applyBan for non-ban actions (e.g. pin)", async () => {
const { BanEnforcer } = await import("../ban-enforcer.js");
const mockBanEnforcer = vi.mocked(BanEnforcer).mock.results[0].value;
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: 1n }]),
}),
}),
});
const event = {
did: "did:plc:forum",
time_us: 1234567890,
kind: "commit",
commit: {
rev: "abc",
operation: "create",
collection: "space.atbb.modAction",
rkey: "action2",
cid: "cid124",
record: {
$type: "space.atbb.modAction",
action: "space.atbb.modAction.pin",
subject: { post: { uri: "at://did:plc:user/space.atbb.post/abc", cid: "cid" } },
createdBy: "did:plc:mod",
createdAt: "2024-01-01T00:00:00Z",
},
},
} as any;
await indexer.handleModActionCreate(event);
expect(mockBanEnforcer.applyBan).not.toHaveBeenCalled();
});
});
Step 2: Run tests to confirm they fail#
pnpm --filter @atbb/appview test src/lib/__tests__/indexer.test.ts
Expected: FAIL — applyBan is not called yet.
Step 3: Override handleModActionCreate in Indexer#
In apps/appview/src/lib/indexer.ts, replace handleModActionCreate:
async handleModActionCreate(
event: CommitCreateEvent<"space.atbb.modAction">
) {
await this.genericCreate(this.modActionConfig, event);
const record = event.commit.record as unknown as ModAction.Record;
if (
record.action === "space.atbb.modAction.ban" &&
record.subject.did
) {
await this.banEnforcer.applyBan(record.subject.did);
}
}
Step 4: Run tests to confirm they pass#
pnpm --filter @atbb/appview test src/lib/__tests__/indexer.test.ts
Expected: PASS
Step 5: Commit#
git add apps/appview/src/lib/indexer.ts apps/appview/src/lib/__tests__/indexer.test.ts
git commit -m "feat: soft-delete existing posts when ban is indexed (ATB-21)"
Task 4: Override handleModActionDelete — unban restoration#
Files:
- Modify:
apps/appview/src/lib/indexer.ts - Modify:
apps/appview/src/lib/__tests__/indexer.test.ts
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.
Step 1: Add failing tests#
Add a new describe block in indexer.test.ts:
describe("Ban enforcement — handleModActionDelete", () => {
it("calls liftBan when a ban record is deleted", async () => {
const { BanEnforcer } = await import("../ban-enforcer.js");
const mockBanEnforcer = vi.mocked(BanEnforcer).mock.results[0].value;
// Transaction mock: select returns a ban record, delete succeeds
(mockDb.transaction as ReturnType<typeof vi.fn>).mockImplementation(
async (callback) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([
{
action: "space.atbb.modAction.ban",
subjectDid: "did:plc:target123",
},
]),
}),
}),
}),
delete: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(undefined),
}),
};
return callback(tx);
}
);
const event = {
did: "did:plc:forum",
time_us: 1234567890,
kind: "commit",
commit: {
rev: "abc",
operation: "delete",
collection: "space.atbb.modAction",
rkey: "action1",
},
} as any;
await indexer.handleModActionDelete(event);
expect(mockBanEnforcer.liftBan).toHaveBeenCalledWith(
"did:plc:target123",
expect.anything() // the transaction
);
});
it("does NOT call liftBan when a non-ban record is deleted", async () => {
const { BanEnforcer } = await import("../ban-enforcer.js");
const mockBanEnforcer = vi.mocked(BanEnforcer).mock.results[0].value;
(mockDb.transaction as ReturnType<typeof vi.fn>).mockImplementation(
async (callback) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([
{
action: "space.atbb.modAction.pin",
subjectDid: null,
},
]),
}),
}),
}),
delete: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(undefined),
}),
};
return callback(tx);
}
);
const event = {
did: "did:plc:forum",
time_us: 1234567890,
kind: "commit",
commit: {
rev: "abc",
operation: "delete",
collection: "space.atbb.modAction",
rkey: "action2",
},
} as any;
await indexer.handleModActionDelete(event);
expect(mockBanEnforcer.liftBan).not.toHaveBeenCalled();
});
});
Step 2: Run tests to confirm they fail#
pnpm --filter @atbb/appview test src/lib/__tests__/indexer.test.ts
Expected: FAIL — liftBan is not called yet.
Step 3: Override handleModActionDelete in Indexer#
In apps/appview/src/lib/indexer.ts, replace handleModActionDelete:
async handleModActionDelete(
event: CommitDeleteEvent<"space.atbb.modAction">
) {
try {
await this.db.transaction(async (tx) => {
// 1. Read before delete to capture action type and subject
const [existing] = await tx
.select({
action: modActions.action,
subjectDid: modActions.subjectDid,
})
.from(modActions)
.where(
and(
eq(modActions.did, event.did),
eq(modActions.rkey, event.commit.rkey)
)
)
.limit(1);
// 2. Hard delete the record
await tx
.delete(modActions)
.where(
and(
eq(modActions.did, event.did),
eq(modActions.rkey, event.commit.rkey)
)
);
// 3. Restore posts if the deleted record was a ban
if (
existing?.action === "space.atbb.modAction.ban" &&
existing?.subjectDid
) {
await this.banEnforcer.liftBan(existing.subjectDid, tx);
}
});
console.log(
`[DELETE] ModAction: ${event.did}/${event.commit.rkey}`
);
} catch (error) {
console.error(
`Failed to delete modAction: ${event.did}/${event.commit.rkey}`,
error
);
throw error;
}
}
Step 4: Run tests to confirm they pass#
pnpm --filter @atbb/appview test src/lib/__tests__/indexer.test.ts
Expected: PASS
Step 5: Commit#
git add apps/appview/src/lib/indexer.ts apps/appview/src/lib/__tests__/indexer.test.ts
git commit -m "feat: restore posts when ban record is deleted (ATB-21)"
Task 5: Add race condition test#
Files:
- Modify:
apps/appview/src/lib/__tests__/indexer.test.ts
The race condition (post indexed before ban) is handled naturally by applyBan soft-deleting after the fact. This test documents and verifies that contract.
Step 1: Add the race condition test#
Add inside the Ban enforcement — handleModActionCreate describe block in indexer.test.ts:
it("race condition: post indexed before ban — ban retroactively hides it", async () => {
const { BanEnforcer } = await import("../ban-enforcer.js");
const mockBanEnforcer = vi.mocked(BanEnforcer).mock.results[0].value;
// Step 1: Post is indexed before ban arrives (isBanned = false at that moment)
mockBanEnforcer.isBanned.mockResolvedValueOnce(false);
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: 1n }]),
}),
}),
});
const postEvent = {
did: "did:plc:target123",
time_us: 1234567890,
kind: "commit",
commit: {
rev: "abc",
operation: "create",
collection: "space.atbb.post",
rkey: "post1",
cid: "cid123",
record: {
$type: "space.atbb.post",
text: "Hello world",
createdAt: "2024-01-01T00:00:00Z",
},
},
} as any;
await indexer.handlePostCreate(postEvent);
expect(mockDb.transaction).toHaveBeenCalled(); // post was indexed
// Step 2: Ban arrives — applyBan is called, retroactively hides the post
const banEvent = {
did: "did:plc:forum",
time_us: 1234567891,
kind: "commit",
commit: {
rev: "def",
operation: "create",
collection: "space.atbb.modAction",
rkey: "action1",
cid: "cid124",
record: {
$type: "space.atbb.modAction",
action: "space.atbb.modAction.ban",
subject: { did: "did:plc:target123" },
createdBy: "did:plc:mod",
createdAt: "2024-01-01T00:00:01Z",
},
},
} as any;
await indexer.handleModActionCreate(banEvent);
expect(mockBanEnforcer.applyBan).toHaveBeenCalledWith("did:plc:target123");
});
Step 2: Run tests to confirm they pass#
pnpm --filter @atbb/appview test src/lib/__tests__/indexer.test.ts
Expected: PASS
Step 3: Run full test suite#
pnpm --filter @atbb/appview test
Expected: all tests pass
Step 4: Commit#
git add apps/appview/src/lib/__tests__/indexer.test.ts
git commit -m "test: add race condition coverage for firehose ban enforcement (ATB-21)"
Task 6: Final verification#
Step 1: Build to confirm TypeScript compiles#
pnpm build
Expected: clean build, no type errors.
Step 2: Run all tests one final time#
pnpm test
Expected: all tests pass
Step 3: Update Linear and plan doc#
- Mark ATB-21 as Done in Linear
- Add a comment to ATB-21 summarizing:
BanEnforcerclass, three handler overrides, DB-query-per-post-create withmod_actions_subject_did_idx - Mark the plan doc checklist item in
docs/atproto-forum-plan.md