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
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

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: FAILCannot 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: FAILapplyBan 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: FAILliftBan 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: BanEnforcer class, three handler overrides, DB-query-per-post-create with mod_actions_subject_did_idx
  • Mark the plan doc checklist item in docs/atproto-forum-plan.md