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.

Board Management Endpoints Implementation Plan (ATB-45)#

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add POST /api/admin/boards, PUT /api/admin/boards/:id, and DELETE /api/admin/boards/:id endpoints to the AppView, following the same PDS-first write pattern as category management.

Architecture: All mutations write to the Forum DID's PDS via ForumAgent.putRecord()/deleteRecord(); the firehose indexer handles DB updates asynchronously. The only synchronous DB read is looking up the category CID on create (needed to build the categoryRef strongRef required by the lexicon). Delete pre-flights against posts.boardId to refuse with 409 if posts exist.

Tech Stack: Hono, Drizzle ORM (postgres.js), @atproto/common-web (TID generation), Vitest, Bruno.


Key files to understand before starting#

  • Implementation: apps/appview/src/routes/admin.ts — add the 3 new endpoints at the bottom (before return app)
  • Tests: apps/appview/src/routes/__tests__/admin.test.ts — add a new describe.sequential("POST /api/admin/boards"...) block after the categories tests (around line 1450)
  • DB schema: packages/db/src/schema.tsboards table has id, did, rkey, cid, name, description, slug, sortOrder, categoryId, categoryUri, createdAt, indexedAt
  • Lexicon: packages/lexicon/lexicons/space/atbb/forum/board.yaml — record has name, description, slug, sortOrder, category (a categoryRef object: { category: { uri, cid } })
  • Route helpers: apps/appview/src/lib/route-errors.tshandleRouteError, safeParseJsonBody, getForumAgentOrError
  • Bruno templates: bruno/AppView API/Admin/Create Category.bru, Update Category.bru, Delete Category.bru — copy as starting point

Test setup pattern (copy from categories tests)#

The test file already has mocks at the top for requireAuth, requirePermission, mockPutRecord, and mockDeleteRecord. The board tests reuse these same mocks. No new setup needed — just add new describe.sequential blocks after the existing categories describe blocks.

For tests that need a category in the DB (create + edit lookups), insert with:

const [cat] = await ctx.db.insert(categories).values({
  did: ctx.config.forumDid,
  rkey: "tid-test-cat",
  cid: "bafycat",
  name: "Test Category",
  createdAt: new Date("2026-01-01T00:00:00.000Z"),
  indexedAt: new Date(),
}).returning({ id: categories.id });
const categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`;

For tests that need a board in the DB (edit + delete), insert with:

const [brd] = await ctx.db.insert(boards).values({
  did: ctx.config.forumDid,
  rkey: "tid-test-board",
  cid: "bafyboard",
  name: "Original Name",
  description: "Original description",
  sortOrder: 1,
  categoryId: cat.id,
  categoryUri,
  createdAt: new Date("2026-01-01T00:00:00.000Z"),
  indexedAt: new Date(),
}).returning({ id: boards.id });
const boardId = brd.id.toString();

Task 1: POST /api/admin/boards — failing tests#

Files:

  • Modify: apps/appview/src/routes/__tests__/admin.test.ts (append after line ~1450, after the closing }); of the DELETE categories describe block)

Step 1: Write the failing tests

Add this new describe block to the end of the describe.sequential("Admin Routes", ...) block (just before the outer closing });):

describe.sequential("POST /api/admin/boards", () => {
  let categoryUri: string;

  beforeEach(async () => {
    await ctx.cleanDatabase();

    mockUser = { did: "did:plc:test-admin" };
    mockPutRecord.mockClear();
    mockDeleteRecord.mockClear();
    mockPutRecord.mockResolvedValue({
      data: {
        uri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid123`,
        cid: "bafyboard",
      },
    });

    // Insert a category the tests can reference
    await ctx.db.insert(categories).values({
      did: ctx.config.forumDid,
      rkey: "tid-test-cat",
      cid: "bafycat",
      name: "Test Category",
      createdAt: new Date("2026-01-01T00:00:00.000Z"),
      indexedAt: new Date(),
    });
    categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`;
  });

  it("creates board with valid body → 201 and putRecord called with categoryRef", async () => {
    const res = await app.request("/api/admin/boards", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "General Chat", description: "Talk here.", sortOrder: 1, categoryUri }),
    });

    expect(res.status).toBe(201);
    const data = await res.json();
    expect(data.uri).toContain("/space.atbb.forum.board/");
    expect(data.cid).toBe("bafyboard");
    expect(mockPutRecord).toHaveBeenCalledWith(
      expect.objectContaining({
        repo: ctx.config.forumDid,
        collection: "space.atbb.forum.board",
        rkey: expect.any(String),
        record: expect.objectContaining({
          $type: "space.atbb.forum.board",
          name: "General Chat",
          description: "Talk here.",
          sortOrder: 1,
          category: { category: { uri: categoryUri, cid: "bafycat" } },
          createdAt: expect.any(String),
        }),
      })
    );
  });

  it("creates board without optional fields → 201", async () => {
    const res = await app.request("/api/admin/boards", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Minimal", categoryUri }),
    });

    expect(res.status).toBe(201);
    expect(mockPutRecord).toHaveBeenCalledWith(
      expect.objectContaining({
        record: expect.objectContaining({ name: "Minimal" }),
      })
    );
  });

  it("returns 400 when name is missing → no PDS write", async () => {
    const res = await app.request("/api/admin/boards", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ categoryUri }),
    });

    expect(res.status).toBe(400);
    const data = await res.json();
    expect(data.error).toContain("name");
    expect(mockPutRecord).not.toHaveBeenCalled();
  });

  it("returns 400 when name is empty string → no PDS write", async () => {
    const res = await app.request("/api/admin/boards", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "   ", categoryUri }),
    });

    expect(res.status).toBe(400);
    expect(mockPutRecord).not.toHaveBeenCalled();
  });

  it("returns 400 when categoryUri is missing → no PDS write", async () => {
    const res = await app.request("/api/admin/boards", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Test Board" }),
    });

    expect(res.status).toBe(400);
    const data = await res.json();
    expect(data.error).toContain("categoryUri");
    expect(mockPutRecord).not.toHaveBeenCalled();
  });

  it("returns 404 when categoryUri references unknown category → no PDS write", async () => {
    const res = await app.request("/api/admin/boards", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Test Board", categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/unknown999` }),
    });

    expect(res.status).toBe(404);
    const data = await res.json();
    expect(data.error).toContain("Category not found");
    expect(mockPutRecord).not.toHaveBeenCalled();
  });

  it("returns 400 for malformed JSON", async () => {
    const res = await app.request("/api/admin/boards", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: "{ bad json }",
    });

    expect(res.status).toBe(400);
    const data = await res.json();
    expect(data.error).toContain("Invalid JSON");
    expect(mockPutRecord).not.toHaveBeenCalled();
  });

  it("returns 401 when unauthenticated → no PDS write", async () => {
    mockUser = null;

    const res = await app.request("/api/admin/boards", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Test", categoryUri }),
    });

    expect(res.status).toBe(401);
    expect(mockPutRecord).not.toHaveBeenCalled();
  });

  it("returns 503 when PDS network error", async () => {
    mockPutRecord.mockRejectedValue(new Error("fetch failed"));

    const res = await app.request("/api/admin/boards", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Test", categoryUri }),
    });

    expect(res.status).toBe(503);
    const data = await res.json();
    expect(data.error).toContain("Unable to reach external service");
    expect(mockPutRecord).toHaveBeenCalled();
  });

  it("returns 500 when ForumAgent unavailable", async () => {
    ctx.forumAgent = null;

    const res = await app.request("/api/admin/boards", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Test", categoryUri }),
    });

    expect(res.status).toBe(500);
    const data = await res.json();
    expect(data.error).toContain("Forum agent not available");
  });

  it("returns 403 when user lacks manageCategories permission", async () => {
    const { requirePermission } = await import("../../middleware/permissions.js");
    const mockRequirePermission = requirePermission as any;
    mockRequirePermission.mockImplementation(() => async (c: any) => {
      return c.json({ error: "Forbidden" }, 403);
    });

    const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx));
    const res = await testApp.request("/api/admin/boards", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Test", categoryUri }),
    });

    expect(res.status).toBe(403);
    expect(mockPutRecord).not.toHaveBeenCalled();

    mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
      await next();
    });
  });
});

Step 2: Run tests to verify they fail

export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH
cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo
pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | grep -E "FAIL|PASS|Error|✓|×|POST.*boards" | head -30

Expected: Tests fail with "Cannot find route" or similar.

Step 3: Commit the failing tests

git add apps/appview/src/routes/__tests__/admin.test.ts
git commit -m "test(appview): add failing tests for POST /api/admin/boards (ATB-45)"

Task 2: POST /api/admin/boards — implementation#

Files:

  • Modify: apps/appview/src/routes/admin.ts (add before return app; at the end, around line 701)

Step 1: Add the POST /api/admin/boards endpoint

Insert the following handler before the return app; line:

/**
 * POST /api/admin/boards
 *
 * Create a new forum board within a category. Fetches the category's CID from DB
 * to build the categoryRef strongRef required by the lexicon. Writes
 * space.atbb.forum.board to the Forum DID's PDS via putRecord.
 * The firehose indexer creates the DB row asynchronously.
 */
app.post(
  "/boards",
  requireAuth(ctx),
  requirePermission(ctx, "space.atbb.permission.manageCategories"),
  async (c) => {
    const { body, error: parseError } = await safeParseJsonBody(c);
    if (parseError) return parseError;

    const { name, description, sortOrder, categoryUri } = body;

    if (typeof name !== "string" || name.trim().length === 0) {
      return c.json({ error: "name is required and must be a non-empty string" }, 400);
    }

    if (typeof categoryUri !== "string" || !categoryUri.startsWith("at://")) {
      return c.json({ error: "categoryUri is required and must be a valid AT URI" }, 400);
    }

    // Derive rkey from the categoryUri to look up the category in the DB
    const categoryRkey = categoryUri.split("/").pop();

    let category: typeof categories.$inferSelect;
    try {
      const [row] = await ctx.db
        .select()
        .from(categories)
        .where(
          and(
            eq(categories.did, ctx.config.forumDid),
            eq(categories.rkey, categoryRkey ?? "")
          )
        )
        .limit(1);

      if (!row) {
        return c.json({ error: "Category not found" }, 404);
      }
      category = row;
    } catch (error) {
      return handleRouteError(c, error, "Failed to look up category", {
        operation: "POST /api/admin/boards",
        logger: ctx.logger,
        categoryUri,
      });
    }

    const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/boards");
    if (agentError) return agentError;

    const rkey = TID.nextStr();
    const now = new Date().toISOString();

    try {
      const result = await agent.com.atproto.repo.putRecord({
        repo: ctx.config.forumDid,
        collection: "space.atbb.forum.board",
        rkey,
        record: {
          $type: "space.atbb.forum.board",
          name: name.trim(),
          ...(typeof description === "string" && { description: description.trim() }),
          ...(Number.isInteger(sortOrder) && sortOrder >= 0 && { sortOrder }),
          category: { category: { uri: categoryUri, cid: category.cid } },
          createdAt: now,
        },
      });

      return c.json({ uri: result.data.uri, cid: result.data.cid }, 201);
    } catch (error) {
      return handleRouteError(c, error, "Failed to create board", {
        operation: "POST /api/admin/boards",
        logger: ctx.logger,
        categoryUri,
      });
    }
  }
);

Step 2: Run tests to verify they pass

export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH
cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo
pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | grep -E "FAIL|PASS|POST.*boards|✓|×" | head -30

Expected: All POST /api/admin/boards tests PASS.

Step 3: Commit

git add apps/appview/src/routes/admin.ts
git commit -m "feat(appview): POST /api/admin/boards create endpoint (ATB-45)"

Task 3: PUT /api/admin/boards/:id — failing tests#

Files:

  • Modify: apps/appview/src/routes/__tests__/admin.test.ts (append after the POST boards describe block)

Step 1: Write the failing tests

describe.sequential("PUT /api/admin/boards/:id", () => {
  let boardId: string;
  let categoryUri: string;

  beforeEach(async () => {
    await ctx.cleanDatabase();

    mockUser = { did: "did:plc:test-admin" };
    mockPutRecord.mockClear();
    mockDeleteRecord.mockClear();
    mockPutRecord.mockResolvedValue({
      data: {
        uri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid-test-board`,
        cid: "bafyboardupdated",
      },
    });

    // Insert a category and a board
    const [cat] = await ctx.db.insert(categories).values({
      did: ctx.config.forumDid,
      rkey: "tid-test-cat",
      cid: "bafycat",
      name: "Test Category",
      createdAt: new Date("2026-01-01T00:00:00.000Z"),
      indexedAt: new Date(),
    }).returning({ id: categories.id });

    categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`;

    const [brd] = await ctx.db.insert(boards).values({
      did: ctx.config.forumDid,
      rkey: "tid-test-board",
      cid: "bafyboard",
      name: "Original Name",
      description: "Original description",
      sortOrder: 1,
      categoryId: cat.id,
      categoryUri,
      createdAt: new Date("2026-01-01T00:00:00.000Z"),
      indexedAt: new Date(),
    }).returning({ id: boards.id });

    boardId = brd.id.toString();
  });

  it("updates board with all fields → 200 and putRecord called with same rkey", async () => {
    const res = await app.request(`/api/admin/boards/${boardId}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Renamed Board", description: "New description", sortOrder: 2 }),
    });

    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.uri).toContain("/space.atbb.forum.board/");
    expect(data.cid).toBe("bafyboardupdated");
    expect(mockPutRecord).toHaveBeenCalledWith(
      expect.objectContaining({
        repo: ctx.config.forumDid,
        collection: "space.atbb.forum.board",
        rkey: "tid-test-board",
        record: expect.objectContaining({
          $type: "space.atbb.forum.board",
          name: "Renamed Board",
          description: "New description",
          sortOrder: 2,
          category: { category: { uri: categoryUri, cid: "bafycat" } },
        }),
      })
    );
  });

  it("updates board without optional fields → falls back to existing values", async () => {
    const res = await app.request(`/api/admin/boards/${boardId}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Renamed Only" }),
    });

    expect(res.status).toBe(200);
    expect(mockPutRecord).toHaveBeenCalledWith(
      expect.objectContaining({
        record: expect.objectContaining({
          name: "Renamed Only",
          description: "Original description",
          sortOrder: 1,
        }),
      })
    );
  });

  it("returns 400 when name is missing", async () => {
    const res = await app.request(`/api/admin/boards/${boardId}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ description: "No name" }),
    });

    expect(res.status).toBe(400);
    const data = await res.json();
    expect(data.error).toContain("name");
    expect(mockPutRecord).not.toHaveBeenCalled();
  });

  it("returns 400 when name is empty string", async () => {
    const res = await app.request(`/api/admin/boards/${boardId}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "   " }),
    });

    expect(res.status).toBe(400);
    expect(mockPutRecord).not.toHaveBeenCalled();
  });

  it("returns 400 for non-numeric ID", async () => {
    const res = await app.request("/api/admin/boards/not-a-number", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Test" }),
    });

    expect(res.status).toBe(400);
    expect(mockPutRecord).not.toHaveBeenCalled();
  });

  it("returns 404 when board not found", async () => {
    const res = await app.request("/api/admin/boards/99999", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Test" }),
    });

    expect(res.status).toBe(404);
    const data = await res.json();
    expect(data.error).toContain("Board not found");
    expect(mockPutRecord).not.toHaveBeenCalled();
  });

  it("returns 400 for malformed JSON", async () => {
    const res = await app.request(`/api/admin/boards/${boardId}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: "{ bad json }",
    });

    expect(res.status).toBe(400);
    expect(mockPutRecord).not.toHaveBeenCalled();
  });

  it("returns 401 when unauthenticated", async () => {
    mockUser = null;

    const res = await app.request(`/api/admin/boards/${boardId}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Test" }),
    });

    expect(res.status).toBe(401);
    expect(mockPutRecord).not.toHaveBeenCalled();
  });

  it("returns 503 when PDS network error", async () => {
    mockPutRecord.mockRejectedValue(new Error("fetch failed"));

    const res = await app.request(`/api/admin/boards/${boardId}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Test" }),
    });

    expect(res.status).toBe(503);
    const data = await res.json();
    expect(data.error).toContain("Unable to reach external service");
  });

  it("returns 500 when ForumAgent unavailable", async () => {
    ctx.forumAgent = null;

    const res = await app.request(`/api/admin/boards/${boardId}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Test" }),
    });

    expect(res.status).toBe(500);
    const data = await res.json();
    expect(data.error).toContain("Forum agent not available");
  });

  it("returns 403 when user lacks manageCategories permission", async () => {
    const { requirePermission } = await import("../../middleware/permissions.js");
    const mockRequirePermission = requirePermission as any;
    mockRequirePermission.mockImplementation(() => async (c: any) => {
      return c.json({ error: "Forbidden" }, 403);
    });

    const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx));
    const res = await testApp.request(`/api/admin/boards/${boardId}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Test" }),
    });

    expect(res.status).toBe(403);
    expect(mockPutRecord).not.toHaveBeenCalled();

    mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
      await next();
    });
  });
});

Step 2: Run tests to verify they fail

export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH
cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo
pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | grep -E "FAIL|PUT.*boards|×" | head -20

Expected: PUT /api/admin/boards tests FAIL.

Step 3: Commit

git add apps/appview/src/routes/__tests__/admin.test.ts
git commit -m "test(appview): add failing tests for PUT /api/admin/boards/:id (ATB-45)"

Task 4: PUT /api/admin/boards/:id — implementation#

Files:

  • Modify: apps/appview/src/routes/admin.ts (add after the POST /boards handler, before return app;)

Step 1: Add the PUT /api/admin/boards/:id endpoint

Note: The boards table stores categoryUri and categoryId. The edit endpoint re-uses the existing categoryUri and fetches the category CID. This avoids clients being able to secretly reparent a board by passing a new categoryUri on edit (category changes would need a dedicated reparent operation).

/**
 * PUT /api/admin/boards/:id
 *
 * Update an existing board's name, description, and sortOrder.
 * Fetches existing rkey + categoryUri from DB, then putRecord with updated fields.
 * Preserves the original categoryRef and createdAt.
 * The firehose indexer updates the DB row asynchronously.
 */
app.put(
  "/boards/:id",
  requireAuth(ctx),
  requirePermission(ctx, "space.atbb.permission.manageCategories"),
  async (c) => {
    const idParam = c.req.param("id");
    const id = parseBigIntParam(idParam);
    if (id === null) {
      return c.json({ error: "Invalid board ID" }, 400);
    }

    const { body, error: parseError } = await safeParseJsonBody(c);
    if (parseError) return parseError;

    const { name, description, sortOrder } = body;

    if (typeof name !== "string" || name.trim().length === 0) {
      return c.json({ error: "name is required and must be a non-empty string" }, 400);
    }

    let board: typeof boards.$inferSelect;
    try {
      const [row] = await ctx.db
        .select()
        .from(boards)
        .where(and(eq(boards.id, id), eq(boards.did, ctx.config.forumDid)))
        .limit(1);

      if (!row) {
        return c.json({ error: "Board not found" }, 404);
      }
      board = row;
    } catch (error) {
      return handleRouteError(c, error, "Failed to look up board", {
        operation: "PUT /api/admin/boards/:id",
        logger: ctx.logger,
        id: idParam,
      });
    }

    // Fetch category CID to build the categoryRef strongRef
    let categoryCid: string;
    try {
      const categoryRkey = board.categoryUri.split("/").pop() ?? "";
      const [cat] = await ctx.db
        .select({ cid: categories.cid })
        .from(categories)
        .where(
          and(
            eq(categories.did, ctx.config.forumDid),
            eq(categories.rkey, categoryRkey)
          )
        )
        .limit(1);

      if (!cat) {
        return c.json({ error: "Category not found" }, 404);
      }
      categoryCid = cat.cid;
    } catch (error) {
      return handleRouteError(c, error, "Failed to look up category", {
        operation: "PUT /api/admin/boards/:id",
        logger: ctx.logger,
        id: idParam,
      });
    }

    const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/boards/:id");
    if (agentError) return agentError;

    // putRecord is a full replacement — fall back to existing values for
    // optional fields not provided in the request body, to avoid data loss.
    const resolvedDescription = typeof description === "string"
      ? description.trim()
      : board.description;
    const resolvedSortOrder = (Number.isInteger(sortOrder) && sortOrder >= 0)
      ? sortOrder
      : board.sortOrder;

    try {
      const result = await agent.com.atproto.repo.putRecord({
        repo: ctx.config.forumDid,
        collection: "space.atbb.forum.board",
        rkey: board.rkey,
        record: {
          $type: "space.atbb.forum.board",
          name: name.trim(),
          ...(resolvedDescription != null && { description: resolvedDescription }),
          ...(resolvedSortOrder != null && { sortOrder: resolvedSortOrder }),
          category: { category: { uri: board.categoryUri, cid: categoryCid } },
          createdAt: board.createdAt.toISOString(),
        },
      });

      return c.json({ uri: result.data.uri, cid: result.data.cid });
    } catch (error) {
      return handleRouteError(c, error, "Failed to update board", {
        operation: "PUT /api/admin/boards/:id",
        logger: ctx.logger,
        id: idParam,
      });
    }
  }
);

Step 2: Run tests to verify they pass

export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH
cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo
pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | grep -E "FAIL|PASS|PUT.*boards|✓|×" | head -30

Expected: All PUT /api/admin/boards tests PASS.

Step 3: Commit

git add apps/appview/src/routes/admin.ts
git commit -m "feat(appview): PUT /api/admin/boards/:id update endpoint (ATB-45)"

Task 5: DELETE /api/admin/boards/:id — failing tests#

Files:

  • Modify: apps/appview/src/routes/__tests__/admin.test.ts (append after the PUT boards describe block)

Step 1: Write the failing tests

describe.sequential("DELETE /api/admin/boards/:id", () => {
  let boardId: string;
  let categoryUri: string;

  beforeEach(async () => {
    await ctx.cleanDatabase();

    mockUser = { did: "did:plc:test-admin" };
    mockPutRecord.mockClear();
    mockDeleteRecord.mockClear();
    mockDeleteRecord.mockResolvedValue({});

    // Insert a category and a board
    const [cat] = await ctx.db.insert(categories).values({
      did: ctx.config.forumDid,
      rkey: "tid-test-cat",
      cid: "bafycat",
      name: "Test Category",
      createdAt: new Date("2026-01-01T00:00:00.000Z"),
      indexedAt: new Date(),
    }).returning({ id: categories.id });

    categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`;

    const [brd] = await ctx.db.insert(boards).values({
      did: ctx.config.forumDid,
      rkey: "tid-test-board",
      cid: "bafyboard",
      name: "Test Board",
      categoryId: cat.id,
      categoryUri,
      createdAt: new Date("2026-01-01T00:00:00.000Z"),
      indexedAt: new Date(),
    }).returning({ id: boards.id });

    boardId = brd.id.toString();
  });

  it("deletes empty board → 200 and deleteRecord called", async () => {
    const res = await app.request(`/api/admin/boards/${boardId}`, {
      method: "DELETE",
    });

    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.success).toBe(true);
    expect(mockDeleteRecord).toHaveBeenCalledWith(
      expect.objectContaining({
        repo: ctx.config.forumDid,
        collection: "space.atbb.forum.board",
        rkey: "tid-test-board",
      })
    );
  });

  it("returns 409 when board has posts → deleteRecord NOT called", async () => {
    // Insert a user and a post referencing this board
    await ctx.db.insert(users).values({
      did: "did:plc:test-user",
      handle: "testuser.bsky.social",
      indexedAt: new Date(),
    });

    const [brd] = await ctx.db.select().from(boards).where(eq(boards.rkey, "tid-test-board")).limit(1);

    // Insert a post with boardId set
    await ctx.db.insert(posts).values({
      did: "did:plc:test-user",
      rkey: "tid-test-post",
      cid: "bafypost",
      text: "Hello world",
      boardId: brd.id,
      boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid-test-board`,
      forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
      createdAt: new Date(),
      indexedAt: new Date(),
    });

    const res = await app.request(`/api/admin/boards/${boardId}`, {
      method: "DELETE",
    });

    expect(res.status).toBe(409);
    const data = await res.json();
    expect(data.error).toContain("posts");
    expect(mockDeleteRecord).not.toHaveBeenCalled();
  });

  it("returns 400 for non-numeric ID", async () => {
    const res = await app.request("/api/admin/boards/not-a-number", {
      method: "DELETE",
    });

    expect(res.status).toBe(400);
    expect(mockDeleteRecord).not.toHaveBeenCalled();
  });

  it("returns 404 when board not found", async () => {
    const res = await app.request("/api/admin/boards/99999", {
      method: "DELETE",
    });

    expect(res.status).toBe(404);
    const data = await res.json();
    expect(data.error).toContain("Board not found");
    expect(mockDeleteRecord).not.toHaveBeenCalled();
  });

  it("returns 401 when unauthenticated", async () => {
    mockUser = null;

    const res = await app.request(`/api/admin/boards/${boardId}`, {
      method: "DELETE",
    });

    expect(res.status).toBe(401);
    expect(mockDeleteRecord).not.toHaveBeenCalled();
  });

  it("returns 503 when PDS network error", async () => {
    mockDeleteRecord.mockRejectedValue(new Error("fetch failed"));

    const res = await app.request(`/api/admin/boards/${boardId}`, {
      method: "DELETE",
    });

    expect(res.status).toBe(503);
    const data = await res.json();
    expect(data.error).toContain("Unable to reach external service");
  });

  it("returns 500 when ForumAgent unavailable", async () => {
    ctx.forumAgent = null;

    const res = await app.request(`/api/admin/boards/${boardId}`, {
      method: "DELETE",
    });

    expect(res.status).toBe(500);
    const data = await res.json();
    expect(data.error).toContain("Forum agent not available");
  });

  it("returns 403 when user lacks manageCategories permission", async () => {
    const { requirePermission } = await import("../../middleware/permissions.js");
    const mockRequirePermission = requirePermission as any;
    mockRequirePermission.mockImplementation(() => async (c: any) => {
      return c.json({ error: "Forbidden" }, 403);
    });

    const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx));
    const res = await testApp.request(`/api/admin/boards/${boardId}`, {
      method: "DELETE",
    });

    expect(res.status).toBe(403);
    expect(mockDeleteRecord).not.toHaveBeenCalled();

    mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
      await next();
    });
  });
});

Important: The DELETE test that inserts a post needs to import posts from @atbb/db. Check the import at the top of the test file — if posts is not already imported, add it to the existing import:

import { memberships, roles, rolePermissions, users, forums, categories, boards, posts } from "@atbb/db";

Step 2: Run tests to verify they fail

export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH
cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo
pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | grep -E "FAIL|DELETE.*boards|×" | head -20

Expected: DELETE /api/admin/boards tests FAIL.

Step 3: Commit

git add apps/appview/src/routes/__tests__/admin.test.ts
git commit -m "test(appview): add failing tests for DELETE /api/admin/boards/:id (ATB-45)"

Task 6: DELETE /api/admin/boards/:id — implementation#

Files:

  • Modify: apps/appview/src/routes/admin.ts (add after the PUT /boards/:id handler, before return app;)

Step 1: Add the DELETE /api/admin/boards/:id endpoint

Also add posts to the existing Drizzle import at the top of the file:

import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts } from "@atbb/db";

Then add the handler:

/**
 * DELETE /api/admin/boards/:id
 *
 * Delete a board. Pre-flight: refuses with 409 if any posts have boardId
 * pointing to this board. If clear, calls deleteRecord on the Forum DID's PDS.
 * The firehose indexer removes the DB row asynchronously.
 */
app.delete(
  "/boards/:id",
  requireAuth(ctx),
  requirePermission(ctx, "space.atbb.permission.manageCategories"),
  async (c) => {
    const idParam = c.req.param("id");
    const id = parseBigIntParam(idParam);
    if (id === null) {
      return c.json({ error: "Invalid board ID" }, 400);
    }

    let board: typeof boards.$inferSelect;
    try {
      const [row] = await ctx.db
        .select()
        .from(boards)
        .where(and(eq(boards.id, id), eq(boards.did, ctx.config.forumDid)))
        .limit(1);

      if (!row) {
        return c.json({ error: "Board not found" }, 404);
      }
      board = row;
    } catch (error) {
      return handleRouteError(c, error, "Failed to look up board", {
        operation: "DELETE /api/admin/boards/:id",
        logger: ctx.logger,
        id: idParam,
      });
    }

    // Pre-flight: refuse if any posts reference this board
    try {
      const [postCount] = await ctx.db
        .select({ count: count() })
        .from(posts)
        .where(eq(posts.boardId, id));

      if (postCount && postCount.count > 0) {
        return c.json(
          { error: "Cannot delete board with posts. Remove all posts first." },
          409
        );
      }
    } catch (error) {
      return handleRouteError(c, error, "Failed to check board posts", {
        operation: "DELETE /api/admin/boards/:id",
        logger: ctx.logger,
        id: idParam,
      });
    }

    const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/boards/:id");
    if (agentError) return agentError;

    try {
      await agent.com.atproto.repo.deleteRecord({
        repo: ctx.config.forumDid,
        collection: "space.atbb.forum.board",
        rkey: board.rkey,
      });

      return c.json({ success: true });
    } catch (error) {
      return handleRouteError(c, error, "Failed to delete board", {
        operation: "DELETE /api/admin/boards/:id",
        logger: ctx.logger,
        id: idParam,
      });
    }
  }
);

Step 2: Run all admin tests to verify everything passes

export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH
cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo
pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -20

Expected: All tests PASS, no failures.

Step 3: Run the full test suite

export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH
cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo
pnpm --filter @atbb/appview exec vitest run 2>&1 | tail -20

Expected: All tests PASS.

Step 4: Commit

git add apps/appview/src/routes/admin.ts
git commit -m "feat(appview): DELETE /api/admin/boards/:id delete endpoint (ATB-45)"

Task 7: Bruno collection — board management#

Files:

  • Create: bruno/AppView API/Admin/Create Board.bru
  • Create: bruno/AppView API/Admin/Update Board.bru
  • Create: bruno/AppView API/Admin/Delete Board.bru

Step 1: Create Create Board.bru

meta {
  name: Create Board
  type: http
  seq: 13
}

post {
  url: {{appview_url}}/api/admin/boards
}

body:json {
  {
    "name": "General Chat",
    "description": "Talk about anything.",
    "sortOrder": 1,
    "categoryUri": "{{category_uri}}"
  }
}

assert {
  res.status: eq 201
  res.body.uri: isDefined
  res.body.cid: isDefined
}

docs {
  Create a new forum board within a category. Fetches the category's CID from DB
  to build the categoryRef strongRef. Writes space.atbb.forum.board to the Forum
  DID's PDS. The firehose indexer creates the DB row asynchronously.

  **Requires:** space.atbb.permission.manageCategories

  Body:
  - name (required): Board display name
  - categoryUri (required): AT URI of the parent category
  - description (optional): Short description
  - sortOrder (optional): Numeric sort position (lower = first)

  Returns (201):
  {
    "uri": "at://did:plc:.../space.atbb.forum.board/abc123",
    "cid": "bafyrei..."
  }

  Error codes:
  - 400: Missing or empty name, missing categoryUri, malformed JSON
  - 401: Not authenticated
  - 403: Missing manageCategories permission
  - 404: categoryUri references unknown category
  - 500: ForumAgent not configured
  - 503: PDS network error
}

Step 2: Create Update Board.bru

meta {
  name: Update Board
  type: http
  seq: 14
}

put {
  url: {{appview_url}}/api/admin/boards/:id
}

params:path {
  id: {{board_id}}
}

body:json {
  {
    "name": "General Chat (renamed)",
    "description": "Updated description.",
    "sortOrder": 2
  }
}

assert {
  res.status: eq 200
  res.body.uri: isDefined
  res.body.cid: isDefined
}

docs {
  Update an existing forum board's name, description, and sortOrder.
  Fetches existing rkey and categoryRef from DB, calls putRecord with updated
  fields preserving the original category and createdAt.

  **Requires:** space.atbb.permission.manageCategories

  Path params:
  - id: Board database ID (bigint as string)

  Body:
  - name (required): New display name
  - description (optional): New description (falls back to existing if omitted)
  - sortOrder (optional): New sort position (falls back to existing if omitted)

  Returns (200):
  {
    "uri": "at://did:plc:.../space.atbb.forum.board/abc123",
    "cid": "bafyrei..."
  }

  Error codes:
  - 400: Missing name, empty name, invalid ID format, malformed JSON
  - 401: Not authenticated
  - 403: Missing manageCategories permission
  - 404: Board not found
  - 500: ForumAgent not configured
  - 503: PDS network error
}

Step 3: Create Delete Board.bru

meta {
  name: Delete Board
  type: http
  seq: 15
}

delete {
  url: {{appview_url}}/api/admin/boards/:id
}

params:path {
  id: {{board_id}}
}

assert {
  res.status: eq 200
  res.body.success: isTrue
}

docs {
  Delete a forum board. Pre-flight check refuses with 409 if any posts reference
  this board. If clear, calls deleteRecord on the Forum DID's PDS.
  The firehose indexer removes the DB row asynchronously.

  **Requires:** space.atbb.permission.manageCategories

  Path params:
  - id: Board database ID (bigint as string)

  Returns (200):
  {
    "success": true
  }

  Error codes:
  - 400: Invalid ID format
  - 401: Not authenticated
  - 403: Missing manageCategories permission
  - 404: Board not found
  - 409: Board has posts — remove them first
  - 500: ForumAgent not configured
  - 503: PDS network error
}

Step 4: Commit

git add "bruno/AppView API/Admin/Create Board.bru" "bruno/AppView API/Admin/Update Board.bru" "bruno/AppView API/Admin/Delete Board.bru"
git commit -m "docs(bruno): add board management API collection (ATB-45)"

Task 8: Lint, full test run, and verification#

Step 1: Run lint fix

export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH
cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo
pnpm --filter @atbb/appview lint:fix

Expected: No unfixable errors.

Step 2: Run full test suite

export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH
cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo
pnpm --filter @atbb/appview exec vitest run 2>&1 | tail -20

Expected: All tests PASS.

Step 3: Update Linear issue

Mark ATB-45 as "In Progress" in Linear, then as "Done" once the branch is ready for review.

Step 4: Request code review

Follow the commit-push-pr skill to push and open a PR:

git log --oneline origin/main..HEAD

Verify all commits are present, then push and open PR against main.