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
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

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():

  • innerJoin on moderator_user via modActions.createdBy = moderatorUser.did (moderator always has a users row)
  • leftJoin on subject_user via modActions.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