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

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.

+841
+841
docs/plans/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 |