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:
- Add
modActionsto the@atbb/dbimport:
import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts, modActions } from "@atbb/db";
- Add
descandaliasto thedrizzle-ormimport. Also addaliasfromdrizzle-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 Backlog → In Review
- Add a comment: "Implemented
GET /api/admin/modlog. AddedrequireAnyPermissionmiddleware for OR-based auth. Double users join via Drizzlealias()for moderator and subject handles. Tests inpermissions.test.tsandadmin.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 |