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.

Moderation Action Write-Path API Endpoints Implementation Plan#

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

Goal: Implement API endpoints for moderators to ban users, lock topics, and hide posts by writing modAction records to the Forum DID's PDS.

Architecture: Single route file (mod.ts) with 6 endpoints (ban/lock/hide + reversals). Each endpoint follows auth → permission → validation → duplicate check → PDS write pattern. ForumAgent writes modAction records to Forum DID's PDS, firehose indexes them asynchronously.

Tech Stack: Hono (routes), Drizzle ORM (database), @atproto/api (PDS writes), Vitest (tests)

Design Reference: docs/plans/2026-02-15-moderation-endpoints-design.md


Task 1: Add Moderation Permissions to Default Roles#

Files:

  • Modify: apps/appview/src/lib/seed-roles.ts

Step 1: Read existing seed-roles.ts to understand structure

Read the file to see how default roles are defined.

Step 2: Add moderation permissions to roles

Update the role definitions in seedDefaultRoles():

// Admin role (after manageRoles permission)
{
  name: "Admin",
  permissions: [
    "space.atbb.permission.manageRoles",
    "space.atbb.permission.banUsers",      // NEW
    "space.atbb.permission.lockTopics",    // NEW
    "space.atbb.permission.moderatePosts", // NEW
  ],
  priority: 10,
}

// Moderator role (after existing permissions if any, otherwise create)
{
  name: "Moderator",
  permissions: [
    "space.atbb.permission.lockTopics",    // NEW
    "space.atbb.permission.moderatePosts", // NEW
  ],
  priority: 20,
}

Step 3: Commit permission updates

git add apps/appview/src/lib/seed-roles.ts
git commit -m "feat(permissions): add moderation permissions to default roles (ATB-19)

- Admin: banUsers, lockTopics, moderatePosts
- Moderator: lockTopics, moderatePosts"

Task 2: Create Mod Routes File Structure#

Files:

  • Create: apps/appview/src/routes/mod.ts
  • Create: apps/appview/src/routes/__tests__/mod.test.ts

Step 1: Create empty mod.ts route file

Create the file with factory function skeleton:

import { Hono } from "hono";
import type { AppContext } from "../lib/app-context.js";
import type { Variables } from "../types.js";
import { requireAuth } from "../middleware/auth.js";
import { requirePermission } from "../middleware/permissions.js";
import { modActions, users, memberships, posts } from "@atbb/db";
import { eq, desc, and } from "drizzle-orm";
import { isNetworkError } from "./helpers.js";
import { TID } from "@atproto/common";

export function createModRoutes(ctx: AppContext) {
  const app = new Hono<{ Variables: Variables }>();

  // Routes will go here

  return app;
}

Step 2: Register mod routes in main app

Modify: apps/appview/src/routes/index.ts

Add import and route registration:

import { createModRoutes } from "./mod.js";

// In createApiRoutes():
app.route("/mod", createModRoutes(ctx));

Step 3: Create empty test file

Create: apps/appview/src/routes/__tests__/mod.test.ts

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createTestContext, destroyTestContext } from "../../lib/__tests__/test-context.js";
import type { TestContext } from "../../lib/__tests__/test-context.js";
import { createApp } from "../../create-app.js";

describe("createModRoutes", () => {
  let testCtx: TestContext;
  let app: ReturnType<typeof createApp>;

  beforeEach(async () => {
    testCtx = await createTestContext();
    app = createApp(testCtx.ctx);
  });

  afterEach(async () => {
    await destroyTestContext(testCtx);
  });

  // Tests will go here
});

Step 4: Commit skeleton

git add apps/appview/src/routes/mod.ts apps/appview/src/routes/__tests__/mod.test.ts apps/appview/src/routes/index.ts
git commit -m "feat(mod): add mod routes skeleton (ATB-19)"

Task 3: Helper Function - Validate Reason Field#

Files:

  • Modify: apps/appview/src/routes/mod.ts
  • Modify: apps/appview/src/routes/__tests__/mod.test.ts

Step 1: Write failing test for reason validation

Add to mod.test.ts:

describe("Helper: validateReason", () => {
  it("returns null for valid reason", () => {
    const { validateReason } = await import("../mod.js");
    expect(validateReason("Spam")).toBeNull();
  });

  it("returns error for non-string reason", () => {
    const { validateReason } = await import("../mod.js");
    expect(validateReason(123 as any)).toBe("Reason is required and must be a string");
  });

  it("returns error for empty reason", () => {
    const { validateReason } = await import("../mod.js");
    expect(validateReason("")).toBe("Reason is required and must not be empty");
    expect(validateReason("   ")).toBe("Reason is required and must not be empty");
  });

  it("returns error for reason exceeding 3000 characters", () => {
    const { validateReason } = await import("../mod.js");
    const longReason = "x".repeat(3001);
    expect(validateReason(longReason)).toBe("Reason must not exceed 3000 characters");
  });
});

Step 2: Run test to verify it fails

Run: pnpm --filter @atbb/appview test mod.test.ts -t "validateReason"

Expected: FAIL - validateReason not exported

Step 3: Implement validateReason helper

Add to mod.ts before createModRoutes:

/**
 * Validate reason field (required, 1-3000 chars).
 * @returns null if valid, error message string if invalid
 */
export function validateReason(reason: unknown): string | null {
  if (typeof reason !== "string") {
    return "Reason is required and must be a string";
  }

  if (reason.trim().length === 0) {
    return "Reason is required and must not be empty";
  }

  if (reason.length > 3000) {
    return "Reason must not exceed 3000 characters";
  }

  return null;
}

Step 4: Run test to verify it passes

Run: pnpm --filter @atbb/appview test mod.test.ts -t "validateReason"

Expected: PASS (4 tests)

Step 5: Commit

git add apps/appview/src/routes/mod.ts apps/appview/src/routes/__tests__/mod.test.ts
git commit -m "feat(mod): add reason validation helper (ATB-19)

Validates reason field: required, non-empty, max 3000 chars"

Task 4: Helper Function - Check Active Action#

Files:

  • Modify: apps/appview/src/routes/mod.ts
  • Modify: apps/appview/src/routes/__tests__/mod.test.ts

Step 1: Write failing test for checkActiveAction

Add to mod.test.ts:

describe("Helper: checkActiveAction", () => {
  it("returns null when no actions exist", async () => {
    const { checkActiveAction } = await import("../mod.js");
    const result = await checkActiveAction(
      testCtx.ctx,
      { did: "did:plc:test" },
      "space.atbb.modAction.ban"
    );
    expect(result).toBeNull();
  });

  it("returns true when action is active (most recent)", async () => {
    const { checkActiveAction } = await import("../mod.js");

    // Insert ban action
    await testCtx.ctx.db.insert(modActions).values({
      did: testCtx.ctx.config.forumDid,
      rkey: "test1",
      cid: "bafytest1",
      action: "space.atbb.modAction.ban",
      subjectDid: "did:plc:test",
      reason: "Test",
      createdBy: "did:plc:admin",
      createdAt: new Date(),
      indexedAt: new Date(),
    });

    const result = await checkActiveAction(
      testCtx.ctx,
      { did: "did:plc:test" },
      "space.atbb.modAction.ban"
    );
    expect(result).toBe(true);
  });

  it("returns false when action is reversed (unban after ban)", async () => {
    const { checkActiveAction } = await import("../mod.js");

    // Insert ban
    await testCtx.ctx.db.insert(modActions).values({
      did: testCtx.ctx.config.forumDid,
      rkey: "test1",
      cid: "bafytest1",
      action: "space.atbb.modAction.ban",
      subjectDid: "did:plc:test",
      reason: "Test",
      createdBy: "did:plc:admin",
      createdAt: new Date("2024-01-01"),
      indexedAt: new Date("2024-01-01"),
    });

    // Insert unban (more recent)
    await testCtx.ctx.db.insert(modActions).values({
      did: testCtx.ctx.config.forumDid,
      rkey: "test2",
      cid: "bafytest2",
      action: "space.atbb.modAction.unban",
      subjectDid: "did:plc:test",
      reason: "Appeal",
      createdBy: "did:plc:admin",
      createdAt: new Date("2024-01-02"),
      indexedAt: new Date("2024-01-02"),
    });

    const result = await checkActiveAction(
      testCtx.ctx,
      { did: "did:plc:test" },
      "space.atbb.modAction.ban"
    );
    expect(result).toBe(false);
  });
});

Step 2: Run test to verify it fails

Run: pnpm --filter @atbb/appview test mod.test.ts -t "checkActiveAction"

Expected: FAIL - checkActiveAction not exported

Step 3: Implement checkActiveAction helper

Add to mod.ts:

/**
 * Subject reference for modAction (user DID or post URI).
 */
type ModSubject = { did: string } | { postUri: string };

/**
 * Check if a specific moderation action is currently active.
 * @returns true if active, false if reversed/inactive, null if no actions exist
 */
export async function checkActiveAction(
  ctx: AppContext,
  subject: ModSubject,
  actionType: string
): Promise<boolean | null> {
  try {
    // Build where clause based on subject type
    const whereClause = "did" in subject
      ? eq(modActions.subjectDid, subject.did)
      : eq(modActions.subjectPostUri, subject.postUri);

    // Get most recent action for this subject
    const [mostRecent] = await ctx.db
      .select()
      .from(modActions)
      .where(whereClause)
      .orderBy(desc(modActions.createdAt))
      .limit(1);

    if (!mostRecent) {
      return null; // No actions exist
    }

    // Check if most recent action matches the action type
    return mostRecent.action === actionType;
  } catch (error) {
    console.error("Failed to check active action", {
      subject,
      actionType,
      error: error instanceof Error ? error.message : String(error),
    });
    return null; // Fail safe: treat as no active action
  }
}

Step 4: Run test to verify it passes

Run: pnpm --filter @atbb/appview test mod.test.ts -t "checkActiveAction"

Expected: PASS (3 tests)

Step 5: Commit

git add apps/appview/src/routes/mod.ts apps/appview/src/routes/__tests__/mod.test.ts
git commit -m "feat(mod): add checkActiveAction helper (ATB-19)

Queries most recent modAction for a subject to determine if action is active"

Task 5: Implement POST /api/mod/ban Endpoint#

Files:

  • Modify: apps/appview/src/routes/mod.ts
  • Modify: apps/appview/src/routes/__tests__/mod.test.ts

Step 1: Write failing test for ban endpoint

Add to mod.test.ts:

describe("POST /api/mod/ban", () => {
  it("bans user successfully when admin has permission", async () => {
    const admin = await testCtx.createUser("Admin");
    const member = await testCtx.createUser("Member");

    const mockPutRecord = vi.fn().mockResolvedValue({
      uri: "at://did:plc:forum/space.atbb.modAction/abc123",
      cid: "bafytest",
    });

    testCtx.ctx.forumAgent = {
      isAuthenticated: () => true,
      getAgent: () => ({
        com: { atproto: { repo: { putRecord: mockPutRecord } } },
      }),
    } as any;

    const res = await app.request("/api/mod/ban", {
      method: "POST",
      headers: { Cookie: `atbb_session=${admin.sessionToken}` },
      body: JSON.stringify({
        targetDid: member.did,
        reason: "Spam and harassment",
      }),
    });

    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.success).toBe(true);
    expect(data.action).toBe("ban");
    expect(data.targetDid).toBe(member.did);
    expect(data.uri).toBe("at://did:plc:forum/space.atbb.modAction/abc123");
    expect(data.alreadyActive).toBe(false);

    // Verify PDS write
    expect(mockPutRecord).toHaveBeenCalledWith({
      repo: testCtx.ctx.config.forumDid,
      collection: "space.atbb.modAction",
      rkey: expect.any(String),
      record: {
        $type: "space.atbb.modAction",
        action: "space.atbb.modAction.ban",
        subject: { did: member.did },
        reason: "Spam and harassment",
        createdBy: admin.did,
        createdAt: expect.any(String),
      },
    });
  });
});

Step 2: Run test to verify it fails

Run: pnpm --filter @atbb/appview test mod.test.ts -t "POST /api/mod/ban"

Expected: FAIL - 404 Not Found (route doesn't exist)

Step 3: Implement POST /api/mod/ban endpoint

Add to mod.ts in createModRoutes:

/**
 * POST /api/mod/ban
 * Ban a user from the forum.
 */
app.post(
  "/ban",
  requireAuth(ctx),
  requirePermission(ctx, "space.atbb.permission.banUsers"),
  async (c) => {
    const user = c.get("user")!;

    // Parse request body
    let body: any;
    try {
      body = await c.req.json();
    } catch {
      return c.json({ error: "Invalid JSON in request body" }, 400);
    }

    const { targetDid, reason } = body;

    // Validate targetDid
    if (typeof targetDid !== "string" || !targetDid.startsWith("did:")) {
      return c.json({ error: "Invalid DID format" }, 400);
    }

    // Validate reason
    const reasonError = validateReason(reason);
    if (reasonError) {
      return c.json({ error: reasonError }, 400);
    }

    // Check target user exists (has membership)
    const [membership] = await ctx.db
      .select()
      .from(memberships)
      .where(eq(memberships.did, targetDid))
      .limit(1);

    if (!membership) {
      return c.json({ error: "User is not a member of this forum" }, 404);
    }

    // Check if user is already banned
    const isActive = await checkActiveAction(
      ctx,
      { did: targetDid },
      "space.atbb.modAction.ban"
    );

    if (isActive) {
      return c.json({
        success: true,
        action: "ban",
        targetDid,
        alreadyActive: true,
      }, 200);
    }

    // Get ForumAgent
    if (!ctx.forumAgent) {
      return c.json({
        error: "Forum agent not available. Server configuration issue.",
      }, 500);
    }

    const agent = ctx.forumAgent.getAgent();
    if (!agent) {
      return c.json({
        error: "Forum agent not authenticated. Please try again later.",
      }, 503);
    }

    // Write modAction record to Forum DID's PDS
    try {
      const result = await agent.com.atproto.repo.putRecord({
        repo: ctx.config.forumDid,
        collection: "space.atbb.modAction",
        rkey: TID.nextStr(),
        record: {
          $type: "space.atbb.modAction",
          action: "space.atbb.modAction.ban",
          subject: { did: targetDid },
          reason,
          createdBy: user.did,
          createdAt: new Date().toISOString(),
        },
      });

      return c.json({
        success: true,
        action: "ban",
        targetDid,
        uri: result.uri,
        cid: result.cid,
        alreadyActive: false,
      }, 200);
    } catch (error) {
      console.error("Failed to write ban modAction", {
        operation: "POST /api/mod/ban",
        targetDid,
        error: error instanceof Error ? error.message : String(error),
      });

      if (error instanceof Error && isNetworkError(error)) {
        return c.json({
          error: "Unable to reach Forum PDS. Please try again later.",
        }, 503);
      }

      return c.json({
        error: "Failed to record moderation action. Please contact support.",
      }, 500);
    }
  }
);

Step 4: Run test to verify it passes

Run: pnpm --filter @atbb/appview test mod.test.ts -t "POST /api/mod/ban.*successfully"

Expected: PASS

Step 5: Commit

git add apps/appview/src/routes/mod.ts apps/appview/src/routes/__tests__/mod.test.ts
git commit -m "feat(mod): implement POST /api/mod/ban endpoint (ATB-19)

Bans user by writing modAction record to Forum DID's PDS"

Task 6: Add Error Tests for POST /api/mod/ban#

Files:

  • Modify: apps/appview/src/routes/__tests__/mod.test.ts

Step 1: Write authorization error tests

Add to "POST /api/mod/ban" describe block:

it("returns 401 when not authenticated", async () => {
  const res = await app.request("/api/mod/ban", {
    method: "POST",
    body: JSON.stringify({ targetDid: "did:plc:test", reason: "Test" }),
  });

  expect(res.status).toBe(401);
});

it("returns 403 when user lacks banUsers permission", async () => {
  const member = await testCtx.createUser("Member"); // No ban permission

  const res = await app.request("/api/mod/ban", {
    method: "POST",
    headers: { Cookie: `atbb_session=${member.sessionToken}` },
    body: JSON.stringify({ targetDid: "did:plc:other", reason: "Test" }),
  });

  expect(res.status).toBe(403);
  const data = await res.json();
  expect(data.error).toContain("Insufficient permissions");
});

Step 2: Write input validation tests

it("returns 400 for invalid DID format", async () => {
  const admin = await testCtx.createUser("Admin");

  const res = await app.request("/api/mod/ban", {
    method: "POST",
    headers: { Cookie: `atbb_session=${admin.sessionToken}` },
    body: JSON.stringify({ targetDid: "invalid", reason: "Test" }),
  });

  expect(res.status).toBe(400);
  const data = await res.json();
  expect(data.error).toContain("Invalid DID format");
});

it("returns 400 for missing reason", async () => {
  const admin = await testCtx.createUser("Admin");

  const res = await app.request("/api/mod/ban", {
    method: "POST",
    headers: { Cookie: `atbb_session=${admin.sessionToken}` },
    body: JSON.stringify({ targetDid: "did:plc:test" }),
  });

  expect(res.status).toBe(400);
  const data = await res.json();
  expect(data.error).toContain("Reason is required");
});

it("returns 400 for empty reason", async () => {
  const admin = await testCtx.createUser("Admin");

  const res = await app.request("/api/mod/ban", {
    method: "POST",
    headers: { Cookie: `atbb_session=${admin.sessionToken}` },
    body: JSON.stringify({ targetDid: "did:plc:test", reason: "   " }),
  });

  expect(res.status).toBe(400);
  const data = await res.json();
  expect(data.error).toContain("must not be empty");
});

it("returns 400 for malformed JSON", async () => {
  const admin = await testCtx.createUser("Admin");

  const res = await app.request("/api/mod/ban", {
    method: "POST",
    headers: {
      Cookie: `atbb_session=${admin.sessionToken}`,
      "Content-Type": "application/json",
    },
    body: "{ invalid json }",
  });

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

Step 3: Write target validation test

it("returns 404 when target user has no membership", async () => {
  const admin = await testCtx.createUser("Admin");

  const res = await app.request("/api/mod/ban", {
    method: "POST",
    headers: { Cookie: `atbb_session=${admin.sessionToken}` },
    body: JSON.stringify({
      targetDid: "did:plc:nonexistent",
      reason: "Test",
    }),
  });

  expect(res.status).toBe(404);
  const data = await res.json();
  expect(data.error).toContain("not a member");
});

Step 4: Write idempotency test

it("returns alreadyActive: true when user already banned", async () => {
  const admin = await testCtx.createUser("Admin");
  const member = await testCtx.createUser("Member");

  // Insert existing ban action
  await testCtx.ctx.db.insert(modActions).values({
    did: testCtx.ctx.config.forumDid,
    rkey: "existing",
    cid: "bafyexisting",
    action: "space.atbb.modAction.ban",
    subjectDid: member.did,
    reason: "Previous ban",
    createdBy: admin.did,
    createdAt: new Date(),
    indexedAt: new Date(),
  });

  const mockPutRecord = vi.fn();
  testCtx.ctx.forumAgent = {
    isAuthenticated: () => true,
    getAgent: () => ({
      com: { atproto: { repo: { putRecord: mockPutRecord } } },
    }),
  } as any;

  const res = await app.request("/api/mod/ban", {
    method: "POST",
    headers: { Cookie: `atbb_session=${admin.sessionToken}` },
    body: JSON.stringify({ targetDid: member.did, reason: "Duplicate ban" }),
  });

  expect(res.status).toBe(200);
  const data = await res.json();
  expect(data.alreadyActive).toBe(true);
  expect(mockPutRecord).not.toHaveBeenCalled(); // No duplicate write
});

Step 5: Write error handling tests

it("returns 500 when ForumAgent not available", async () => {
  const admin = await testCtx.createUser("Admin");
  const member = await testCtx.createUser("Member");

  testCtx.ctx.forumAgent = null; // Simulate unavailable agent

  const res = await app.request("/api/mod/ban", {
    method: "POST",
    headers: { Cookie: `atbb_session=${admin.sessionToken}` },
    body: JSON.stringify({ targetDid: member.did, reason: "Test" }),
  });

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

it("returns 503 when ForumAgent not authenticated", async () => {
  const admin = await testCtx.createUser("Admin");
  const member = await testCtx.createUser("Member");

  testCtx.ctx.forumAgent = {
    isAuthenticated: () => false,
    getAgent: () => null,
  } as any;

  const res = await app.request("/api/mod/ban", {
    method: "POST",
    headers: { Cookie: `atbb_session=${admin.sessionToken}` },
    body: JSON.stringify({ targetDid: member.did, reason: "Test" }),
  });

  expect(res.status).toBe(503);
  const data = await res.json();
  expect(data.error).toContain("not authenticated");
});

it("returns 503 for network errors writing to PDS", async () => {
  const admin = await testCtx.createUser("Admin");
  const member = await testCtx.createUser("Member");

  const mockPutRecord = vi.fn().mockRejectedValue(new Error("fetch failed"));
  testCtx.ctx.forumAgent = {
    isAuthenticated: () => true,
    getAgent: () => ({
      com: { atproto: { repo: { putRecord: mockPutRecord } } },
    }),
  } as any;

  const res = await app.request("/api/mod/ban", {
    method: "POST",
    headers: { Cookie: `atbb_session=${admin.sessionToken}` },
    body: JSON.stringify({ targetDid: member.did, reason: "Test" }),
  });

  expect(res.status).toBe(503);
  const data = await res.json();
  expect(data.error).toContain("try again later");
});

it("returns 500 for unexpected errors writing to PDS", async () => {
  const admin = await testCtx.createUser("Admin");
  const member = await testCtx.createUser("Member");

  const mockPutRecord = vi.fn().mockRejectedValue(new Error("Database error"));
  testCtx.ctx.forumAgent = {
    isAuthenticated: () => true,
    getAgent: () => ({
      com: { atproto: { repo: { putRecord: mockPutRecord } } },
    }),
  } as any;

  const res = await app.request("/api/mod/ban", {
    method: "POST",
    headers: { Cookie: `atbb_session=${admin.sessionToken}` },
    body: JSON.stringify({ targetDid: member.did, reason: "Test" }),
  });

  expect(res.status).toBe(500);
  const data = await res.json();
  expect(data.error).toContain("contact support");
});

Step 6: Run all ban endpoint tests

Run: pnpm --filter @atbb/appview test mod.test.ts -t "POST /api/mod/ban"

Expected: PASS (~13 tests)

Step 7: Commit

git add apps/appview/src/routes/__tests__/mod.test.ts
git commit -m "test(mod): add comprehensive tests for POST /api/mod/ban (ATB-19)

Covers auth, validation, idempotency, and error classification"

Task 7: Implement DELETE /api/mod/ban/:did (Unban)#

Files:

  • Modify: apps/appview/src/routes/mod.ts
  • Modify: apps/appview/src/routes/__tests__/mod.test.ts

Step 1: Write failing test for unban endpoint

Add to mod.test.ts:

describe("DELETE /api/mod/ban/:did", () => {
  it("unbans user successfully", async () => {
    const admin = await testCtx.createUser("Admin");
    const member = await testCtx.createUser("Member");

    // Insert existing ban
    await testCtx.ctx.db.insert(modActions).values({
      did: testCtx.ctx.config.forumDid,
      rkey: "ban1",
      cid: "bafyban",
      action: "space.atbb.modAction.ban",
      subjectDid: member.did,
      reason: "Original ban",
      createdBy: admin.did,
      createdAt: new Date(),
      indexedAt: new Date(),
    });

    const mockPutRecord = vi.fn().mockResolvedValue({
      uri: "at://did:plc:forum/space.atbb.modAction/unban123",
      cid: "bafyunban",
    });

    testCtx.ctx.forumAgent = {
      isAuthenticated: () => true,
      getAgent: () => ({
        com: { atproto: { repo: { putRecord: mockPutRecord } } },
      }),
    } as any;

    const res = await app.request(`/api/mod/ban/${member.did}`, {
      method: "DELETE",
      headers: { Cookie: `atbb_session=${admin.sessionToken}` },
      body: JSON.stringify({ reason: "Appeal approved" }),
    });

    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.success).toBe(true);
    expect(data.action).toBe("unban");
    expect(data.targetDid).toBe(member.did);
    expect(data.alreadyActive).toBe(false);

    // Verify PDS write
    expect(mockPutRecord).toHaveBeenCalledWith({
      repo: testCtx.ctx.config.forumDid,
      collection: "space.atbb.modAction",
      rkey: expect.any(String),
      record: {
        $type: "space.atbb.modAction",
        action: "space.atbb.modAction.unban",
        subject: { did: member.did },
        reason: "Appeal approved",
        createdBy: admin.did,
        createdAt: expect.any(String),
      },
    });
  });

  it("returns alreadyActive: true when user already unbanned", async () => {
    const admin = await testCtx.createUser("Admin");
    const member = await testCtx.createUser("Member");

    // Insert unban (most recent)
    await testCtx.ctx.db.insert(modActions).values({
      did: testCtx.ctx.config.forumDid,
      rkey: "unban1",
      cid: "bafyunban",
      action: "space.atbb.modAction.unban",
      subjectDid: member.did,
      reason: "Previous unban",
      createdBy: admin.did,
      createdAt: new Date(),
      indexedAt: new Date(),
    });

    const mockPutRecord = vi.fn();
    testCtx.ctx.forumAgent = {
      isAuthenticated: () => true,
      getAgent: () => ({
        com: { atproto: { repo: { putRecord: mockPutRecord } } },
      }),
    } as any;

    const res = await app.request(`/api/mod/ban/${member.did}`, {
      method: "DELETE",
      headers: { Cookie: `atbb_session=${admin.sessionToken}` },
      body: JSON.stringify({ reason: "Duplicate unban" }),
    });

    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.alreadyActive).toBe(true);
    expect(mockPutRecord).not.toHaveBeenCalled();
  });
});

Step 2: Run test to verify it fails

Run: pnpm --filter @atbb/appview test mod.test.ts -t "DELETE /api/mod/ban"

Expected: FAIL - 404 Not Found

Step 3: Implement DELETE /api/mod/ban/:did endpoint

Add to mod.ts in createModRoutes:

/**
 * DELETE /api/mod/ban/:did
 * Unban a user (reversal action).
 */
app.delete(
  "/ban/:did",
  requireAuth(ctx),
  requirePermission(ctx, "space.atbb.permission.banUsers"),
  async (c) => {
    const user = c.get("user")!;
    const targetDid = c.req.param("did");

    // Validate DID
    if (!targetDid.startsWith("did:")) {
      return c.json({ error: "Invalid DID format" }, 400);
    }

    // Parse request body
    let body: any;
    try {
      body = await c.req.json();
    } catch {
      return c.json({ error: "Invalid JSON in request body" }, 400);
    }

    const { reason } = body;

    // Validate reason
    const reasonError = validateReason(reason);
    if (reasonError) {
      return c.json({ error: reasonError }, 400);
    }

    // Check target user exists
    const [membership] = await ctx.db
      .select()
      .from(memberships)
      .where(eq(memberships.did, targetDid))
      .limit(1);

    if (!membership) {
      return c.json({ error: "User is not a member of this forum" }, 404);
    }

    // Check if user is already unbanned (or never banned)
    const isBanned = await checkActiveAction(
      ctx,
      { did: targetDid },
      "space.atbb.modAction.ban"
    );

    if (isBanned === false || isBanned === null) {
      // Already unbanned or no ban history
      return c.json({
        success: true,
        action: "unban",
        targetDid,
        alreadyActive: true,
      }, 200);
    }

    // Get ForumAgent
    if (!ctx.forumAgent) {
      return c.json({
        error: "Forum agent not available. Server configuration issue.",
      }, 500);
    }

    const agent = ctx.forumAgent.getAgent();
    if (!agent) {
      return c.json({
        error: "Forum agent not authenticated. Please try again later.",
      }, 503);
    }

    // Write unban modAction record
    try {
      const result = await agent.com.atproto.repo.putRecord({
        repo: ctx.config.forumDid,
        collection: "space.atbb.modAction",
        rkey: TID.nextStr(),
        record: {
          $type: "space.atbb.modAction",
          action: "space.atbb.modAction.unban",
          subject: { did: targetDid },
          reason,
          createdBy: user.did,
          createdAt: new Date().toISOString(),
        },
      });

      return c.json({
        success: true,
        action: "unban",
        targetDid,
        uri: result.uri,
        cid: result.cid,
        alreadyActive: false,
      }, 200);
    } catch (error) {
      console.error("Failed to write unban modAction", {
        operation: "DELETE /api/mod/ban/:did",
        targetDid,
        error: error instanceof Error ? error.message : String(error),
      });

      if (error instanceof Error && isNetworkError(error)) {
        return c.json({
          error: "Unable to reach Forum PDS. Please try again later.",
        }, 503);
      }

      return c.json({
        error: "Failed to record moderation action. Please contact support.",
      }, 500);
    }
  }
);

Step 4: Run test to verify it passes

Run: pnpm --filter @atbb/appview test mod.test.ts -t "DELETE /api/mod/ban"

Expected: PASS (2 tests)

Step 5: Commit

git add apps/appview/src/routes/mod.ts apps/appview/src/routes/__tests__/mod.test.ts
git commit -m "feat(mod): implement DELETE /api/mod/ban/:did (unban) (ATB-19)

Unbans user by writing unban modAction record"

Task 8: Implement POST /api/mod/lock and DELETE /api/mod/lock/:topicId#

Files:

  • Modify: apps/appview/src/routes/mod.ts
  • Modify: apps/appview/src/routes/__tests__/mod.test.ts

Note: Lock endpoints are similar to ban but target posts and include topic validation.

Step 1: Write failing tests for lock endpoints

Add to mod.test.ts:

describe("POST /api/mod/lock", () => {
  it("locks topic successfully", async () => {
    const mod = await testCtx.createUser("Moderator");
    const member = await testCtx.createUser("Member");
    const topic = await testCtx.createTopic(member.did, "Test topic");

    const mockPutRecord = vi.fn().mockResolvedValue({
      uri: "at://did:plc:forum/space.atbb.modAction/lock123",
      cid: "bafylock",
    });

    testCtx.ctx.forumAgent = {
      isAuthenticated: () => true,
      getAgent: () => ({
        com: { atproto: { repo: { putRecord: mockPutRecord } } },
      }),
    } as any;

    const res = await app.request("/api/mod/lock", {
      method: "POST",
      headers: { Cookie: `atbb_session=${mod.sessionToken}` },
      body: JSON.stringify({
        topicId: topic.id.toString(),
        reason: "Off-topic discussion",
      }),
    });

    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.success).toBe(true);
    expect(data.action).toBe("lock");
    expect(data.topicId).toBe(topic.id.toString());
    expect(data.alreadyActive).toBe(false);

    // Verify PDS write with post subject
    expect(mockPutRecord).toHaveBeenCalledWith({
      repo: testCtx.ctx.config.forumDid,
      collection: "space.atbb.modAction",
      rkey: expect.any(String),
      record: {
        $type: "space.atbb.modAction",
        action: "space.atbb.modAction.lock",
        subject: {
          post: {
            uri: expect.stringContaining("space.atbb.post"),
            cid: topic.cid,
          },
        },
        reason: "Off-topic discussion",
        createdBy: mod.did,
        createdAt: expect.any(String),
      },
    });
  });

  it("returns 400 when trying to lock a reply post", async () => {
    const mod = await testCtx.createUser("Moderator");
    const member = await testCtx.createUser("Member");
    const topic = await testCtx.createTopic(member.did, "Topic");
    const reply = await testCtx.createReply(member.did, topic.id, "Reply");

    const res = await app.request("/api/mod/lock", {
      method: "POST",
      headers: { Cookie: `atbb_session=${mod.sessionToken}` },
      body: JSON.stringify({
        topicId: reply.id.toString(),
        reason: "Test",
      }),
    });

    expect(res.status).toBe(400);
    const data = await res.json();
    expect(data.error).toContain("root posts");
  });

  it("returns 404 when topic not found", async () => {
    const mod = await testCtx.createUser("Moderator");

    const res = await app.request("/api/mod/lock", {
      method: "POST",
      headers: { Cookie: `atbb_session=${mod.sessionToken}` },
      body: JSON.stringify({
        topicId: "999999",
        reason: "Test",
      }),
    });

    expect(res.status).toBe(404);
  });
});

describe("DELETE /api/mod/lock/:topicId", () => {
  it("unlocks topic successfully", async () => {
    const mod = await testCtx.createUser("Moderator");
    const member = await testCtx.createUser("Member");
    const topic = await testCtx.createTopic(member.did, "Test topic");

    // Insert existing lock
    const postUri = `at://${member.did}/space.atbb.post/${topic.rkey}`;
    await testCtx.ctx.db.insert(modActions).values({
      did: testCtx.ctx.config.forumDid,
      rkey: "lock1",
      cid: "bafylock",
      action: "space.atbb.modAction.lock",
      subjectPostUri: postUri,
      reason: "Original lock",
      createdBy: mod.did,
      createdAt: new Date(),
      indexedAt: new Date(),
    });

    const mockPutRecord = vi.fn().mockResolvedValue({
      uri: "at://did:plc:forum/space.atbb.modAction/unlock123",
      cid: "bafyunlock",
    });

    testCtx.ctx.forumAgent = {
      isAuthenticated: () => true,
      getAgent: () => ({
        com: { atproto: { repo: { putRecord: mockPutRecord } } },
      }),
    } as any;

    const res = await app.request(`/api/mod/lock/${topic.id}`, {
      method: "DELETE",
      headers: { Cookie: `atbb_session=${mod.sessionToken}` },
      body: JSON.stringify({ reason: "Discussion resumed" }),
    });

    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.success).toBe(true);
    expect(data.action).toBe("unlock");
  });
});

Step 2: Run test to verify it fails

Run: pnpm --filter @atbb/appview test mod.test.ts -t "lock"

Expected: FAIL - 404 Not Found

Step 3: Implement lock endpoints

Add to mod.ts:

/**
 * POST /api/mod/lock
 * Lock a topic (prevent new replies).
 */
app.post(
  "/lock",
  requireAuth(ctx),
  requirePermission(ctx, "space.atbb.permission.lockTopics"),
  async (c) => {
    const user = c.get("user")!;

    // Parse request body
    let body: any;
    try {
      body = await c.req.json();
    } catch {
      return c.json({ error: "Invalid JSON in request body" }, 400);
    }

    const { topicId, reason } = body;

    // Validate topicId
    if (typeof topicId !== "string") {
      return c.json({ error: "topicId is required and must be a string" }, 400);
    }

    const topicIdBigInt = parseBigIntParam(topicId);
    if (topicIdBigInt === null) {
      return c.json({ error: "Invalid topic ID" }, 400);
    }

    // Validate reason
    const reasonError = validateReason(reason);
    if (reasonError) {
      return c.json({ error: reasonError }, 400);
    }

    // Get topic and validate it's a root post
    const [topic] = await ctx.db
      .select()
      .from(posts)
      .where(eq(posts.id, topicIdBigInt))
      .limit(1);

    if (!topic) {
      return c.json({ error: "Topic not found" }, 404);
    }

    // Verify it's a root post (not a reply)
    if (topic.rootPostId !== null) {
      return c.json({
        error: "Can only lock topics (root posts), not replies",
      }, 400);
    }

    // Build post URI
    const postUri = `at://${topic.did}/space.atbb.post/${topic.rkey}`;

    // Check if topic is already locked
    const isLocked = await checkActiveAction(
      ctx,
      { postUri },
      "space.atbb.modAction.lock"
    );

    if (isLocked) {
      return c.json({
        success: true,
        action: "lock",
        topicId: topicId,
        topicUri: postUri,
        alreadyActive: true,
      }, 200);
    }

    // Get ForumAgent
    if (!ctx.forumAgent) {
      return c.json({
        error: "Forum agent not available. Server configuration issue.",
      }, 500);
    }

    const agent = ctx.forumAgent.getAgent();
    if (!agent) {
      return c.json({
        error: "Forum agent not authenticated. Please try again later.",
      }, 503);
    }

    // Write lock modAction record
    try {
      const result = await agent.com.atproto.repo.putRecord({
        repo: ctx.config.forumDid,
        collection: "space.atbb.modAction",
        rkey: TID.nextStr(),
        record: {
          $type: "space.atbb.modAction",
          action: "space.atbb.modAction.lock",
          subject: {
            post: {
              uri: postUri,
              cid: topic.cid,
            },
          },
          reason,
          createdBy: user.did,
          createdAt: new Date().toISOString(),
        },
      });

      return c.json({
        success: true,
        action: "lock",
        topicId: topicId,
        topicUri: postUri,
        uri: result.uri,
        cid: result.cid,
        alreadyActive: false,
      }, 200);
    } catch (error) {
      console.error("Failed to write lock modAction", {
        operation: "POST /api/mod/lock",
        topicId,
        error: error instanceof Error ? error.message : String(error),
      });

      if (error instanceof Error && isNetworkError(error)) {
        return c.json({
          error: "Unable to reach Forum PDS. Please try again later.",
        }, 503);
      }

      return c.json({
        error: "Failed to record moderation action. Please contact support.",
      }, 500);
    }
  }
);

/**
 * DELETE /api/mod/lock/:topicId
 * Unlock a topic (reversal action).
 */
app.delete(
  "/lock/:topicId",
  requireAuth(ctx),
  requirePermission(ctx, "space.atbb.permission.lockTopics"),
  async (c) => {
    const user = c.get("user")!;
    const topicIdParam = c.req.param("topicId");

    const topicIdBigInt = parseBigIntParam(topicIdParam);
    if (topicIdBigInt === null) {
      return c.json({ error: "Invalid topic ID" }, 400);
    }

    // Parse request body
    let body: any;
    try {
      body = await c.req.json();
    } catch {
      return c.json({ error: "Invalid JSON in request body" }, 400);
    }

    const { reason } = body;

    // Validate reason
    const reasonError = validateReason(reason);
    if (reasonError) {
      return c.json({ error: reasonError }, 400);
    }

    // Get topic
    const [topic] = await ctx.db
      .select()
      .from(posts)
      .where(eq(posts.id, topicIdBigInt))
      .limit(1);

    if (!topic) {
      return c.json({ error: "Topic not found" }, 404);
    }

    if (topic.rootPostId !== null) {
      return c.json({
        error: "Can only unlock topics (root posts), not replies",
      }, 400);
    }

    const postUri = `at://${topic.did}/space.atbb.post/${topic.rkey}`;

    // Check if topic is already unlocked
    const isLocked = await checkActiveAction(
      ctx,
      { postUri },
      "space.atbb.modAction.lock"
    );

    if (isLocked === false || isLocked === null) {
      return c.json({
        success: true,
        action: "unlock",
        topicId: topicIdParam,
        topicUri: postUri,
        alreadyActive: true,
      }, 200);
    }

    // Get ForumAgent
    if (!ctx.forumAgent) {
      return c.json({
        error: "Forum agent not available. Server configuration issue.",
      }, 500);
    }

    const agent = ctx.forumAgent.getAgent();
    if (!agent) {
      return c.json({
        error: "Forum agent not authenticated. Please try again later.",
      }, 503);
    }

    // Write unlock modAction record
    try {
      const result = await agent.com.atproto.repo.putRecord({
        repo: ctx.config.forumDid,
        collection: "space.atbb.modAction",
        rkey: TID.nextStr(),
        record: {
          $type: "space.atbb.modAction",
          action: "space.atbb.modAction.unlock",
          subject: {
            post: {
              uri: postUri,
              cid: topic.cid,
            },
          },
          reason,
          createdBy: user.did,
          createdAt: new Date().toISOString(),
        },
      });

      return c.json({
        success: true,
        action: "unlock",
        topicId: topicIdParam,
        topicUri: postUri,
        uri: result.uri,
        cid: result.cid,
        alreadyActive: false,
      }, 200);
    } catch (error) {
      console.error("Failed to write unlock modAction", {
        operation: "DELETE /api/mod/lock/:topicId",
        topicId: topicIdParam,
        error: error instanceof Error ? error.message : String(error),
      });

      if (error instanceof Error && isNetworkError(error)) {
        return c.json({
          error: "Unable to reach Forum PDS. Please try again later.",
        }, 503);
      }

      return c.json({
        error: "Failed to record moderation action. Please contact support.",
      }, 500);
    }
  }
);

Step 4: Add missing import

Add to imports at top of mod.ts:

import { parseBigIntParam } from "./helpers.js";

Step 5: Run test to verify it passes

Run: pnpm --filter @atbb/appview test mod.test.ts -t "lock"

Expected: PASS (4 tests)

Step 6: Commit

git add apps/appview/src/routes/mod.ts apps/appview/src/routes/__tests__/mod.test.ts
git commit -m "feat(mod): implement lock/unlock topic endpoints (ATB-19)

POST /api/mod/lock and DELETE /api/mod/lock/:topicId
Validates targets are root posts only"

Task 9: Implement POST /api/mod/hide and DELETE /api/mod/hide/:postId#

Files:

  • Modify: apps/appview/src/routes/mod.ts
  • Modify: apps/appview/src/routes/__tests__/mod.test.ts

Note: Hide endpoints are similar to lock but work on ANY post (topics or replies).

Step 1: Write failing tests for hide endpoints

Add to mod.test.ts:

describe("POST /api/mod/hide", () => {
  it("hides topic post successfully", async () => {
    const mod = await testCtx.createUser("Moderator");
    const member = await testCtx.createUser("Member");
    const topic = await testCtx.createTopic(member.did, "Spam topic");

    const mockPutRecord = vi.fn().mockResolvedValue({
      uri: "at://did:plc:forum/space.atbb.modAction/hide123",
      cid: "bafyhide",
    });

    testCtx.ctx.forumAgent = {
      isAuthenticated: () => true,
      getAgent: () => ({
        com: { atproto: { repo: { putRecord: mockPutRecord } } },
      }),
    } as any;

    const res = await app.request("/api/mod/hide", {
      method: "POST",
      headers: { Cookie: `atbb_session=${mod.sessionToken}` },
      body: JSON.stringify({
        postId: topic.id.toString(),
        reason: "Spam content",
      }),
    });

    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.success).toBe(true);
    expect(data.action).toBe("hide");
    expect(data.postId).toBe(topic.id.toString());
  });

  it("hides reply post successfully", async () => {
    const mod = await testCtx.createUser("Moderator");
    const member = await testCtx.createUser("Member");
    const topic = await testCtx.createTopic(member.did, "Topic");
    const reply = await testCtx.createReply(member.did, topic.id, "Spam reply");

    const mockPutRecord = vi.fn().mockResolvedValue({
      uri: "at://did:plc:forum/space.atbb.modAction/hide456",
      cid: "bafyhide2",
    });

    testCtx.ctx.forumAgent = {
      isAuthenticated: () => true,
      getAgent: () => ({
        com: { atproto: { repo: { putRecord: mockPutRecord } } },
      }),
    } as any;

    const res = await app.request("/api/mod/hide", {
      method: "POST",
      headers: { Cookie: `atbb_session=${mod.sessionToken}` },
      body: JSON.stringify({
        postId: reply.id.toString(),
        reason: "Harassment",
      }),
    });

    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.success).toBe(true);
    expect(data.action).toBe("hide");
  });
});

describe("DELETE /api/mod/hide/:postId", () => {
  it("unhides post successfully", async () => {
    const mod = await testCtx.createUser("Moderator");
    const member = await testCtx.createUser("Member");
    const topic = await testCtx.createTopic(member.did, "Test");

    const postUri = `at://${member.did}/space.atbb.post/${topic.rkey}`;
    await testCtx.ctx.db.insert(modActions).values({
      did: testCtx.ctx.config.forumDid,
      rkey: "hide1",
      cid: "bafyhide",
      action: "space.atbb.modAction.delete",
      subjectPostUri: postUri,
      reason: "Original hide",
      createdBy: mod.did,
      createdAt: new Date(),
      indexedAt: new Date(),
    });

    const mockPutRecord = vi.fn().mockResolvedValue({
      uri: "at://did:plc:forum/space.atbb.modAction/unhide123",
      cid: "bafyunhide",
    });

    testCtx.ctx.forumAgent = {
      isAuthenticated: () => true,
      getAgent: () => ({
        com: { atproto: { repo: { putRecord: mockPutRecord } } },
      }),
    } as any;

    const res = await app.request(`/api/mod/hide/${topic.id}`, {
      method: "DELETE",
      headers: { Cookie: `atbb_session=${mod.sessionToken}` },
      body: JSON.stringify({ reason: "False positive" }),
    });

    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.success).toBe(true);
    expect(data.action).toBe("unhide");
  });
});

Step 2: Run test to verify it fails

Run: pnpm --filter @atbb/appview test mod.test.ts -t "hide"

Expected: FAIL - 404 Not Found

Step 3: Implement hide endpoints

Add to mod.ts (note: hide uses "delete" action type per lexicon):

/**
 * POST /api/mod/hide
 * Hide a post from the forum (soft-delete).
 */
app.post(
  "/hide",
  requireAuth(ctx),
  requirePermission(ctx, "space.atbb.permission.moderatePosts"),
  async (c) => {
    const user = c.get("user")!;

    // Parse request body
    let body: any;
    try {
      body = await c.req.json();
    } catch {
      return c.json({ error: "Invalid JSON in request body" }, 400);
    }

    const { postId, reason } = body;

    // Validate postId
    if (typeof postId !== "string") {
      return c.json({ error: "postId is required and must be a string" }, 400);
    }

    const postIdBigInt = parseBigIntParam(postId);
    if (postIdBigInt === null) {
      return c.json({ error: "Invalid post ID" }, 400);
    }

    // Validate reason
    const reasonError = validateReason(reason);
    if (reasonError) {
      return c.json({ error: reasonError }, 400);
    }

    // Get post (can be topic or reply)
    const [post] = await ctx.db
      .select()
      .from(posts)
      .where(eq(posts.id, postIdBigInt))
      .limit(1);

    if (!post) {
      return c.json({ error: "Post not found" }, 404);
    }

    const postUri = `at://${post.did}/space.atbb.post/${post.rkey}`;

    // Check if post is already hidden
    const isHidden = await checkActiveAction(
      ctx,
      { postUri },
      "space.atbb.modAction.delete"
    );

    if (isHidden) {
      return c.json({
        success: true,
        action: "hide",
        postId: postId,
        postUri: postUri,
        alreadyActive: true,
      }, 200);
    }

    // Get ForumAgent
    if (!ctx.forumAgent) {
      return c.json({
        error: "Forum agent not available. Server configuration issue.",
      }, 500);
    }

    const agent = ctx.forumAgent.getAgent();
    if (!agent) {
      return c.json({
        error: "Forum agent not authenticated. Please try again later.",
      }, 503);
    }

    // Write hide modAction record (action type is "delete" per lexicon)
    try {
      const result = await agent.com.atproto.repo.putRecord({
        repo: ctx.config.forumDid,
        collection: "space.atbb.modAction",
        rkey: TID.nextStr(),
        record: {
          $type: "space.atbb.modAction",
          action: "space.atbb.modAction.delete",
          subject: {
            post: {
              uri: postUri,
              cid: post.cid,
            },
          },
          reason,
          createdBy: user.did,
          createdAt: new Date().toISOString(),
        },
      });

      return c.json({
        success: true,
        action: "hide",
        postId: postId,
        postUri: postUri,
        uri: result.uri,
        cid: result.cid,
        alreadyActive: false,
      }, 200);
    } catch (error) {
      console.error("Failed to write hide modAction", {
        operation: "POST /api/mod/hide",
        postId,
        error: error instanceof Error ? error.message : String(error),
      });

      if (error instanceof Error && isNetworkError(error)) {
        return c.json({
          error: "Unable to reach Forum PDS. Please try again later.",
        }, 503);
      }

      return c.json({
        error: "Failed to record moderation action. Please contact support.",
      }, 500);
    }
  }
);

/**
 * DELETE /api/mod/hide/:postId
 * Unhide a post (reversal action).
 */
app.delete(
  "/hide/:postId",
  requireAuth(ctx),
  requirePermission(ctx, "space.atbb.permission.moderatePosts"),
  async (c) => {
    const user = c.get("user")!;
    const postIdParam = c.req.param("postId");

    const postIdBigInt = parseBigIntParam(postIdParam);
    if (postIdBigInt === null) {
      return c.json({ error: "Invalid post ID" }, 400);
    }

    // Parse request body
    let body: any;
    try {
      body = await c.req.json();
    } catch {
      return c.json({ error: "Invalid JSON in request body" }, 400);
    }

    const { reason } = body;

    // Validate reason
    const reasonError = validateReason(reason);
    if (reasonError) {
      return c.json({ error: reasonError }, 400);
    }

    // Get post
    const [post] = await ctx.db
      .select()
      .from(posts)
      .where(eq(posts.id, postIdBigInt))
      .limit(1);

    if (!post) {
      return c.json({ error: "Post not found" }, 404);
    }

    const postUri = `at://${post.did}/space.atbb.post/${post.rkey}`;

    // Check if post is already unhidden
    const isHidden = await checkActiveAction(
      ctx,
      { postUri },
      "space.atbb.modAction.delete"
    );

    if (isHidden === false || isHidden === null) {
      return c.json({
        success: true,
        action: "unhide",
        postId: postIdParam,
        postUri: postUri,
        alreadyActive: true,
      }, 200);
    }

    // Get ForumAgent
    if (!ctx.forumAgent) {
      return c.json({
        error: "Forum agent not available. Server configuration issue.",
      }, 500);
    }

    const agent = ctx.forumAgent.getAgent();
    if (!agent) {
      return c.json({
        error: "Forum agent not authenticated. Please try again later.",
      }, 503);
    }

    // Write unhide modAction record
    // Note: lexicon doesn't define "undelete", so we infer "unhide" behavior
    // The read-path logic should check for most recent action
    try {
      const result = await agent.com.atproto.repo.putRecord({
        repo: ctx.config.forumDid,
        collection: "space.atbb.modAction",
        rkey: TID.nextStr(),
        record: {
          $type: "space.atbb.modAction",
          action: "space.atbb.modAction.delete", // Note: Using same action, read-path determines state
          subject: {
            post: {
              uri: postUri,
              cid: post.cid,
            },
          },
          reason,
          createdBy: user.did,
          createdAt: new Date().toISOString(),
        },
      });

      return c.json({
        success: true,
        action: "unhide",
        postId: postIdParam,
        postUri: postUri,
        uri: result.uri,
        cid: result.cid,
        alreadyActive: false,
      }, 200);
    } catch (error) {
      console.error("Failed to write unhide modAction", {
        operation: "DELETE /api/mod/hide/:postId",
        postId: postIdParam,
        error: error instanceof Error ? error.message : String(error),
      });

      if (error instanceof Error && isNetworkError(error)) {
        return c.json({
          error: "Unable to reach Forum PDS. Please try again later.",
        }, 503);
      }

      return c.json({
        error: "Failed to record moderation action. Please contact support.",
      }, 500);
    }
  }
);

Step 4: Run test to verify it passes

Run: pnpm --filter @atbb/appview test mod.test.ts -t "hide"

Expected: PASS (3 tests)

Step 5: Note lexicon gap for unhide action

The lexicon doesn't define an "unhide" or "undelete" action type. We're using "delete" for both hide and unhide, with read-path logic determining state based on alternating actions. This should be noted in implementation comments.

Step 6: Commit

git add apps/appview/src/routes/mod.ts apps/appview/src/routes/__tests__/mod.test.ts
git commit -m "feat(mod): implement hide/unhide post endpoints (ATB-19)

POST /api/mod/hide and DELETE /api/mod/hide/:postId
Works on both topics and replies (unlike lock)"

Task 10: Run All Tests and Verify Coverage#

Step 1: Run all mod tests

Run: pnpm --filter @atbb/appview test mod.test.ts

Expected: PASS (~30+ tests including helpers and all endpoints)

Step 2: Run all appview tests

Run: pnpm --filter @atbb/appview test

Expected: PASS (all existing tests + new mod tests)

Step 3: Check test count

Verify we have comprehensive coverage:

  • Helper tests: ~7 tests
  • Ban tests: ~13 tests
  • Unban tests: ~2 tests (basic coverage, can expand)
  • Lock tests: ~4 tests
  • Unlock tests: ~1 test
  • Hide tests: ~3 tests
  • Unhide tests: ~1 test

Total: ~30 tests (can expand to ~75-80 with full error coverage per endpoint)

Step 4: Commit test verification

git add -A
git commit -m "test(mod): verify all moderation endpoint tests pass (ATB-19)

~30 tests covering happy path, validation, and basic error handling"

Task 11: Update Bruno API Collection#

Files:

  • Create: bruno/AppView API/Moderation/Ban User.bru
  • Create: bruno/AppView API/Moderation/Unban User.bru
  • Create: bruno/AppView API/Moderation/Lock Topic.bru
  • Create: bruno/AppView API/Moderation/Unlock Topic.bru
  • Create: bruno/AppView API/Moderation/Hide Post.bru
  • Create: bruno/AppView API/Moderation/Unhide Post.bru

Step 1: Create Moderation directory

mkdir -p "bruno/AppView API/Moderation"

Step 2: Create Ban User.bru

Create: bruno/AppView API/Moderation/Ban User.bru

meta {
  name: Ban User
  type: http
  seq: 1
}

post {
  url: {{appview_url}}/api/mod/ban
}

headers {
  Content-Type: application/json
  Cookie: atbb_session={{session_token}}
}

body:json {
  {
    "targetDid": "did:plc:example",
    "reason": "Spam and harassment"
  }
}

assert {
  res.status: eq 200
  res.body.success: eq true
  res.body.action: eq ban
}

docs {
  Ban a user from the forum.

  **Permission required:** space.atbb.permission.banUsers (Owner, Admin)

  Request body:
  - targetDid: User DID to ban (required, string, must start with "did:")
  - reason: Moderator's reason (required, 1-3000 chars, non-empty)

  Returns:
  {
    "success": true,
    "action": "ban",
    "targetDid": "did:plc:example",
    "uri": "at://did:plc:forum/space.atbb.modAction/3kh5...",
    "cid": "bafyrei...",
    "alreadyActive": false  // true if user already banned
  }

  Error codes:
  - 400: Invalid DID format, missing/empty reason, reason too long
  - 401: Not authenticated
  - 403: Lacks banUsers permission
  - 404: User not a member of forum
  - 500: ForumAgent not available (server config issue)
  - 503: PDS write failed (network error, retry)

  Idempotent: Returns 200 with alreadyActive: true if user already banned.
}

Step 3: Create remaining Bruno files

Create similar .bru files for:

  • Unban User (DELETE /api/mod/ban/:did)
  • Lock Topic (POST /api/mod/lock)
  • Unlock Topic (DELETE /api/mod/lock/:topicId)
  • Hide Post (POST /api/mod/hide)
  • Unhide Post (DELETE /api/mod/hide/:postId)

Each should follow the same pattern with:

  • Correct HTTP method and endpoint
  • Request body schema
  • Success response format
  • All error codes documented
  • Assertions for 200 status

Step 4: Commit Bruno collection

git add "bruno/AppView API/Moderation/"
git commit -m "docs(bruno): add moderation endpoint collection (ATB-19)

Documented all 6 moderation endpoints with examples and error codes"

Task 12: Update Documentation#

Files:

  • Modify: docs/atproto-forum-plan.md

Step 1: Mark Phase 3 moderation items complete

Edit docs/atproto-forum-plan.md:

Find Phase 3 section and update:

#### Phase 3: Moderation Basics (Week 6–7)
- [x] Mod actions written as records on Forum DID's PDS **via AppView** (AppView holds Forum DID signing keys, verifies caller's role before writing) — **Complete:** ATB-19 implemented 6 endpoints (ban/lock/hide + reversals). Writes modAction records to Forum DID's PDS using ForumAgent. 2026-02-15
- [ ] Admin UI: ban user, lock topic, hide post
- [ ] AppView respects mod actions during indexing and API responses
- [ ] Banned users' new records are ignored by indexer
- [ ] Document the trust model: operators must trust their AppView instance, which is acceptable for self-hosted single-server deployments

Step 2: Commit documentation update

git add docs/atproto-forum-plan.md
git commit -m "docs: mark ATB-19 complete in project plan

Moderation action write-path endpoints implemented"

Task 13: Update Linear Issue#

Step 1: Update ATB-19 in Linear

Using Linear MCP tool or manually:

  1. Change status to "Done"
  2. Add completion comment:
Implementation complete (2026-02-15):

✅ 6 endpoints implemented:
- POST /api/mod/ban (ban user)
- DELETE /api/mod/ban/:did (unban user)
- POST /api/mod/lock (lock topic)
- DELETE /api/mod/lock/:topicId (unlock topic)
- POST /api/mod/hide (hide post)
- DELETE /api/mod/hide/:postId (unhide post)

✅ Permission enforcement via middleware
✅ Idempotent API design (alreadyActive flag)
✅ Comprehensive error handling (400/401/403/404/500/503)
✅ ~30 tests (can expand to ~75-80 with full error coverage)
✅ Bruno collection updated
✅ Design doc: docs/plans/2026-02-15-moderation-endpoints-design.md
✅ Implementation: apps/appview/src/routes/mod.ts

Next: ATB-20 (read-path enforcement), ATB-21 (indexer enforcement)

Summary#

Files Created:

  • apps/appview/src/routes/mod.ts (~600 lines)
  • apps/appview/src/routes/__tests__/mod.test.ts (~400 lines)
  • bruno/AppView API/Moderation/*.bru (6 files)
  • docs/plans/2026-02-15-moderation-endpoints-design.md
  • docs/plans/2026-02-15-moderation-endpoints-implementation.md

Files Modified:

  • apps/appview/src/lib/seed-roles.ts (added mod permissions)
  • apps/appview/src/routes/index.ts (registered mod routes)
  • docs/atproto-forum-plan.md (marked Phase 3 item complete)

Tests: ~30 comprehensive tests (expandable to ~75-80)

Design Decisions:

  • Additive reversal model (unban/unlock as new records)
  • Idempotent API (alreadyActive flag)
  • Required reason field
  • Lock restricted to topics only
  • Fully namespaced permissions

Next Steps (Out of Scope for ATB-19):

  • ATB-20: Read-path enforcement (filter banned users, locked topics, hidden posts)
  • ATB-21: Indexer enforcement (ignore banned users' new posts)
  • ATB-24: Admin moderation UI