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 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.

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