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

feat(appview): admin mod action log endpoint (ATB-46) (#81)

* docs: ATB-46 mod action log endpoint design

Design for GET /api/admin/modlog — paginated mod action audit log with
double users join for moderator and subject handles, and requireAnyPermission
middleware for OR-based permission checks.

* docs: ATB-46 mod action log implementation plan

Step-by-step TDD plan for GET /api/admin/modlog — requireAnyPermission
middleware, Drizzle alias double join, route handler, and Bruno collection.

* feat(appview): add requireAnyPermission middleware (ATB-46)

* test(appview): failing tests for GET /api/admin/modlog (ATB-46)

* feat(appview): GET /api/admin/modlog with double users leftJoin (ATB-46)

* docs(bruno): GET /api/admin/modlog collection (ATB-46)

* docs: move ATB-46 plan docs to complete/

* fix(appview): scope modlog queries to forumDid (ATB-46)

authored by

Malpercio and committed by
GitHub
2a6f7873 58e8a160

+1644 -4
+2
apps/appview/src/lib/__tests__/test-context.ts
··· 101 101 await db.delete(posts).where(like(posts.did, "did:plc:test-%")).catch(() => {}); 102 102 await db.delete(memberships).where(like(memberships.did, "did:plc:test-%")).catch(() => {}); 103 103 await db.delete(users).where(like(users.did, "did:plc:test-%")).catch(() => {}); 104 + await db.delete(users).where(like(users.did, "did:plc:mod-%")).catch(() => {}); 105 + await db.delete(users).where(like(users.did, "did:plc:subject-%")).catch(() => {}); 104 106 await db.delete(boards).where(eq(boards.did, config.forumDid)).catch(() => {}); 105 107 await db.delete(categories).where(eq(categories.did, config.forumDid)).catch(() => {}); 106 108 await db.delete(roles).where(eq(roles.did, config.forumDid)).catch(() => {}); // cascades to role_permissions
+196
apps/appview/src/middleware/__tests__/permissions.test.ts
··· 1 1 import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 + import { Hono } from "hono"; 2 3 import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 3 4 import { roles, rolePermissions, memberships, users } from "@atbb/db"; 4 5 import { 5 6 checkPermission, 6 7 checkMinRole, 7 8 canActOnUser, 9 + requireAnyPermission, 8 10 } from "../permissions.js"; 11 + import type { Variables } from "../../types.js"; 9 12 10 13 describe("Permission Helper Functions", () => { 11 14 let ctx: TestContext; ··· 463 466 const result = await canActOnUser(ctx, "did:plc:test-mod5", "did:plc:test-admin5"); 464 467 465 468 expect(result).toBe(false); // Moderator (20) cannot act on Admin (10) 469 + }); 470 + }); 471 + 472 + describe("requireAnyPermission", () => { 473 + it("returns 200 when user has one of the required permissions", async () => { 474 + // Create a role with moderatePosts permission 475 + const [modRole] = await ctx.db.insert(roles).values({ 476 + did: ctx.config.forumDid, 477 + rkey: "mod-role-anyperm-1", 478 + cid: "test-cid", 479 + name: "Moderator", 480 + description: "Moderator role", 481 + priority: 20, 482 + createdAt: new Date(), 483 + indexedAt: new Date(), 484 + }).returning({ id: roles.id }); 485 + 486 + await ctx.db.insert(rolePermissions).values([ 487 + { roleId: modRole.id, permission: "space.atbb.permission.moderatePosts" }, 488 + ]); 489 + 490 + await ctx.db.insert(users).values({ 491 + did: "did:plc:test-anyperm-1", 492 + handle: "anyperm1.bsky.social", 493 + indexedAt: new Date(), 494 + }); 495 + 496 + await ctx.db.insert(memberships).values({ 497 + did: "did:plc:test-anyperm-1", 498 + rkey: "membership-anyperm-1", 499 + cid: "test-cid", 500 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 501 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role-anyperm-1`, 502 + createdAt: new Date(), 503 + indexedAt: new Date(), 504 + }); 505 + 506 + const testApp = new Hono<{ Variables: Variables }>(); 507 + testApp.use("*", async (c, next) => { 508 + c.set("user", { 509 + did: "did:plc:test-anyperm-1", 510 + handle: "anyperm1.bsky.social", 511 + pdsUrl: "https://pds.example.com", 512 + agent: {} as any, 513 + }); 514 + await next(); 515 + }); 516 + testApp.get( 517 + "/test", 518 + requireAnyPermission(ctx, [ 519 + "space.atbb.permission.moderatePosts", 520 + "space.atbb.permission.banUsers", 521 + ]), 522 + (c) => c.json({ ok: true }) 523 + ); 524 + 525 + const res = await testApp.request("/test"); 526 + expect(res.status).toBe(200); 527 + const body = await res.json(); 528 + expect(body).toEqual({ ok: true }); 529 + }); 530 + 531 + it("returns 403 when user has none of the required permissions", async () => { 532 + // Create a role with only createTopics permission 533 + const [memberRole] = await ctx.db.insert(roles).values({ 534 + did: ctx.config.forumDid, 535 + rkey: "mod-role-anyperm-2", 536 + cid: "test-cid", 537 + name: "Member", 538 + description: "Member role", 539 + priority: 30, 540 + createdAt: new Date(), 541 + indexedAt: new Date(), 542 + }).returning({ id: roles.id }); 543 + 544 + await ctx.db.insert(rolePermissions).values([ 545 + { roleId: memberRole.id, permission: "space.atbb.permission.createTopics" }, 546 + ]); 547 + 548 + await ctx.db.insert(users).values({ 549 + did: "did:plc:test-anyperm-2", 550 + handle: "anyperm2.bsky.social", 551 + indexedAt: new Date(), 552 + }); 553 + 554 + await ctx.db.insert(memberships).values({ 555 + did: "did:plc:test-anyperm-2", 556 + rkey: "membership-anyperm-2", 557 + cid: "test-cid", 558 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 559 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role-anyperm-2`, 560 + createdAt: new Date(), 561 + indexedAt: new Date(), 562 + }); 563 + 564 + const testApp = new Hono<{ Variables: Variables }>(); 565 + testApp.use("*", async (c, next) => { 566 + c.set("user", { 567 + did: "did:plc:test-anyperm-2", 568 + handle: "anyperm2.bsky.social", 569 + pdsUrl: "https://pds.example.com", 570 + agent: {} as any, 571 + }); 572 + await next(); 573 + }); 574 + testApp.get( 575 + "/test", 576 + requireAnyPermission(ctx, [ 577 + "space.atbb.permission.moderatePosts", 578 + "space.atbb.permission.banUsers", 579 + ]), 580 + (c) => c.json({ ok: true }) 581 + ); 582 + 583 + const res = await testApp.request("/test"); 584 + expect(res.status).toBe(403); 585 + const body = await res.json(); 586 + expect(body).toEqual({ error: "Insufficient permissions" }); 587 + }); 588 + 589 + it("returns 401 when user is not authenticated", async () => { 590 + const testApp = new Hono<{ Variables: Variables }>(); 591 + // No auth middleware — user is not set 592 + testApp.get( 593 + "/test", 594 + requireAnyPermission(ctx, [ 595 + "space.atbb.permission.moderatePosts", 596 + "space.atbb.permission.banUsers", 597 + ]), 598 + (c) => c.json({ ok: true }) 599 + ); 600 + 601 + const res = await testApp.request("/test"); 602 + expect(res.status).toBe(401); 603 + }); 604 + 605 + it("short-circuits on second permission if first fails", async () => { 606 + // Create a role with banUsers but NOT moderatePosts 607 + const [banRole] = await ctx.db.insert(roles).values({ 608 + did: ctx.config.forumDid, 609 + rkey: "mod-role-anyperm-3", 610 + cid: "test-cid", 611 + name: "BanRole", 612 + description: "Role with banUsers only", 613 + priority: 15, 614 + createdAt: new Date(), 615 + indexedAt: new Date(), 616 + }).returning({ id: roles.id }); 617 + 618 + await ctx.db.insert(rolePermissions).values([ 619 + { roleId: banRole.id, permission: "space.atbb.permission.banUsers" }, 620 + ]); 621 + 622 + await ctx.db.insert(users).values({ 623 + did: "did:plc:test-anyperm-3", 624 + handle: "anyperm3.bsky.social", 625 + indexedAt: new Date(), 626 + }); 627 + 628 + await ctx.db.insert(memberships).values({ 629 + did: "did:plc:test-anyperm-3", 630 + rkey: "membership-anyperm-3", 631 + cid: "test-cid", 632 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 633 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role-anyperm-3`, 634 + createdAt: new Date(), 635 + indexedAt: new Date(), 636 + }); 637 + 638 + const testApp = new Hono<{ Variables: Variables }>(); 639 + testApp.use("*", async (c, next) => { 640 + c.set("user", { 641 + did: "did:plc:test-anyperm-3", 642 + handle: "anyperm3.bsky.social", 643 + pdsUrl: "https://pds.example.com", 644 + agent: {} as any, 645 + }); 646 + await next(); 647 + }); 648 + // First perm (moderatePosts) will fail, second (banUsers) will succeed 649 + testApp.get( 650 + "/test", 651 + requireAnyPermission(ctx, [ 652 + "space.atbb.permission.moderatePosts", 653 + "space.atbb.permission.banUsers", 654 + ]), 655 + (c) => c.json({ ok: true }) 656 + ); 657 + 658 + const res = await testApp.request("/test"); 659 + expect(res.status).toBe(200); 660 + const body = await res.json(); 661 + expect(body).toEqual({ ok: true }); 466 662 }); 467 663 }); 468 664 });
+30
apps/appview/src/middleware/permissions.ts
··· 240 240 } 241 241 242 242 /** 243 + * Require at least one of a list of permissions (OR logic). 244 + * 245 + * Iterates the permissions list in order, calling checkPermission for each. 246 + * Short-circuits and calls next() on the first match. 247 + * Returns 401 if not authenticated, 403 if none of the permissions match. 248 + */ 249 + export function requireAnyPermission( 250 + ctx: AppContext, 251 + permissions: string[] 252 + ) { 253 + return async (c: Context<{ Variables: Variables }>, next: Next) => { 254 + const user = c.get("user"); 255 + 256 + if (!user) { 257 + return c.json({ error: "Authentication required" }, 401); 258 + } 259 + 260 + for (const permission of permissions) { 261 + const hasPermission = await checkPermission(ctx, user.did, permission); 262 + if (hasPermission) { 263 + await next(); 264 + return; 265 + } 266 + } 267 + 268 + return c.json({ error: "Insufficient permissions" }, 403); 269 + }; 270 + } 271 + 272 + /** 243 273 * Require minimum role middleware. 244 274 * 245 275 * Validates that the authenticated user has a role with sufficient priority.
+268 -1
apps/appview/src/routes/__tests__/admin.test.ts
··· 2 2 import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 3 3 import { Hono } from "hono"; 4 4 import type { Variables } from "../../types.js"; 5 - import { memberships, roles, rolePermissions, users, forums, categories, boards, posts } from "@atbb/db"; 5 + import { memberships, roles, rolePermissions, users, forums, categories, boards, posts, modActions } from "@atbb/db"; 6 6 import { eq } from "drizzle-orm"; 7 7 8 8 // Mock middleware at module level ··· 10 10 let mockGetUserRole: ReturnType<typeof vi.fn>; 11 11 let mockPutRecord: ReturnType<typeof vi.fn>; 12 12 let mockDeleteRecord: ReturnType<typeof vi.fn>; 13 + let mockRequireAnyPermissionPass = true; 13 14 14 15 // Create the mock function at module level 15 16 mockGetUserRole = vi.fn(); ··· 28 29 requirePermission: vi.fn(() => async (_c: any, next: any) => { 29 30 await next(); 30 31 }), 32 + requireAnyPermission: vi.fn(() => async (c: any, next: any) => { 33 + if (!mockRequireAnyPermissionPass) { 34 + return c.json({ error: "Insufficient permissions" }, 403); 35 + } 36 + await next(); 37 + }), 31 38 getUserRole: (...args: any[]) => mockGetUserRole(...args), 32 39 checkPermission: vi.fn().mockResolvedValue(true), 33 40 })); ··· 46 53 // Set up mock user for auth middleware 47 54 mockUser = { did: "did:plc:test-admin" }; 48 55 mockGetUserRole.mockClear(); 56 + mockRequireAnyPermissionPass = true; 49 57 50 58 // Mock putRecord 51 59 mockPutRecord = vi.fn().mockResolvedValue({ data: { uri: "at://...", cid: "bafytest" } }); ··· 2175 2183 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 2176 2184 await next(); 2177 2185 }); 2186 + }); 2187 + }); 2188 + 2189 + describe("GET /api/admin/modlog", () => { 2190 + beforeEach(async () => { 2191 + await ctx.cleanDatabase(); 2192 + }); 2193 + 2194 + it("returns 401 when not authenticated", async () => { 2195 + mockUser = null; 2196 + const res = await app.request("/api/admin/modlog"); 2197 + expect(res.status).toBe(401); 2198 + }); 2199 + 2200 + it("returns 403 when user lacks all mod permissions", async () => { 2201 + mockRequireAnyPermissionPass = false; 2202 + const res = await app.request("/api/admin/modlog"); 2203 + expect(res.status).toBe(403); 2204 + }); 2205 + 2206 + it("returns empty list when no mod actions exist", async () => { 2207 + const res = await app.request("/api/admin/modlog"); 2208 + expect(res.status).toBe(200); 2209 + const data = await res.json() as any; 2210 + expect(data.actions).toEqual([]); 2211 + expect(data.total).toBe(0); 2212 + expect(data.offset).toBe(0); 2213 + expect(data.limit).toBe(50); 2214 + }); 2215 + 2216 + it("returns paginated mod actions with moderator and subject handles", async () => { 2217 + await ctx.db.insert(users).values([ 2218 + { did: "did:plc:mod-alice", handle: "alice.bsky.social", indexedAt: new Date() }, 2219 + { did: "did:plc:subject-bob", handle: "bob.bsky.social", indexedAt: new Date() }, 2220 + ]); 2221 + 2222 + await ctx.db.insert(modActions).values({ 2223 + did: ctx.config.forumDid, 2224 + rkey: "modaction-ban-1", 2225 + cid: "cid-ban-1", 2226 + action: "space.atbb.modAction.ban", 2227 + subjectDid: "did:plc:subject-bob", 2228 + subjectPostUri: null, 2229 + createdBy: "did:plc:mod-alice", 2230 + reason: "Spam", 2231 + createdAt: new Date("2026-02-26T12:01:00Z"), 2232 + indexedAt: new Date(), 2233 + }); 2234 + 2235 + const res = await app.request("/api/admin/modlog"); 2236 + expect(res.status).toBe(200); 2237 + 2238 + const data = await res.json() as any; 2239 + expect(data.total).toBe(1); 2240 + expect(data.actions).toHaveLength(1); 2241 + 2242 + const action = data.actions[0]; 2243 + expect(typeof action.id).toBe("string"); 2244 + expect(action.action).toBe("space.atbb.modAction.ban"); 2245 + expect(action.moderatorDid).toBe("did:plc:mod-alice"); 2246 + expect(action.moderatorHandle).toBe("alice.bsky.social"); 2247 + expect(action.subjectDid).toBe("did:plc:subject-bob"); 2248 + expect(action.subjectHandle).toBe("bob.bsky.social"); 2249 + expect(action.subjectPostUri).toBeNull(); 2250 + expect(action.reason).toBe("Spam"); 2251 + expect(action.createdAt).toBe("2026-02-26T12:01:00.000Z"); 2252 + }); 2253 + 2254 + it("returns null subjectHandle and populated subjectPostUri for post-targeting actions", async () => { 2255 + await ctx.db.insert(users).values({ 2256 + did: "did:plc:mod-carol", 2257 + handle: "carol.bsky.social", 2258 + indexedAt: new Date(), 2259 + }); 2260 + 2261 + await ctx.db.insert(modActions).values({ 2262 + did: ctx.config.forumDid, 2263 + rkey: "modaction-hide-1", 2264 + cid: "cid-hide-1", 2265 + action: "space.atbb.modAction.hide", 2266 + subjectDid: null, 2267 + subjectPostUri: "at://did:plc:user/space.atbb.post/abc123", 2268 + createdBy: "did:plc:mod-carol", 2269 + reason: "Inappropriate", 2270 + createdAt: new Date("2026-02-26T11:30:00Z"), 2271 + indexedAt: new Date(), 2272 + }); 2273 + 2274 + const res = await app.request("/api/admin/modlog"); 2275 + expect(res.status).toBe(200); 2276 + 2277 + const data = await res.json() as any; 2278 + const action = data.actions.find((a: any) => a.action === "space.atbb.modAction.hide"); 2279 + expect(action).toBeDefined(); 2280 + expect(action.subjectDid).toBeNull(); 2281 + expect(action.subjectHandle).toBeNull(); 2282 + expect(action.subjectPostUri).toBe("at://did:plc:user/space.atbb.post/abc123"); 2283 + }); 2284 + 2285 + it("falls back to moderatorDid when moderator has no handle indexed", async () => { 2286 + await ctx.db.insert(users).values({ 2287 + did: "did:plc:mod-nohandle", 2288 + handle: null, 2289 + indexedAt: new Date(), 2290 + }); 2291 + 2292 + await ctx.db.insert(modActions).values({ 2293 + did: ctx.config.forumDid, 2294 + rkey: "modaction-nohandle-1", 2295 + cid: "cid-nohandle-1", 2296 + action: "space.atbb.modAction.ban", 2297 + subjectDid: null, 2298 + subjectPostUri: null, 2299 + createdBy: "did:plc:mod-nohandle", 2300 + reason: "Test", 2301 + createdAt: new Date(), 2302 + indexedAt: new Date(), 2303 + }); 2304 + 2305 + const res = await app.request("/api/admin/modlog"); 2306 + expect(res.status).toBe(200); 2307 + 2308 + const data = await res.json() as any; 2309 + const action = data.actions.find((a: any) => a.moderatorDid === "did:plc:mod-nohandle"); 2310 + expect(action).toBeDefined(); 2311 + expect(action.moderatorHandle).toBe("did:plc:mod-nohandle"); 2312 + }); 2313 + 2314 + it("falls back to moderatorDid when moderator has no users row at all", async () => { 2315 + // Insert a mod action whose createdBy DID has NO entry in the users table 2316 + await ctx.db.insert(modActions).values({ 2317 + did: ctx.config.forumDid, 2318 + rkey: "modaction-nouser-1", 2319 + cid: "cid-nouser-1", 2320 + action: "space.atbb.modAction.ban", 2321 + subjectDid: null, 2322 + subjectPostUri: null, 2323 + createdBy: "did:plc:mod-completely-unknown", 2324 + reason: "No users row", 2325 + createdAt: new Date(), 2326 + indexedAt: new Date(), 2327 + }); 2328 + 2329 + const res = await app.request("/api/admin/modlog"); 2330 + expect(res.status).toBe(200); 2331 + 2332 + const data = await res.json() as any; 2333 + // The action must appear in the results (not silently dropped by an inner join) 2334 + const action = data.actions.find( 2335 + (a: any) => a.moderatorDid === "did:plc:mod-completely-unknown" 2336 + ); 2337 + expect(action).toBeDefined(); 2338 + expect(action.moderatorHandle).toBe("did:plc:mod-completely-unknown"); 2339 + }); 2340 + 2341 + it("returns actions in createdAt DESC order", async () => { 2342 + await ctx.db.insert(users).values({ 2343 + did: "did:plc:mod-order", 2344 + handle: "order.bsky.social", 2345 + indexedAt: new Date(), 2346 + }); 2347 + 2348 + const now = Date.now(); 2349 + await ctx.db.insert(modActions).values([ 2350 + { 2351 + did: ctx.config.forumDid, 2352 + rkey: "modaction-old", 2353 + cid: "cid-old", 2354 + action: "space.atbb.modAction.ban", 2355 + subjectDid: null, 2356 + subjectPostUri: null, 2357 + createdBy: "did:plc:mod-order", 2358 + reason: "Old action", 2359 + createdAt: new Date(now - 10000), 2360 + indexedAt: new Date(), 2361 + }, 2362 + { 2363 + did: ctx.config.forumDid, 2364 + rkey: "modaction-new", 2365 + cid: "cid-new", 2366 + action: "space.atbb.modAction.hide", 2367 + subjectDid: null, 2368 + subjectPostUri: null, 2369 + createdBy: "did:plc:mod-order", 2370 + reason: "New action", 2371 + createdAt: new Date(now), 2372 + indexedAt: new Date(), 2373 + }, 2374 + ]); 2375 + 2376 + const res = await app.request("/api/admin/modlog"); 2377 + const data = await res.json() as any; 2378 + 2379 + const orderActions = data.actions.filter((a: any) => 2380 + a.moderatorDid === "did:plc:mod-order" 2381 + ); 2382 + expect(orderActions).toHaveLength(2); 2383 + expect(orderActions[0].reason).toBe("New action"); 2384 + expect(orderActions[1].reason).toBe("Old action"); 2385 + }); 2386 + 2387 + it("respects limit and offset query params", async () => { 2388 + await ctx.db.insert(users).values({ 2389 + did: "did:plc:mod-pagination", 2390 + handle: "pagination.bsky.social", 2391 + indexedAt: new Date(), 2392 + }); 2393 + 2394 + await ctx.db.insert(modActions).values([ 2395 + { 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() }, 2396 + { 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() }, 2397 + { 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() }, 2398 + ]); 2399 + 2400 + const page1 = await app.request("/api/admin/modlog?limit=2&offset=0"); 2401 + const data1 = await page1.json() as any; 2402 + expect(data1.actions).toHaveLength(2); 2403 + expect(data1.limit).toBe(2); 2404 + expect(data1.offset).toBe(0); 2405 + expect(data1.total).toBe(3); 2406 + expect(data1.actions[0].reason).toBe("A"); 2407 + 2408 + const page2 = await app.request("/api/admin/modlog?limit=2&offset=2"); 2409 + const data2 = await page2.json() as any; 2410 + expect(data2.actions).toHaveLength(1); 2411 + expect(data2.total).toBe(3); 2412 + expect(data2.actions[0].reason).toBe("C"); 2413 + }); 2414 + 2415 + it("returns 400 for non-numeric limit", async () => { 2416 + const res = await app.request("/api/admin/modlog?limit=abc"); 2417 + expect(res.status).toBe(400); 2418 + const data = await res.json() as any; 2419 + expect(data.error).toMatch(/limit/i); 2420 + }); 2421 + 2422 + it("returns 400 for negative limit", async () => { 2423 + const res = await app.request("/api/admin/modlog?limit=-1"); 2424 + expect(res.status).toBe(400); 2425 + }); 2426 + 2427 + it("returns 400 for negative offset", async () => { 2428 + const res = await app.request("/api/admin/modlog?offset=-5"); 2429 + expect(res.status).toBe(400); 2430 + }); 2431 + 2432 + it("caps limit at 100", async () => { 2433 + const res = await app.request("/api/admin/modlog?limit=999"); 2434 + expect(res.status).toBe(200); 2435 + const data = await res.json() as any; 2436 + expect(data.limit).toBe(100); 2437 + }); 2438 + 2439 + it("uses default limit=50 and offset=0 when not provided", async () => { 2440 + const res = await app.request("/api/admin/modlog"); 2441 + expect(res.status).toBe(200); 2442 + const data = await res.json() as any; 2443 + expect(data.limit).toBe(50); 2444 + expect(data.offset).toBe(0); 2178 2445 }); 2179 2446 }); 2180 2447
+105 -3
apps/appview/src/routes/admin.ts
··· 2 2 import type { AppContext } from "../lib/app-context.js"; 3 3 import type { Variables } from "../types.js"; 4 4 import { requireAuth } from "../middleware/auth.js"; 5 - import { requirePermission, getUserRole } from "../middleware/permissions.js"; 6 - import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts } from "@atbb/db"; 7 - import { eq, and, sql, asc, count } from "drizzle-orm"; 5 + import { requirePermission, requireAnyPermission, getUserRole } from "../middleware/permissions.js"; 6 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts, modActions } from "@atbb/db"; 7 + import { eq, and, sql, asc, desc, count } from "drizzle-orm"; 8 + import { alias } from "drizzle-orm/pg-core"; 8 9 import { isProgrammingError } from "../lib/errors.js"; 9 10 import { BackfillStatus } from "../lib/backfill-manager.js"; 10 11 import { CursorManager } from "../lib/cursor-manager.js"; ··· 975 976 operation: "DELETE /api/admin/boards/:id", 976 977 logger: ctx.logger, 977 978 id: idParam, 979 + }); 980 + } 981 + } 982 + ); 983 + 984 + /** 985 + * GET /api/admin/modlog 986 + * 987 + * Paginated, reverse-chronological list of mod actions. 988 + * Joins users table twice: once for the moderator handle (via createdBy), 989 + * once for the subject handle (via subjectDid, nullable for post-targeting actions). 990 + * 991 + * Uses leftJoin for both users joins so actions are never dropped when a 992 + * moderator or subject DID has no indexed users row. moderatorHandle falls 993 + * back to moderatorDid in that case. 994 + * 995 + * Requires any of: moderatePosts, banUsers, lockTopics. 996 + */ 997 + app.get( 998 + "/modlog", 999 + requireAuth(ctx), 1000 + requireAnyPermission(ctx, [ 1001 + "space.atbb.permission.moderatePosts", 1002 + "space.atbb.permission.banUsers", 1003 + "space.atbb.permission.lockTopics", 1004 + ]), 1005 + async (c) => { 1006 + const rawLimit = c.req.query("limit"); 1007 + const rawOffset = c.req.query("offset"); 1008 + 1009 + if (rawLimit !== undefined && (!/^\d+$/.test(rawLimit))) { 1010 + return c.json({ error: "limit must be a positive integer" }, 400); 1011 + } 1012 + if (rawOffset !== undefined && (!/^\d+$/.test(rawOffset))) { 1013 + return c.json({ error: "offset must be a non-negative integer" }, 400); 1014 + } 1015 + 1016 + const limitVal = rawLimit !== undefined ? parseInt(rawLimit, 10) : 50; 1017 + const offsetVal = rawOffset !== undefined ? parseInt(rawOffset, 10) : 0; 1018 + 1019 + if (rawLimit !== undefined && limitVal < 1) { 1020 + return c.json({ error: "limit must be a positive integer" }, 400); 1021 + } 1022 + if (rawOffset !== undefined && offsetVal < 0) { 1023 + return c.json({ error: "offset must be a non-negative integer" }, 400); 1024 + } 1025 + 1026 + const clampedLimit = Math.min(limitVal, 100); 1027 + 1028 + const moderatorUser = alias(users, "moderator_user"); 1029 + const subjectUser = alias(users, "subject_user"); 1030 + 1031 + try { 1032 + const [countResult, actions] = await Promise.all([ 1033 + ctx.db 1034 + .select({ total: count() }) 1035 + .from(modActions) 1036 + .where(eq(modActions.did, ctx.config.forumDid)), 1037 + ctx.db 1038 + .select({ 1039 + id: modActions.id, 1040 + action: modActions.action, 1041 + moderatorDid: modActions.createdBy, 1042 + moderatorHandle: moderatorUser.handle, 1043 + subjectDid: modActions.subjectDid, 1044 + subjectHandle: subjectUser.handle, 1045 + subjectPostUri: modActions.subjectPostUri, 1046 + reason: modActions.reason, 1047 + createdAt: modActions.createdAt, 1048 + }) 1049 + .from(modActions) 1050 + .where(eq(modActions.did, ctx.config.forumDid)) 1051 + .leftJoin(moderatorUser, eq(modActions.createdBy, moderatorUser.did)) 1052 + .leftJoin(subjectUser, eq(modActions.subjectDid, subjectUser.did)) 1053 + .orderBy(desc(modActions.createdAt)) 1054 + .limit(clampedLimit) 1055 + .offset(offsetVal), 1056 + ]); 1057 + 1058 + const total = Number(countResult[0]?.total ?? 0); 1059 + 1060 + return c.json({ 1061 + actions: actions.map((a) => ({ 1062 + id: a.id.toString(), 1063 + action: a.action, 1064 + moderatorDid: a.moderatorDid, 1065 + moderatorHandle: a.moderatorHandle ?? a.moderatorDid, 1066 + subjectDid: a.subjectDid ?? null, 1067 + subjectHandle: a.subjectHandle ?? null, 1068 + subjectPostUri: a.subjectPostUri ?? null, 1069 + reason: a.reason ?? null, 1070 + createdAt: a.createdAt.toISOString(), 1071 + })), 1072 + total, 1073 + offset: offsetVal, 1074 + limit: clampedLimit, 1075 + }); 1076 + } catch (error) { 1077 + return handleRouteError(c, error, "Failed to retrieve mod action log", { 1078 + operation: "GET /api/admin/modlog", 1079 + logger: ctx.logger, 978 1080 }); 979 1081 } 980 1082 }
+68
bruno/AppView API/Admin/Get Mod Action Log.bru
··· 1 + meta { 2 + name: Get Mod Action Log 3 + type: http 4 + seq: 16 5 + } 6 + 7 + get { 8 + url: {{appview_url}}/api/admin/modlog 9 + } 10 + 11 + params:query { 12 + limit: 50 13 + offset: 0 14 + } 15 + 16 + assert { 17 + res.status: eq 200 18 + res.body.actions: isArray 19 + res.body.total: isDefined 20 + res.body.limit: isDefined 21 + res.body.offset: isDefined 22 + } 23 + 24 + docs { 25 + Paginated, reverse-chronological list of moderation actions with human-readable 26 + handles for both the moderator and the subject. 27 + 28 + **Requires any one of:** 29 + - `space.atbb.permission.moderatePosts` 30 + - `space.atbb.permission.banUsers` 31 + - `space.atbb.permission.lockTopics` 32 + 33 + Query params: 34 + - limit: Max results per page (default: 50, max: 100) 35 + - offset: Number of records to skip for pagination (default: 0) 36 + 37 + Returns: 38 + { 39 + "actions": [ 40 + { 41 + "id": "123", 42 + "action": "space.atbb.modAction.ban", 43 + "moderatorDid": "did:plc:abc", 44 + "moderatorHandle": "alice.bsky.social", 45 + "subjectDid": "did:plc:xyz", 46 + "subjectHandle": "bob.bsky.social", 47 + "subjectPostUri": null, 48 + "reason": "Spam", 49 + "createdAt": "2026-02-26T12:01:00Z" 50 + } 51 + ], 52 + "total": 42, 53 + "offset": 0, 54 + "limit": 50 55 + } 56 + 57 + Error codes: 58 + - 400: Invalid limit or offset (non-numeric or negative) 59 + - 401: Not authenticated 60 + - 403: Insufficient permissions (none of the three mod permissions) 61 + 62 + Notes: 63 + - Actions are returned in descending createdAt order (newest first) 64 + - id is serialized as a string (BigInt) 65 + - moderatorHandle falls back to moderatorDid when the moderator's handle is not indexed 66 + - subjectHandle is null for post-targeting actions (subjectDid is null) 67 + - subjectPostUri is null for user-targeting actions 68 + }
+134
docs/plans/complete/2026-03-01-atb-46-modlog-endpoint-design.md
··· 1 + # ATB-46 — Admin Mod Action Log Endpoint 2 + 3 + **Date:** 2026-03-01 4 + **Status:** Approved 5 + **Linear:** ATB-46 6 + 7 + ## Summary 8 + 9 + Add `GET /api/admin/modlog` to the AppView. Returns a paginated, reverse-chronological list of mod actions joined with human-readable handles for both the moderator and the subject. Access requires any one of three moderation permissions. 10 + 11 + ## Permissions Middleware Extension 12 + 13 + Add `requireAnyPermission(ctx, permissions[])` to `apps/appview/src/middleware/permissions.ts`. It checks each permission in order and short-circuits on the first match (no unnecessary DB queries). Exported alongside the existing `requirePermission`. 14 + 15 + ```typescript 16 + export function requireAnyPermission(ctx: AppContext, permissions: string[]) { 17 + return async (c, next) => { 18 + const user = c.get("user"); 19 + if (!user) return c.json({ error: "Authentication required" }, 401); 20 + for (const perm of permissions) { 21 + if (await checkPermission(ctx, user.did, perm)) return next(); 22 + } 23 + return c.json({ error: "Insufficient permissions" }, 403); 24 + }; 25 + } 26 + ``` 27 + 28 + ## Endpoint 29 + 30 + ``` 31 + GET /api/admin/modlog?limit=50&offset=0 32 + ``` 33 + 34 + **Auth chain:** `requireAuth(ctx)` → `requireAnyPermission(ctx, ["space.atbb.permission.moderatePosts", "space.atbb.permission.banUsers", "space.atbb.permission.lockTopics"])` 35 + 36 + **Pagination defaults:** `limit=50`, `offset=0`. Cap `limit` at 100. 37 + 38 + **Invalid params:** non-numeric or negative `limit`/`offset` → 400. 39 + 40 + ## Query Strategy 41 + 42 + The `users` table is joined twice using Drizzle's `alias()`: 43 + 44 + - `innerJoin` on `moderator_user` via `modActions.createdBy = moderatorUser.did` (moderator always has a users row) 45 + - `leftJoin` on `subject_user` via `modActions.subjectDid = subjectUser.did` (null for post-targeting actions) 46 + 47 + ```typescript 48 + const moderatorUser = alias(users, "moderator_user"); 49 + const subjectUser = alias(users, "subject_user"); 50 + 51 + const actions = await ctx.db 52 + .select({ 53 + id: modActions.id, 54 + action: modActions.action, 55 + moderatorDid: modActions.createdBy, 56 + moderatorHandle: moderatorUser.handle, 57 + subjectDid: modActions.subjectDid, 58 + subjectHandle: subjectUser.handle, 59 + subjectPostUri: modActions.subjectPostUri, 60 + reason: modActions.reason, 61 + createdAt: modActions.createdAt, 62 + }) 63 + .from(modActions) 64 + .innerJoin(moderatorUser, eq(modActions.createdBy, moderatorUser.did)) 65 + .leftJoin(subjectUser, eq(modActions.subjectDid, subjectUser.did)) 66 + .orderBy(desc(modActions.createdAt)) 67 + .limit(limitVal) 68 + .offset(offsetVal); 69 + ``` 70 + 71 + Total count uses a separate query with no join (only `mod_actions` rows are counted): 72 + 73 + ```typescript 74 + const [{ total }] = await ctx.db 75 + .select({ total: count() }) 76 + .from(modActions); 77 + ``` 78 + 79 + ## Handle Fallback 80 + 81 + `moderatorHandle` falls back to `moderatorDid` in the response serialization layer when the `users` table has no handle for that DID. `subjectHandle` is null for post-targeting actions (left join produces no row when `subjectDid` is null). 82 + 83 + ## Response 84 + 85 + ```json 86 + { 87 + "actions": [ 88 + { 89 + "id": "123", 90 + "action": "space.atbb.modAction.ban", 91 + "moderatorDid": "did:plc:abc", 92 + "moderatorHandle": "alice.bsky.social", 93 + "subjectDid": "did:plc:xyz", 94 + "subjectHandle": "bob.bsky.social", 95 + "subjectPostUri": null, 96 + "reason": "Spam", 97 + "createdAt": "2026-02-26T12:01:00Z" 98 + } 99 + ], 100 + "total": 42, 101 + "offset": 0, 102 + "limit": 50 103 + } 104 + ``` 105 + 106 + BigInt `id` is serialized as a string. `createdAt` is ISO 8601. 107 + 108 + ## Tests 109 + 110 + | Scenario | Expected | 111 + |----------|----------| 112 + | Returns actions in `createdAt DESC` order | 200 | 113 + | `moderatorHandle` joined from `users` via `createdBy` | correct handle | 114 + | `subjectHandle` populated for user-targeting actions | correct handle | 115 + | `subjectHandle` null for post-targeting actions | null | 116 + | `subjectPostUri` null for user-targeting actions | null | 117 + | `moderatorHandle` falls back to DID when no handle indexed | DID string | 118 + | `limit` and `offset` params respected | correct slice | 119 + | Default `limit=50`, `offset=0` | correct defaults | 120 + | Non-numeric or negative `limit`/`offset` | 400 | 121 + | Unauthenticated | 401 | 122 + | Authenticated, no mod permissions | 403 | 123 + | Authenticated with `moderatePosts` | 200 | 124 + | Authenticated with `banUsers` | 200 | 125 + | Authenticated with `lockTopics` | 200 | 126 + 127 + ## Files Modified 128 + 129 + | File | Change | 130 + |------|--------| 131 + | `apps/appview/src/middleware/permissions.ts` | Add `requireAnyPermission` | 132 + | `apps/appview/src/routes/admin.ts` | Add `GET /modlog` route | 133 + | `apps/appview/src/routes/__tests__/admin.test.ts` | Add modlog tests | 134 + | `bruno/appview/Admin/Get Mod Action Log.bru` | New Bruno collection file |
+841
docs/plans/complete/2026-03-01-atb-46-modlog-endpoint.md
··· 1 + # ATB-46: Admin Mod Action Log Endpoint — Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **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. 6 + 7 + **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). 8 + 9 + **Tech Stack:** Hono, Drizzle ORM (`alias`, `desc`, `count`), Vitest, PostgreSQL 10 + 11 + --- 12 + 13 + ### Task 1: Add `requireAnyPermission` to the permissions middleware 14 + 15 + **Files:** 16 + - Modify: `apps/appview/src/middleware/permissions.ts` 17 + 18 + **Step 1: Write the failing unit tests** 19 + 20 + 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 `}`): 21 + 22 + ```typescript 23 + describe("requireAnyPermission", () => { 24 + it("calls next() when user has one of the required permissions", async () => { 25 + // Create a role with moderatePosts permission 26 + const [modRole] = await ctx.db.insert(roles).values({ 27 + did: ctx.config.forumDid, 28 + rkey: "mod-role-anytest", 29 + cid: "test-cid", 30 + name: "Moderator", 31 + description: "Test moderator role", 32 + priority: 20, 33 + createdAt: new Date(), 34 + indexedAt: new Date(), 35 + }).returning({ id: roles.id }); 36 + 37 + await ctx.db.insert(rolePermissions).values([ 38 + { roleId: modRole.id, permission: "space.atbb.permission.moderatePosts" }, 39 + ]); 40 + 41 + await ctx.db.insert(users).values({ 42 + did: "did:plc:test-anyperm-mod", 43 + handle: "anyperm-mod.bsky.social", 44 + indexedAt: new Date(), 45 + }); 46 + 47 + await ctx.db.insert(memberships).values({ 48 + did: "did:plc:test-anyperm-mod", 49 + rkey: "membership-anyperm-mod", 50 + cid: "test-cid", 51 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 52 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role-anytest`, 53 + createdAt: new Date(), 54 + indexedAt: new Date(), 55 + }); 56 + 57 + // Build a tiny Hono app to test the middleware 58 + const { requireAnyPermission } = await import("../permissions.js"); 59 + const testApp = new Hono<{ Variables: Variables }>(); 60 + testApp.use("*", async (c, next) => { 61 + c.set("user", { did: "did:plc:test-anyperm-mod" }); 62 + await next(); 63 + }); 64 + testApp.get("/test", 65 + requireAnyPermission(ctx, [ 66 + "space.atbb.permission.moderatePosts", 67 + "space.atbb.permission.banUsers", 68 + ]), 69 + (c) => c.json({ ok: true }) 70 + ); 71 + 72 + const res = await testApp.request("/test"); 73 + expect(res.status).toBe(200); 74 + }); 75 + 76 + it("returns 403 when user has none of the required permissions", async () => { 77 + // Create a role with only createTopics (no mod permissions) 78 + const [memberRole] = await ctx.db.insert(roles).values({ 79 + did: ctx.config.forumDid, 80 + rkey: "member-role-anytest", 81 + cid: "test-cid", 82 + name: "Member", 83 + description: "Test member role", 84 + priority: 30, 85 + createdAt: new Date(), 86 + indexedAt: new Date(), 87 + }).returning({ id: roles.id }); 88 + 89 + await ctx.db.insert(rolePermissions).values([ 90 + { roleId: memberRole.id, permission: "space.atbb.permission.createTopics" }, 91 + ]); 92 + 93 + await ctx.db.insert(users).values({ 94 + did: "did:plc:test-anyperm-member", 95 + handle: "anyperm-member.bsky.social", 96 + indexedAt: new Date(), 97 + }); 98 + 99 + await ctx.db.insert(memberships).values({ 100 + did: "did:plc:test-anyperm-member", 101 + rkey: "membership-anyperm-member", 102 + cid: "test-cid", 103 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 104 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/member-role-anytest`, 105 + createdAt: new Date(), 106 + indexedAt: new Date(), 107 + }); 108 + 109 + const { requireAnyPermission } = await import("../permissions.js"); 110 + const testApp = new Hono<{ Variables: Variables }>(); 111 + testApp.use("*", async (c, next) => { 112 + c.set("user", { did: "did:plc:test-anyperm-member" }); 113 + await next(); 114 + }); 115 + testApp.get("/test", 116 + requireAnyPermission(ctx, [ 117 + "space.atbb.permission.moderatePosts", 118 + "space.atbb.permission.banUsers", 119 + ]), 120 + (c) => c.json({ ok: true }) 121 + ); 122 + 123 + const res = await testApp.request("/test"); 124 + expect(res.status).toBe(403); 125 + }); 126 + 127 + it("returns 401 when user is not authenticated", async () => { 128 + const { requireAnyPermission } = await import("../permissions.js"); 129 + const testApp = new Hono<{ Variables: Variables }>(); 130 + // No auth middleware — user is not set in context 131 + testApp.get("/test", 132 + requireAnyPermission(ctx, ["space.atbb.permission.moderatePosts"]), 133 + (c) => c.json({ ok: true }) 134 + ); 135 + 136 + const res = await testApp.request("/test"); 137 + expect(res.status).toBe(401); 138 + }); 139 + 140 + it("short-circuits on first matching permission (calls next on second perm if first fails)", async () => { 141 + // Role has banUsers but NOT moderatePosts 142 + const [banRole] = await ctx.db.insert(roles).values({ 143 + did: ctx.config.forumDid, 144 + rkey: "ban-role-anytest", 145 + cid: "test-cid", 146 + name: "BanMod", 147 + description: "Test ban role", 148 + priority: 20, 149 + createdAt: new Date(), 150 + indexedAt: new Date(), 151 + }).returning({ id: roles.id }); 152 + 153 + await ctx.db.insert(rolePermissions).values([ 154 + { roleId: banRole.id, permission: "space.atbb.permission.banUsers" }, 155 + ]); 156 + 157 + await ctx.db.insert(users).values({ 158 + did: "did:plc:test-anyperm-ban", 159 + handle: "anyperm-ban.bsky.social", 160 + indexedAt: new Date(), 161 + }); 162 + 163 + await ctx.db.insert(memberships).values({ 164 + did: "did:plc:test-anyperm-ban", 165 + rkey: "membership-anyperm-ban", 166 + cid: "test-cid", 167 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 168 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/ban-role-anytest`, 169 + createdAt: new Date(), 170 + indexedAt: new Date(), 171 + }); 172 + 173 + const { requireAnyPermission } = await import("../permissions.js"); 174 + const testApp = new Hono<{ Variables: Variables }>(); 175 + testApp.use("*", async (c, next) => { 176 + c.set("user", { did: "did:plc:test-anyperm-ban" }); 177 + await next(); 178 + }); 179 + // Check moderatePosts first (fails), then banUsers (passes) 180 + testApp.get("/test", 181 + requireAnyPermission(ctx, [ 182 + "space.atbb.permission.moderatePosts", 183 + "space.atbb.permission.banUsers", 184 + ]), 185 + (c) => c.json({ ok: true }) 186 + ); 187 + 188 + const res = await testApp.request("/test"); 189 + expect(res.status).toBe(200); 190 + }); 191 + }); 192 + ``` 193 + 194 + Also add these imports at the top of `permissions.test.ts` (alongside existing imports): 195 + 196 + ```typescript 197 + import { Hono } from "hono"; 198 + import type { Variables } from "../../types.js"; 199 + ``` 200 + 201 + **Step 2: Run the tests to verify they fail** 202 + 203 + ```bash 204 + PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 205 + pnpm --filter @atbb/appview exec vitest run \ 206 + src/middleware/__tests__/permissions.test.ts 207 + ``` 208 + 209 + Expected: FAIL — `requireAnyPermission is not a function` (not exported yet) 210 + 211 + **Step 3: Implement `requireAnyPermission` in permissions.ts** 212 + 213 + Add this function just before the `export { checkPermission, getUserRole, checkMinRole }` line at the bottom of `apps/appview/src/middleware/permissions.ts`: 214 + 215 + ```typescript 216 + /** 217 + * Require any of the listed permissions (OR logic). 218 + * 219 + * Returns 401 if not authenticated, 403 if authenticated but lacks all listed permissions. 220 + * Short-circuits on first match — no unnecessary DB queries. 221 + */ 222 + export function requireAnyPermission( 223 + ctx: AppContext, 224 + permissions: string[] 225 + ) { 226 + return async (c: Context<{ Variables: Variables }>, next: Next) => { 227 + const user = c.get("user"); 228 + 229 + if (!user) { 230 + return c.json({ error: "Authentication required" }, 401); 231 + } 232 + 233 + for (const permission of permissions) { 234 + if (await checkPermission(ctx, user.did, permission)) { 235 + return next(); 236 + } 237 + } 238 + 239 + return c.json({ error: "Insufficient permissions" }, 403); 240 + }; 241 + } 242 + ``` 243 + 244 + **Step 4: Run the tests to verify they pass** 245 + 246 + ```bash 247 + PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 248 + pnpm --filter @atbb/appview exec vitest run \ 249 + src/middleware/__tests__/permissions.test.ts 250 + ``` 251 + 252 + Expected: All tests pass (including the 4 new ones). 253 + 254 + **Step 5: Commit** 255 + 256 + ```bash 257 + git add apps/appview/src/middleware/permissions.ts \ 258 + apps/appview/src/middleware/__tests__/permissions.test.ts 259 + git commit -m "feat(appview): add requireAnyPermission middleware (ATB-46)" 260 + ``` 261 + 262 + --- 263 + 264 + ### Task 2: Write failing modlog route tests 265 + 266 + **Files:** 267 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 268 + 269 + **Step 1: Update the permissions mock to include `requireAnyPermission`** 270 + 271 + 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: 272 + 273 + Add this variable declaration alongside the other mock variables at the top (near `let mockUser`): 274 + 275 + ```typescript 276 + let mockRequireAnyPermissionPass = true; 277 + ``` 278 + 279 + Update the `vi.mock` block to add `requireAnyPermission`: 280 + 281 + ```typescript 282 + vi.mock("../../middleware/permissions.js", () => ({ 283 + requirePermission: vi.fn(() => async (_c: any, next: any) => { 284 + await next(); 285 + }), 286 + requireAnyPermission: vi.fn(() => async (c: any, next: any) => { 287 + if (!mockRequireAnyPermissionPass) { 288 + return c.json({ error: "Insufficient permissions" }, 403); 289 + } 290 + await next(); 291 + }), 292 + getUserRole: (...args: any[]) => mockGetUserRole(...args), 293 + checkPermission: vi.fn().mockResolvedValue(true), 294 + })); 295 + ``` 296 + 297 + Also reset `mockRequireAnyPermissionPass = true` in the `beforeEach` block alongside `mockUser` and `mockGetUserRole.mockClear()`. 298 + 299 + **Step 2: Write the failing modlog tests** 300 + 301 + Add a new `describe("GET /api/admin/modlog", ...)` block at the bottom of the `describe.sequential("Admin Routes", ...)` block in `admin.test.ts`: 302 + 303 + ```typescript 304 + describe("GET /api/admin/modlog", () => { 305 + it("returns 401 when not authenticated", async () => { 306 + mockUser = null; 307 + const res = await app.request("/api/admin/modlog"); 308 + expect(res.status).toBe(401); 309 + }); 310 + 311 + it("returns 403 when user lacks all mod permissions", async () => { 312 + mockRequireAnyPermissionPass = false; 313 + const res = await app.request("/api/admin/modlog"); 314 + expect(res.status).toBe(403); 315 + }); 316 + 317 + it("returns empty list when no mod actions exist", async () => { 318 + const res = await app.request("/api/admin/modlog"); 319 + expect(res.status).toBe(200); 320 + const data = await res.json() as any; 321 + expect(data.actions).toEqual([]); 322 + expect(data.total).toBe(0); 323 + expect(data.offset).toBe(0); 324 + expect(data.limit).toBe(50); 325 + }); 326 + 327 + it("returns paginated mod actions with moderator and subject handles", async () => { 328 + // Insert moderator user 329 + await ctx.db.insert(users).values({ 330 + did: "did:plc:mod-alice", 331 + handle: "alice.bsky.social", 332 + indexedAt: new Date(), 333 + }); 334 + 335 + // Insert subject user 336 + await ctx.db.insert(users).values({ 337 + did: "did:plc:subject-bob", 338 + handle: "bob.bsky.social", 339 + indexedAt: new Date(), 340 + }); 341 + 342 + // Insert a user-targeting action (ban) 343 + await ctx.db.insert(modActions).values({ 344 + did: ctx.config.forumDid, 345 + rkey: "modaction-ban-1", 346 + cid: "cid-ban-1", 347 + action: "space.atbb.modAction.ban", 348 + subjectDid: "did:plc:subject-bob", 349 + subjectPostUri: null, 350 + createdBy: "did:plc:mod-alice", 351 + reason: "Spam", 352 + createdAt: new Date("2026-02-26T12:01:00Z"), 353 + indexedAt: new Date(), 354 + }); 355 + 356 + const res = await app.request("/api/admin/modlog"); 357 + expect(res.status).toBe(200); 358 + 359 + const data = await res.json() as any; 360 + expect(data.total).toBe(1); 361 + expect(data.actions).toHaveLength(1); 362 + 363 + const action = data.actions[0]; 364 + expect(action.id).toBe(typeof action.id === "string" ? action.id : String(action.id)); // serialized as string 365 + expect(action.action).toBe("space.atbb.modAction.ban"); 366 + expect(action.moderatorDid).toBe("did:plc:mod-alice"); 367 + expect(action.moderatorHandle).toBe("alice.bsky.social"); 368 + expect(action.subjectDid).toBe("did:plc:subject-bob"); 369 + expect(action.subjectHandle).toBe("bob.bsky.social"); 370 + expect(action.subjectPostUri).toBeNull(); 371 + expect(action.reason).toBe("Spam"); 372 + expect(action.createdAt).toBe("2026-02-26T12:01:00.000Z"); 373 + }); 374 + 375 + it("returns null subjectHandle and populated subjectPostUri for post-targeting actions", async () => { 376 + await ctx.db.insert(users).values({ 377 + did: "did:plc:mod-carol", 378 + handle: "carol.bsky.social", 379 + indexedAt: new Date(), 380 + }); 381 + 382 + // Insert a post-targeting action (hide) — no subjectDid 383 + await ctx.db.insert(modActions).values({ 384 + did: ctx.config.forumDid, 385 + rkey: "modaction-hide-1", 386 + cid: "cid-hide-1", 387 + action: "space.atbb.modAction.hide", 388 + subjectDid: null, 389 + subjectPostUri: "at://did:plc:user/space.atbb.post/abc123", 390 + createdBy: "did:plc:mod-carol", 391 + reason: "Inappropriate", 392 + createdAt: new Date("2026-02-26T11:30:00Z"), 393 + indexedAt: new Date(), 394 + }); 395 + 396 + const res = await app.request("/api/admin/modlog"); 397 + expect(res.status).toBe(200); 398 + 399 + const data = await res.json() as any; 400 + const action = data.actions.find((a: any) => a.action === "space.atbb.modAction.hide"); 401 + expect(action).toBeDefined(); 402 + expect(action.subjectDid).toBeNull(); 403 + expect(action.subjectHandle).toBeNull(); 404 + expect(action.subjectPostUri).toBe("at://did:plc:user/space.atbb.post/abc123"); 405 + }); 406 + 407 + it("falls back to moderatorDid when moderator has no handle indexed", async () => { 408 + // Insert moderator user WITHOUT a handle 409 + await ctx.db.insert(users).values({ 410 + did: "did:plc:mod-nohandle", 411 + handle: null, 412 + indexedAt: new Date(), 413 + }); 414 + 415 + await ctx.db.insert(modActions).values({ 416 + did: ctx.config.forumDid, 417 + rkey: "modaction-nohandle-1", 418 + cid: "cid-nohandle-1", 419 + action: "space.atbb.modAction.ban", 420 + subjectDid: null, 421 + subjectPostUri: null, 422 + createdBy: "did:plc:mod-nohandle", 423 + reason: "Test", 424 + createdAt: new Date(), 425 + indexedAt: new Date(), 426 + }); 427 + 428 + const res = await app.request("/api/admin/modlog"); 429 + expect(res.status).toBe(200); 430 + 431 + const data = await res.json() as any; 432 + const action = data.actions.find((a: any) => a.moderatorDid === "did:plc:mod-nohandle"); 433 + expect(action).toBeDefined(); 434 + expect(action.moderatorHandle).toBe("did:plc:mod-nohandle"); // falls back to DID 435 + }); 436 + 437 + it("returns actions in createdAt DESC order", async () => { 438 + await ctx.db.insert(users).values({ 439 + did: "did:plc:mod-order-test", 440 + handle: "ordertest.bsky.social", 441 + indexedAt: new Date(), 442 + }); 443 + 444 + const now = Date.now(); 445 + await ctx.db.insert(modActions).values([ 446 + { 447 + did: ctx.config.forumDid, 448 + rkey: "modaction-old", 449 + cid: "cid-old", 450 + action: "space.atbb.modAction.ban", 451 + subjectDid: null, 452 + subjectPostUri: null, 453 + createdBy: "did:plc:mod-order-test", 454 + reason: "Old action", 455 + createdAt: new Date(now - 10000), 456 + indexedAt: new Date(), 457 + }, 458 + { 459 + did: ctx.config.forumDid, 460 + rkey: "modaction-new", 461 + cid: "cid-new", 462 + action: "space.atbb.modAction.hide", 463 + subjectDid: null, 464 + subjectPostUri: null, 465 + createdBy: "did:plc:mod-order-test", 466 + reason: "New action", 467 + createdAt: new Date(now), 468 + indexedAt: new Date(), 469 + }, 470 + ]); 471 + 472 + const res = await app.request("/api/admin/modlog"); 473 + const data = await res.json() as any; 474 + 475 + // Newest action should appear first 476 + expect(data.actions[0].reason).toBe("New action"); 477 + expect(data.actions[1].reason).toBe("Old action"); 478 + }); 479 + 480 + it("respects limit and offset query params", async () => { 481 + await ctx.db.insert(users).values({ 482 + did: "did:plc:mod-pagination", 483 + handle: "pagination.bsky.social", 484 + indexedAt: new Date(), 485 + }); 486 + 487 + // Insert 3 actions 488 + await ctx.db.insert(modActions).values([ 489 + { 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() }, 490 + { 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() }, 491 + { 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() }, 492 + ]); 493 + 494 + // Page 1: limit=2, offset=0 495 + const page1 = await app.request("/api/admin/modlog?limit=2&offset=0"); 496 + const data1 = await page1.json() as any; 497 + expect(data1.actions).toHaveLength(2); 498 + expect(data1.total).toBe(3); 499 + expect(data1.limit).toBe(2); 500 + expect(data1.offset).toBe(0); 501 + expect(data1.actions[0].reason).toBe("A"); // newest first 502 + 503 + // Page 2: limit=2, offset=2 504 + const page2 = await app.request("/api/admin/modlog?limit=2&offset=2"); 505 + const data2 = await page2.json() as any; 506 + expect(data2.actions).toHaveLength(1); 507 + expect(data2.actions[0].reason).toBe("C"); 508 + }); 509 + 510 + it("returns 400 for non-numeric limit", async () => { 511 + const res = await app.request("/api/admin/modlog?limit=abc"); 512 + expect(res.status).toBe(400); 513 + const data = await res.json() as any; 514 + expect(data.error).toMatch(/limit/i); 515 + }); 516 + 517 + it("returns 400 for negative limit", async () => { 518 + const res = await app.request("/api/admin/modlog?limit=-1"); 519 + expect(res.status).toBe(400); 520 + }); 521 + 522 + it("returns 400 for negative offset", async () => { 523 + const res = await app.request("/api/admin/modlog?offset=-5"); 524 + expect(res.status).toBe(400); 525 + }); 526 + 527 + it("caps limit at 100", async () => { 528 + const res = await app.request("/api/admin/modlog?limit=999"); 529 + expect(res.status).toBe(200); 530 + const data = await res.json() as any; 531 + expect(data.limit).toBe(100); 532 + }); 533 + 534 + it("uses default limit=50 and offset=0 when not provided", async () => { 535 + const res = await app.request("/api/admin/modlog"); 536 + expect(res.status).toBe(200); 537 + const data = await res.json() as any; 538 + expect(data.limit).toBe(50); 539 + expect(data.offset).toBe(0); 540 + }); 541 + }); 542 + ``` 543 + 544 + Also add `modActions` to the import from `@atbb/db` at the top of `admin.test.ts`: 545 + 546 + ```typescript 547 + import { memberships, roles, rolePermissions, users, forums, categories, boards, posts, modActions } from "@atbb/db"; 548 + ``` 549 + 550 + **Step 3: Run the tests to verify they fail** 551 + 552 + ```bash 553 + PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 554 + pnpm --filter @atbb/appview exec vitest run \ 555 + src/routes/__tests__/admin.test.ts 2>&1 | tail -30 556 + ``` 557 + 558 + Expected: FAIL — `GET /api/admin/modlog` route not found (404). 559 + 560 + **Step 4: Commit the failing tests** 561 + 562 + ```bash 563 + git add apps/appview/src/routes/__tests__/admin.test.ts 564 + git commit -m "test(appview): failing tests for GET /api/admin/modlog (ATB-46)" 565 + ``` 566 + 567 + --- 568 + 569 + ### Task 3: Implement the modlog route handler 570 + 571 + **Files:** 572 + - Modify: `apps/appview/src/routes/admin.ts` 573 + 574 + **Step 1: Update the imports in admin.ts** 575 + 576 + Find the existing import lines at the top of `apps/appview/src/routes/admin.ts` and make two changes: 577 + 578 + 1. Add `modActions` to the `@atbb/db` import: 579 + ```typescript 580 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts, modActions } from "@atbb/db"; 581 + ``` 582 + 583 + 2. Add `desc` and `alias` to the `drizzle-orm` import. Also add `alias` from `drizzle-orm`: 584 + ```typescript 585 + import { eq, and, sql, asc, desc, count } from "drizzle-orm"; 586 + import { alias } from "drizzle-orm"; 587 + ``` 588 + 589 + Note: `alias` may be a named export from `drizzle-orm`. If it isn't available there, import it from `drizzle-orm/pg-core` instead: 590 + ```typescript 591 + import { alias } from "drizzle-orm/pg-core"; 592 + ``` 593 + 594 + **Step 2: Add the modlog route to admin.ts** 595 + 596 + Add this route just before the final `return app;` at the bottom of `createAdminRoutes`: 597 + 598 + ```typescript 599 + /** 600 + * GET /api/admin/modlog 601 + * 602 + * Paginated, reverse-chronological list of mod actions. 603 + * Joins users table twice: once for the moderator handle (via createdBy), 604 + * once for the subject handle (via subjectDid, nullable for post-targeting actions). 605 + * 606 + * Requires any of: moderatePosts, banUsers, lockTopics. 607 + */ 608 + app.get( 609 + "/modlog", 610 + requireAuth(ctx), 611 + requireAnyPermission(ctx, [ 612 + "space.atbb.permission.moderatePosts", 613 + "space.atbb.permission.banUsers", 614 + "space.atbb.permission.lockTopics", 615 + ]), 616 + async (c) => { 617 + const rawLimit = c.req.query("limit"); 618 + const rawOffset = c.req.query("offset"); 619 + 620 + const limitVal = rawLimit !== undefined ? parseInt(rawLimit, 10) : 50; 621 + const offsetVal = rawOffset !== undefined ? parseInt(rawOffset, 10) : 0; 622 + 623 + if (rawLimit !== undefined && (isNaN(limitVal) || limitVal < 1)) { 624 + return c.json({ error: "limit must be a positive integer" }, 400); 625 + } 626 + if (rawOffset !== undefined && (isNaN(offsetVal) || offsetVal < 0)) { 627 + return c.json({ error: "offset must be a non-negative integer" }, 400); 628 + } 629 + 630 + const clampedLimit = Math.min(limitVal, 100); 631 + 632 + const moderatorUser = alias(users, "moderator_user"); 633 + const subjectUser = alias(users, "subject_user"); 634 + 635 + try { 636 + const [countResult, actions] = await Promise.all([ 637 + ctx.db.select({ total: count() }).from(modActions), 638 + ctx.db 639 + .select({ 640 + id: modActions.id, 641 + action: modActions.action, 642 + moderatorDid: modActions.createdBy, 643 + moderatorHandle: moderatorUser.handle, 644 + subjectDid: modActions.subjectDid, 645 + subjectHandle: subjectUser.handle, 646 + subjectPostUri: modActions.subjectPostUri, 647 + reason: modActions.reason, 648 + createdAt: modActions.createdAt, 649 + }) 650 + .from(modActions) 651 + .innerJoin(moderatorUser, eq(modActions.createdBy, moderatorUser.did)) 652 + .leftJoin(subjectUser, eq(modActions.subjectDid, subjectUser.did)) 653 + .orderBy(desc(modActions.createdAt)) 654 + .limit(clampedLimit) 655 + .offset(offsetVal), 656 + ]); 657 + 658 + const total = Number(countResult[0]?.total ?? 0); 659 + 660 + return c.json({ 661 + actions: actions.map((a) => ({ 662 + id: a.id.toString(), 663 + action: a.action, 664 + moderatorDid: a.moderatorDid, 665 + moderatorHandle: a.moderatorHandle ?? a.moderatorDid, 666 + subjectDid: a.subjectDid ?? null, 667 + subjectHandle: a.subjectHandle ?? null, 668 + subjectPostUri: a.subjectPostUri ?? null, 669 + reason: a.reason ?? null, 670 + createdAt: a.createdAt.toISOString(), 671 + })), 672 + total, 673 + offset: offsetVal, 674 + limit: clampedLimit, 675 + }); 676 + } catch (error) { 677 + return handleRouteError(c, error, "Failed to retrieve mod action log", { 678 + operation: "GET /api/admin/modlog", 679 + logger: ctx.logger, 680 + }); 681 + } 682 + } 683 + ); 684 + ``` 685 + 686 + Also add `requireAnyPermission` to the import from the permissions middleware at the top of `admin.ts`: 687 + 688 + ```typescript 689 + import { requireAuth } from "../middleware/auth.js"; 690 + import { requirePermission, requireAnyPermission, getUserRole } from "../middleware/permissions.js"; 691 + ``` 692 + 693 + **Step 3: Run the tests to verify they pass** 694 + 695 + ```bash 696 + PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 697 + pnpm --filter @atbb/appview exec vitest run \ 698 + src/routes/__tests__/admin.test.ts 2>&1 | tail -30 699 + ``` 700 + 701 + Expected: All modlog tests pass. 702 + 703 + **Step 4: Run the full test suite** 704 + 705 + ```bash 706 + PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 707 + pnpm --filter @atbb/appview exec vitest run 708 + ``` 709 + 710 + Expected: All tests pass. If any pre-existing tests fail, investigate before committing. 711 + 712 + **Step 5: Commit** 713 + 714 + ```bash 715 + git add apps/appview/src/routes/admin.ts 716 + git commit -m "feat(appview): GET /api/admin/modlog with double users join (ATB-46)" 717 + ``` 718 + 719 + --- 720 + 721 + ### Task 4: Add Bruno collection file 722 + 723 + **Files:** 724 + - Create: `bruno/AppView API/Admin/Get Mod Action Log.bru` 725 + 726 + **Step 1: Create the Bruno file** 727 + 728 + ``` 729 + meta { 730 + name: Get Mod Action Log 731 + type: http 732 + seq: 13 733 + } 734 + 735 + get { 736 + url: {{appview_url}}/api/admin/modlog 737 + } 738 + 739 + params:query { 740 + limit: 50 741 + offset: 0 742 + } 743 + 744 + assert { 745 + res.status: eq 200 746 + res.body.actions: isArray 747 + res.body.total: isDefined 748 + res.body.offset: isDefined 749 + res.body.limit: isDefined 750 + } 751 + 752 + docs { 753 + Paginated, reverse-chronological list of moderation actions. Joins users 754 + table for both moderator and subject handles. 755 + 756 + **Requires:** any of `space.atbb.permission.moderatePosts`, 757 + `space.atbb.permission.banUsers`, or `space.atbb.permission.lockTopics` 758 + 759 + Query params: 760 + - limit: Max results per page (default: 50, max: 100) 761 + - offset: Number of records to skip (default: 0) 762 + 763 + Returns: 764 + { 765 + "actions": [ 766 + { 767 + "id": "123", 768 + "action": "space.atbb.modAction.ban", 769 + "moderatorDid": "did:plc:abc", 770 + "moderatorHandle": "alice.bsky.social", 771 + "subjectDid": "did:plc:xyz", 772 + "subjectHandle": "bob.bsky.social", 773 + "subjectPostUri": null, 774 + "reason": "Spam", 775 + "createdAt": "2026-02-26T12:01:00Z" 776 + } 777 + ], 778 + "total": 42, 779 + "offset": 0, 780 + "limit": 50 781 + } 782 + 783 + Notes: 784 + - subjectDid / subjectHandle are null for post-targeting actions (hide/lock) 785 + - subjectPostUri is null for user-targeting actions (ban/unban) 786 + - moderatorHandle falls back to moderatorDid if no handle is indexed 787 + - Actions are ordered newest first (createdAt DESC) 788 + 789 + Error codes: 790 + - 400: Invalid limit or offset (non-numeric, negative) 791 + - 401: Not authenticated 792 + - 403: Authenticated but lacks all mod permissions 793 + - 500: Database error 794 + } 795 + ``` 796 + 797 + **Step 2: Commit** 798 + 799 + ```bash 800 + git add "bruno/AppView API/Admin/Get Mod Action Log.bru" 801 + git commit -m "docs(bruno): GET /api/admin/modlog collection (ATB-46)" 802 + ``` 803 + 804 + --- 805 + 806 + ### Task 5: Final verification and Linear update 807 + 808 + **Step 1: Run the full test suite one more time** 809 + 810 + ```bash 811 + PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 812 + pnpm --filter @atbb/appview exec vitest run 813 + ``` 814 + 815 + Expected: All tests pass. 816 + 817 + **Step 2: Update Linear** 818 + 819 + - Change ATB-46 status from **Backlog** → **In Review** 820 + - 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." 821 + 822 + **Step 3: Move the plan doc to complete** 823 + 824 + ```bash 825 + mv docs/plans/2026-03-01-atb-46-modlog-endpoint.md docs/plans/complete/ 826 + mv docs/plans/2026-03-01-atb-46-modlog-endpoint-design.md docs/plans/complete/ 827 + git add docs/plans/complete/ docs/plans/ 828 + git commit -m "docs: move ATB-46 plan docs to complete (ATB-46)" 829 + ``` 830 + 831 + --- 832 + 833 + ## Quick Reference: File Paths 834 + 835 + | File | Action | 836 + |------|--------| 837 + | `apps/appview/src/middleware/permissions.ts` | Add `requireAnyPermission` function | 838 + | `apps/appview/src/middleware/__tests__/permissions.test.ts` | Add 4 tests for `requireAnyPermission` | 839 + | `apps/appview/src/routes/admin.ts` | Add imports (`modActions`, `desc`, `alias`, `requireAnyPermission`) + route | 840 + | `apps/appview/src/routes/__tests__/admin.test.ts` | Update mock + add 11 modlog tests | 841 + | `bruno/AppView API/Admin/Get Mod Action Log.bru` | New Bruno file |