ATB-46 — Admin Mod Action Log Endpoint#
Date: 2026-03-01 Status: Approved Linear: ATB-46
Summary#
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.
Permissions Middleware Extension#
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.
export function requireAnyPermission(ctx: AppContext, permissions: string[]) {
return async (c, next) => {
const user = c.get("user");
if (!user) return c.json({ error: "Authentication required" }, 401);
for (const perm of permissions) {
if (await checkPermission(ctx, user.did, perm)) return next();
}
return c.json({ error: "Insufficient permissions" }, 403);
};
}
Endpoint#
GET /api/admin/modlog?limit=50&offset=0
Auth chain: requireAuth(ctx) → requireAnyPermission(ctx, ["space.atbb.permission.moderatePosts", "space.atbb.permission.banUsers", "space.atbb.permission.lockTopics"])
Pagination defaults: limit=50, offset=0. Cap limit at 100.
Invalid params: non-numeric or negative limit/offset → 400.
Query Strategy#
The users table is joined twice using Drizzle's alias():
innerJoinonmoderator_userviamodActions.createdBy = moderatorUser.did(moderator always has a users row)leftJoinonsubject_userviamodActions.subjectDid = subjectUser.did(null for post-targeting actions)
const moderatorUser = alias(users, "moderator_user");
const subjectUser = alias(users, "subject_user");
const actions = await ctx.db
.select({
id: modActions.id,
action: modActions.action,
moderatorDid: modActions.createdBy,
moderatorHandle: moderatorUser.handle,
subjectDid: modActions.subjectDid,
subjectHandle: subjectUser.handle,
subjectPostUri: modActions.subjectPostUri,
reason: modActions.reason,
createdAt: modActions.createdAt,
})
.from(modActions)
.innerJoin(moderatorUser, eq(modActions.createdBy, moderatorUser.did))
.leftJoin(subjectUser, eq(modActions.subjectDid, subjectUser.did))
.orderBy(desc(modActions.createdAt))
.limit(limitVal)
.offset(offsetVal);
Total count uses a separate query with no join (only mod_actions rows are counted):
const [{ total }] = await ctx.db
.select({ total: count() })
.from(modActions);
Handle Fallback#
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).
Response#
{
"actions": [
{
"id": "123",
"action": "space.atbb.modAction.ban",
"moderatorDid": "did:plc:abc",
"moderatorHandle": "alice.bsky.social",
"subjectDid": "did:plc:xyz",
"subjectHandle": "bob.bsky.social",
"subjectPostUri": null,
"reason": "Spam",
"createdAt": "2026-02-26T12:01:00Z"
}
],
"total": 42,
"offset": 0,
"limit": 50
}
BigInt id is serialized as a string. createdAt is ISO 8601.
Tests#
| Scenario | Expected |
|---|---|
Returns actions in createdAt DESC order |
200 |
moderatorHandle joined from users via createdBy |
correct handle |
subjectHandle populated for user-targeting actions |
correct handle |
subjectHandle null for post-targeting actions |
null |
subjectPostUri null for user-targeting actions |
null |
moderatorHandle falls back to DID when no handle indexed |
DID string |
limit and offset params respected |
correct slice |
Default limit=50, offset=0 |
correct defaults |
Non-numeric or negative limit/offset |
400 |
| Unauthenticated | 401 |
| Authenticated, no mod permissions | 403 |
Authenticated with moderatePosts |
200 |
Authenticated with banUsers |
200 |
Authenticated with lockTopics |
200 |
Files Modified#
| File | Change |
|---|---|
apps/appview/src/middleware/permissions.ts |
Add requireAnyPermission |
apps/appview/src/routes/admin.ts |
Add GET /modlog route |
apps/appview/src/routes/__tests__/admin.test.ts |
Add modlog tests |
bruno/appview/Admin/Get Mod Action Log.bru |
New Bruno collection file |