WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
5
fork

Configure Feed

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

ATB-46: Admin Mod Action Log Endpoint — Implementation Plan#

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

Goal: Add GET /api/admin/modlog to the AppView — a paginated, reverse-chronological mod action audit log that joins users twice for moderator and subject handles.

Architecture: Add requireAnyPermission to the permissions middleware (OR-based auth), then implement the route in admin.ts using Drizzle's alias() for the double users join. Tests live in two places: permissions.test.ts (unit tests for the new middleware) and admin.test.ts (route integration tests).

Tech Stack: Hono, Drizzle ORM (alias, desc, count), Vitest, PostgreSQL


Task 1: Add requireAnyPermission to the permissions middleware#

Files:

  • Modify: apps/appview/src/middleware/permissions.ts

Step 1: Write the failing unit tests

Open apps/appview/src/middleware/__tests__/permissions.test.ts and add a new describe block at the bottom (inside the outer describe, before the closing }):

describe("requireAnyPermission", () => {
  it("calls next() when user has one of the required permissions", async () => {
    // Create a role with moderatePosts permission
    const [modRole] = await ctx.db.insert(roles).values({
      did: ctx.config.forumDid,
      rkey: "mod-role-anytest",
      cid: "test-cid",
      name: "Moderator",
      description: "Test moderator role",
      priority: 20,
      createdAt: new Date(),
      indexedAt: new Date(),
    }).returning({ id: roles.id });

    await ctx.db.insert(rolePermissions).values([
      { roleId: modRole.id, permission: "space.atbb.permission.moderatePosts" },
    ]);

    await ctx.db.insert(users).values({
      did: "did:plc:test-anyperm-mod",
      handle: "anyperm-mod.bsky.social",
      indexedAt: new Date(),
    });

    await ctx.db.insert(memberships).values({
      did: "did:plc:test-anyperm-mod",
      rkey: "membership-anyperm-mod",
      cid: "test-cid",
      forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
      roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role-anytest`,
      createdAt: new Date(),
      indexedAt: new Date(),
    });

    // Build a tiny Hono app to test the middleware
    const { requireAnyPermission } = await import("../permissions.js");
    const testApp = new Hono<{ Variables: Variables }>();
    testApp.use("*", async (c, next) => {
      c.set("user", { did: "did:plc:test-anyperm-mod" });
      await next();
    });
    testApp.get("/test",
      requireAnyPermission(ctx, [
        "space.atbb.permission.moderatePosts",
        "space.atbb.permission.banUsers",
      ]),
      (c) => c.json({ ok: true })
    );

    const res = await testApp.request("/test");
    expect(res.status).toBe(200);
  });

  it("returns 403 when user has none of the required permissions", async () => {
    // Create a role with only createTopics (no mod permissions)
    const [memberRole] = await ctx.db.insert(roles).values({
      did: ctx.config.forumDid,
      rkey: "member-role-anytest",
      cid: "test-cid",
      name: "Member",
      description: "Test member role",
      priority: 30,
      createdAt: new Date(),
      indexedAt: new Date(),
    }).returning({ id: roles.id });

    await ctx.db.insert(rolePermissions).values([
      { roleId: memberRole.id, permission: "space.atbb.permission.createTopics" },
    ]);

    await ctx.db.insert(users).values({
      did: "did:plc:test-anyperm-member",
      handle: "anyperm-member.bsky.social",
      indexedAt: new Date(),
    });

    await ctx.db.insert(memberships).values({
      did: "did:plc:test-anyperm-member",
      rkey: "membership-anyperm-member",
      cid: "test-cid",
      forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
      roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/member-role-anytest`,
      createdAt: new Date(),
      indexedAt: new Date(),
    });

    const { requireAnyPermission } = await import("../permissions.js");
    const testApp = new Hono<{ Variables: Variables }>();
    testApp.use("*", async (c, next) => {
      c.set("user", { did: "did:plc:test-anyperm-member" });
      await next();
    });
    testApp.get("/test",
      requireAnyPermission(ctx, [
        "space.atbb.permission.moderatePosts",
        "space.atbb.permission.banUsers",
      ]),
      (c) => c.json({ ok: true })
    );

    const res = await testApp.request("/test");
    expect(res.status).toBe(403);
  });

  it("returns 401 when user is not authenticated", async () => {
    const { requireAnyPermission } = await import("../permissions.js");
    const testApp = new Hono<{ Variables: Variables }>();
    // No auth middleware — user is not set in context
    testApp.get("/test",
      requireAnyPermission(ctx, ["space.atbb.permission.moderatePosts"]),
      (c) => c.json({ ok: true })
    );

    const res = await testApp.request("/test");
    expect(res.status).toBe(401);
  });

  it("short-circuits on first matching permission (calls next on second perm if first fails)", async () => {
    // Role has banUsers but NOT moderatePosts
    const [banRole] = await ctx.db.insert(roles).values({
      did: ctx.config.forumDid,
      rkey: "ban-role-anytest",
      cid: "test-cid",
      name: "BanMod",
      description: "Test ban role",
      priority: 20,
      createdAt: new Date(),
      indexedAt: new Date(),
    }).returning({ id: roles.id });

    await ctx.db.insert(rolePermissions).values([
      { roleId: banRole.id, permission: "space.atbb.permission.banUsers" },
    ]);

    await ctx.db.insert(users).values({
      did: "did:plc:test-anyperm-ban",
      handle: "anyperm-ban.bsky.social",
      indexedAt: new Date(),
    });

    await ctx.db.insert(memberships).values({
      did: "did:plc:test-anyperm-ban",
      rkey: "membership-anyperm-ban",
      cid: "test-cid",
      forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
      roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/ban-role-anytest`,
      createdAt: new Date(),
      indexedAt: new Date(),
    });

    const { requireAnyPermission } = await import("../permissions.js");
    const testApp = new Hono<{ Variables: Variables }>();
    testApp.use("*", async (c, next) => {
      c.set("user", { did: "did:plc:test-anyperm-ban" });
      await next();
    });
    // Check moderatePosts first (fails), then banUsers (passes)
    testApp.get("/test",
      requireAnyPermission(ctx, [
        "space.atbb.permission.moderatePosts",
        "space.atbb.permission.banUsers",
      ]),
      (c) => c.json({ ok: true })
    );

    const res = await testApp.request("/test");
    expect(res.status).toBe(200);
  });
});

Also add these imports at the top of permissions.test.ts (alongside existing imports):

import { Hono } from "hono";
import type { Variables } from "../../types.js";

Step 2: Run the tests to verify they fail

PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \
  pnpm --filter @atbb/appview exec vitest run \
  src/middleware/__tests__/permissions.test.ts

Expected: FAIL — requireAnyPermission is not a function (not exported yet)

Step 3: Implement requireAnyPermission in permissions.ts

Add this function just before the export { checkPermission, getUserRole, checkMinRole } line at the bottom of apps/appview/src/middleware/permissions.ts:

/**
 * Require any of the listed permissions (OR logic).
 *
 * Returns 401 if not authenticated, 403 if authenticated but lacks all listed permissions.
 * Short-circuits on first match — no unnecessary DB queries.
 */
export function requireAnyPermission(
  ctx: AppContext,
  permissions: string[]
) {
  return async (c: Context<{ Variables: Variables }>, next: Next) => {
    const user = c.get("user");

    if (!user) {
      return c.json({ error: "Authentication required" }, 401);
    }

    for (const permission of permissions) {
      if (await checkPermission(ctx, user.did, permission)) {
        return next();
      }
    }

    return c.json({ error: "Insufficient permissions" }, 403);
  };
}

Step 4: Run the tests to verify they pass

PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \
  pnpm --filter @atbb/appview exec vitest run \
  src/middleware/__tests__/permissions.test.ts

Expected: All tests pass (including the 4 new ones).

Step 5: Commit

git add apps/appview/src/middleware/permissions.ts \
        apps/appview/src/middleware/__tests__/permissions.test.ts
git commit -m "feat(appview): add requireAnyPermission middleware (ATB-46)"

Task 2: Write failing modlog route tests#

Files:

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

Step 1: Update the permissions mock to include requireAnyPermission

Find the vi.mock("../../middleware/permissions.js", ...) block near the top of admin.test.ts. Add requireAnyPermission to it, and add a mockRequireAnyPermissionPass variable so the 403 test can control the mock:

Add this variable declaration alongside the other mock variables at the top (near let mockUser):

let mockRequireAnyPermissionPass = true;

Update the vi.mock block to add requireAnyPermission:

vi.mock("../../middleware/permissions.js", () => ({
  requirePermission: vi.fn(() => async (_c: any, next: any) => {
    await next();
  }),
  requireAnyPermission: vi.fn(() => async (c: any, next: any) => {
    if (!mockRequireAnyPermissionPass) {
      return c.json({ error: "Insufficient permissions" }, 403);
    }
    await next();
  }),
  getUserRole: (...args: any[]) => mockGetUserRole(...args),
  checkPermission: vi.fn().mockResolvedValue(true),
}));

Also reset mockRequireAnyPermissionPass = true in the beforeEach block alongside mockUser and mockGetUserRole.mockClear().

Step 2: Write the failing modlog tests

Add a new describe("GET /api/admin/modlog", ...) block at the bottom of the describe.sequential("Admin Routes", ...) block in admin.test.ts:

describe("GET /api/admin/modlog", () => {
  it("returns 401 when not authenticated", async () => {
    mockUser = null;
    const res = await app.request("/api/admin/modlog");
    expect(res.status).toBe(401);
  });

  it("returns 403 when user lacks all mod permissions", async () => {
    mockRequireAnyPermissionPass = false;
    const res = await app.request("/api/admin/modlog");
    expect(res.status).toBe(403);
  });

  it("returns empty list when no mod actions exist", async () => {
    const res = await app.request("/api/admin/modlog");
    expect(res.status).toBe(200);
    const data = await res.json() as any;
    expect(data.actions).toEqual([]);
    expect(data.total).toBe(0);
    expect(data.offset).toBe(0);
    expect(data.limit).toBe(50);
  });

  it("returns paginated mod actions with moderator and subject handles", async () => {
    // Insert moderator user
    await ctx.db.insert(users).values({
      did: "did:plc:mod-alice",
      handle: "alice.bsky.social",
      indexedAt: new Date(),
    });

    // Insert subject user
    await ctx.db.insert(users).values({
      did: "did:plc:subject-bob",
      handle: "bob.bsky.social",
      indexedAt: new Date(),
    });

    // Insert a user-targeting action (ban)
    await ctx.db.insert(modActions).values({
      did: ctx.config.forumDid,
      rkey: "modaction-ban-1",
      cid: "cid-ban-1",
      action: "space.atbb.modAction.ban",
      subjectDid: "did:plc:subject-bob",
      subjectPostUri: null,
      createdBy: "did:plc:mod-alice",
      reason: "Spam",
      createdAt: new Date("2026-02-26T12:01:00Z"),
      indexedAt: new Date(),
    });

    const res = await app.request("/api/admin/modlog");
    expect(res.status).toBe(200);

    const data = await res.json() as any;
    expect(data.total).toBe(1);
    expect(data.actions).toHaveLength(1);

    const action = data.actions[0];
    expect(action.id).toBe(typeof action.id === "string" ? action.id : String(action.id)); // serialized as string
    expect(action.action).toBe("space.atbb.modAction.ban");
    expect(action.moderatorDid).toBe("did:plc:mod-alice");
    expect(action.moderatorHandle).toBe("alice.bsky.social");
    expect(action.subjectDid).toBe("did:plc:subject-bob");
    expect(action.subjectHandle).toBe("bob.bsky.social");
    expect(action.subjectPostUri).toBeNull();
    expect(action.reason).toBe("Spam");
    expect(action.createdAt).toBe("2026-02-26T12:01:00.000Z");
  });

  it("returns null subjectHandle and populated subjectPostUri for post-targeting actions", async () => {
    await ctx.db.insert(users).values({
      did: "did:plc:mod-carol",
      handle: "carol.bsky.social",
      indexedAt: new Date(),
    });

    // Insert a post-targeting action (hide) — no subjectDid
    await ctx.db.insert(modActions).values({
      did: ctx.config.forumDid,
      rkey: "modaction-hide-1",
      cid: "cid-hide-1",
      action: "space.atbb.modAction.hide",
      subjectDid: null,
      subjectPostUri: "at://did:plc:user/space.atbb.post/abc123",
      createdBy: "did:plc:mod-carol",
      reason: "Inappropriate",
      createdAt: new Date("2026-02-26T11:30:00Z"),
      indexedAt: new Date(),
    });

    const res = await app.request("/api/admin/modlog");
    expect(res.status).toBe(200);

    const data = await res.json() as any;
    const action = data.actions.find((a: any) => a.action === "space.atbb.modAction.hide");
    expect(action).toBeDefined();
    expect(action.subjectDid).toBeNull();
    expect(action.subjectHandle).toBeNull();
    expect(action.subjectPostUri).toBe("at://did:plc:user/space.atbb.post/abc123");
  });

  it("falls back to moderatorDid when moderator has no handle indexed", async () => {
    // Insert moderator user WITHOUT a handle
    await ctx.db.insert(users).values({
      did: "did:plc:mod-nohandle",
      handle: null,
      indexedAt: new Date(),
    });

    await ctx.db.insert(modActions).values({
      did: ctx.config.forumDid,
      rkey: "modaction-nohandle-1",
      cid: "cid-nohandle-1",
      action: "space.atbb.modAction.ban",
      subjectDid: null,
      subjectPostUri: null,
      createdBy: "did:plc:mod-nohandle",
      reason: "Test",
      createdAt: new Date(),
      indexedAt: new Date(),
    });

    const res = await app.request("/api/admin/modlog");
    expect(res.status).toBe(200);

    const data = await res.json() as any;
    const action = data.actions.find((a: any) => a.moderatorDid === "did:plc:mod-nohandle");
    expect(action).toBeDefined();
    expect(action.moderatorHandle).toBe("did:plc:mod-nohandle"); // falls back to DID
  });

  it("returns actions in createdAt DESC order", async () => {
    await ctx.db.insert(users).values({
      did: "did:plc:mod-order-test",
      handle: "ordertest.bsky.social",
      indexedAt: new Date(),
    });

    const now = Date.now();
    await ctx.db.insert(modActions).values([
      {
        did: ctx.config.forumDid,
        rkey: "modaction-old",
        cid: "cid-old",
        action: "space.atbb.modAction.ban",
        subjectDid: null,
        subjectPostUri: null,
        createdBy: "did:plc:mod-order-test",
        reason: "Old action",
        createdAt: new Date(now - 10000),
        indexedAt: new Date(),
      },
      {
        did: ctx.config.forumDid,
        rkey: "modaction-new",
        cid: "cid-new",
        action: "space.atbb.modAction.hide",
        subjectDid: null,
        subjectPostUri: null,
        createdBy: "did:plc:mod-order-test",
        reason: "New action",
        createdAt: new Date(now),
        indexedAt: new Date(),
      },
    ]);

    const res = await app.request("/api/admin/modlog");
    const data = await res.json() as any;

    // Newest action should appear first
    expect(data.actions[0].reason).toBe("New action");
    expect(data.actions[1].reason).toBe("Old action");
  });

  it("respects limit and offset query params", async () => {
    await ctx.db.insert(users).values({
      did: "did:plc:mod-pagination",
      handle: "pagination.bsky.social",
      indexedAt: new Date(),
    });

    // Insert 3 actions
    await ctx.db.insert(modActions).values([
      { did: ctx.config.forumDid, rkey: "pag-1", cid: "c1", action: "space.atbb.modAction.ban", subjectDid: null, subjectPostUri: null, createdBy: "did:plc:mod-pagination", reason: "A", createdAt: new Date(3000), indexedAt: new Date() },
      { did: ctx.config.forumDid, rkey: "pag-2", cid: "c2", action: "space.atbb.modAction.ban", subjectDid: null, subjectPostUri: null, createdBy: "did:plc:mod-pagination", reason: "B", createdAt: new Date(2000), indexedAt: new Date() },
      { did: ctx.config.forumDid, rkey: "pag-3", cid: "c3", action: "space.atbb.modAction.ban", subjectDid: null, subjectPostUri: null, createdBy: "did:plc:mod-pagination", reason: "C", createdAt: new Date(1000), indexedAt: new Date() },
    ]);

    // Page 1: limit=2, offset=0
    const page1 = await app.request("/api/admin/modlog?limit=2&offset=0");
    const data1 = await page1.json() as any;
    expect(data1.actions).toHaveLength(2);
    expect(data1.total).toBe(3);
    expect(data1.limit).toBe(2);
    expect(data1.offset).toBe(0);
    expect(data1.actions[0].reason).toBe("A"); // newest first

    // Page 2: limit=2, offset=2
    const page2 = await app.request("/api/admin/modlog?limit=2&offset=2");
    const data2 = await page2.json() as any;
    expect(data2.actions).toHaveLength(1);
    expect(data2.actions[0].reason).toBe("C");
  });

  it("returns 400 for non-numeric limit", async () => {
    const res = await app.request("/api/admin/modlog?limit=abc");
    expect(res.status).toBe(400);
    const data = await res.json() as any;
    expect(data.error).toMatch(/limit/i);
  });

  it("returns 400 for negative limit", async () => {
    const res = await app.request("/api/admin/modlog?limit=-1");
    expect(res.status).toBe(400);
  });

  it("returns 400 for negative offset", async () => {
    const res = await app.request("/api/admin/modlog?offset=-5");
    expect(res.status).toBe(400);
  });

  it("caps limit at 100", async () => {
    const res = await app.request("/api/admin/modlog?limit=999");
    expect(res.status).toBe(200);
    const data = await res.json() as any;
    expect(data.limit).toBe(100);
  });

  it("uses default limit=50 and offset=0 when not provided", async () => {
    const res = await app.request("/api/admin/modlog");
    expect(res.status).toBe(200);
    const data = await res.json() as any;
    expect(data.limit).toBe(50);
    expect(data.offset).toBe(0);
  });
});

Also add modActions to the import from @atbb/db at the top of admin.test.ts:

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

Step 3: Run the tests to verify they fail

PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \
  pnpm --filter @atbb/appview exec vitest run \
  src/routes/__tests__/admin.test.ts 2>&1 | tail -30

Expected: FAIL — GET /api/admin/modlog route not found (404).

Step 4: Commit the failing tests

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

Task 3: Implement the modlog route handler#

Files:

  • Modify: apps/appview/src/routes/admin.ts

Step 1: Update the imports in admin.ts

Find the existing import lines at the top of apps/appview/src/routes/admin.ts and make two changes:

  1. Add modActions to the @atbb/db import:
import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts, modActions } from "@atbb/db";
  1. Add desc and alias to the drizzle-orm import. Also add alias from drizzle-orm:
import { eq, and, sql, asc, desc, count } from "drizzle-orm";
import { alias } from "drizzle-orm";

Note: alias may be a named export from drizzle-orm. If it isn't available there, import it from drizzle-orm/pg-core instead:

import { alias } from "drizzle-orm/pg-core";

Step 2: Add the modlog route to admin.ts

Add this route just before the final return app; at the bottom of createAdminRoutes:

/**
 * GET /api/admin/modlog
 *
 * Paginated, reverse-chronological list of mod actions.
 * Joins users table twice: once for the moderator handle (via createdBy),
 * once for the subject handle (via subjectDid, nullable for post-targeting actions).
 *
 * Requires any of: moderatePosts, banUsers, lockTopics.
 */
app.get(
  "/modlog",
  requireAuth(ctx),
  requireAnyPermission(ctx, [
    "space.atbb.permission.moderatePosts",
    "space.atbb.permission.banUsers",
    "space.atbb.permission.lockTopics",
  ]),
  async (c) => {
    const rawLimit = c.req.query("limit");
    const rawOffset = c.req.query("offset");

    const limitVal = rawLimit !== undefined ? parseInt(rawLimit, 10) : 50;
    const offsetVal = rawOffset !== undefined ? parseInt(rawOffset, 10) : 0;

    if (rawLimit !== undefined && (isNaN(limitVal) || limitVal < 1)) {
      return c.json({ error: "limit must be a positive integer" }, 400);
    }
    if (rawOffset !== undefined && (isNaN(offsetVal) || offsetVal < 0)) {
      return c.json({ error: "offset must be a non-negative integer" }, 400);
    }

    const clampedLimit = Math.min(limitVal, 100);

    const moderatorUser = alias(users, "moderator_user");
    const subjectUser = alias(users, "subject_user");

    try {
      const [countResult, actions] = await Promise.all([
        ctx.db.select({ total: count() }).from(modActions),
        ctx.db
          .select({
            id: modActions.id,
            action: modActions.action,
            moderatorDid: modActions.createdBy,
            moderatorHandle: moderatorUser.handle,
            subjectDid: modActions.subjectDid,
            subjectHandle: subjectUser.handle,
            subjectPostUri: modActions.subjectPostUri,
            reason: modActions.reason,
            createdAt: modActions.createdAt,
          })
          .from(modActions)
          .innerJoin(moderatorUser, eq(modActions.createdBy, moderatorUser.did))
          .leftJoin(subjectUser, eq(modActions.subjectDid, subjectUser.did))
          .orderBy(desc(modActions.createdAt))
          .limit(clampedLimit)
          .offset(offsetVal),
      ]);

      const total = Number(countResult[0]?.total ?? 0);

      return c.json({
        actions: actions.map((a) => ({
          id: a.id.toString(),
          action: a.action,
          moderatorDid: a.moderatorDid,
          moderatorHandle: a.moderatorHandle ?? a.moderatorDid,
          subjectDid: a.subjectDid ?? null,
          subjectHandle: a.subjectHandle ?? null,
          subjectPostUri: a.subjectPostUri ?? null,
          reason: a.reason ?? null,
          createdAt: a.createdAt.toISOString(),
        })),
        total,
        offset: offsetVal,
        limit: clampedLimit,
      });
    } catch (error) {
      return handleRouteError(c, error, "Failed to retrieve mod action log", {
        operation: "GET /api/admin/modlog",
        logger: ctx.logger,
      });
    }
  }
);

Also add requireAnyPermission to the import from the permissions middleware at the top of admin.ts:

import { requireAuth } from "../middleware/auth.js";
import { requirePermission, requireAnyPermission, getUserRole } from "../middleware/permissions.js";

Step 3: Run the tests to verify they pass

PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \
  pnpm --filter @atbb/appview exec vitest run \
  src/routes/__tests__/admin.test.ts 2>&1 | tail -30

Expected: All modlog tests pass.

Step 4: Run the full test suite

PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \
  pnpm --filter @atbb/appview exec vitest run

Expected: All tests pass. If any pre-existing tests fail, investigate before committing.

Step 5: Commit

git add apps/appview/src/routes/admin.ts
git commit -m "feat(appview): GET /api/admin/modlog with double users join (ATB-46)"

Task 4: Add Bruno collection file#

Files:

  • Create: bruno/AppView API/Admin/Get Mod Action Log.bru

Step 1: Create the Bruno file

meta {
  name: Get Mod Action Log
  type: http
  seq: 13
}

get {
  url: {{appview_url}}/api/admin/modlog
}

params:query {
  limit: 50
  offset: 0
}

assert {
  res.status: eq 200
  res.body.actions: isArray
  res.body.total: isDefined
  res.body.offset: isDefined
  res.body.limit: isDefined
}

docs {
  Paginated, reverse-chronological list of moderation actions. Joins users
  table for both moderator and subject handles.

  **Requires:** any of `space.atbb.permission.moderatePosts`,
  `space.atbb.permission.banUsers`, or `space.atbb.permission.lockTopics`

  Query params:
  - limit: Max results per page (default: 50, max: 100)
  - offset: Number of records to skip (default: 0)

  Returns:
  {
    "actions": [
      {
        "id": "123",
        "action": "space.atbb.modAction.ban",
        "moderatorDid": "did:plc:abc",
        "moderatorHandle": "alice.bsky.social",
        "subjectDid": "did:plc:xyz",
        "subjectHandle": "bob.bsky.social",
        "subjectPostUri": null,
        "reason": "Spam",
        "createdAt": "2026-02-26T12:01:00Z"
      }
    ],
    "total": 42,
    "offset": 0,
    "limit": 50
  }

  Notes:
  - subjectDid / subjectHandle are null for post-targeting actions (hide/lock)
  - subjectPostUri is null for user-targeting actions (ban/unban)
  - moderatorHandle falls back to moderatorDid if no handle is indexed
  - Actions are ordered newest first (createdAt DESC)

  Error codes:
  - 400: Invalid limit or offset (non-numeric, negative)
  - 401: Not authenticated
  - 403: Authenticated but lacks all mod permissions
  - 500: Database error
}

Step 2: Commit

git add "bruno/AppView API/Admin/Get Mod Action Log.bru"
git commit -m "docs(bruno): GET /api/admin/modlog collection (ATB-46)"

Task 5: Final verification and Linear update#

Step 1: Run the full test suite one more time

PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \
  pnpm --filter @atbb/appview exec vitest run

Expected: All tests pass.

Step 2: Update Linear

  • Change ATB-46 status from BacklogIn Review
  • Add a comment: "Implemented GET /api/admin/modlog. Added requireAnyPermission middleware for OR-based auth. Double users join via Drizzle alias() for moderator and subject handles. Tests in permissions.test.ts and admin.test.ts. Bruno collection added."

Step 3: Move the plan doc to complete

mv docs/plans/2026-03-01-atb-46-modlog-endpoint.md docs/plans/complete/
mv docs/plans/2026-03-01-atb-46-modlog-endpoint-design.md docs/plans/complete/
git add docs/plans/complete/ docs/plans/
git commit -m "docs: move ATB-46 plan docs to complete (ATB-46)"

Quick Reference: File Paths#

File Action
apps/appview/src/middleware/permissions.ts Add requireAnyPermission function
apps/appview/src/middleware/__tests__/permissions.test.ts Add 4 tests for requireAnyPermission
apps/appview/src/routes/admin.ts Add imports (modActions, desc, alias, requireAnyPermission) + route
apps/appview/src/routes/__tests__/admin.test.ts Update mock + add 11 modlog tests
bruno/AppView API/Admin/Get Mod Action Log.bru New Bruno file