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-48: Admin Mod Action Log UI (/admin/modlog) Implementation Plan#

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add GET /admin/modlog — a paginated, read-only audit log of moderation actions — to the web app's admin panel.

Architecture: The web server proxies GET /api/admin/modlog?limit=50&offset=N from the AppView (already implemented in ATB-46), renders the result as an HTML table, and serves offset-based pagination via Next/Previous links. All permission gating uses the existing canViewModLog() session helper.

Tech Stack: Hono (web server), Hono JSX (templates), Vitest (tests). No new files — only apps/web/src/routes/admin.tsx and apps/web/src/routes/__tests__/admin.test.tsx are touched.


Context You Need to Know#

File locations#

  • Route to modify: apps/web/src/routes/admin.tsx (add new route before return app; at line ~1249)
  • Test file to modify: apps/web/src/routes/__tests__/admin.test.tsx (add new describe block at the end)
  • Session helpers: apps/web/src/lib/session.tscanViewModLog() already exists
  • AppView endpoint already exists: GET /api/admin/modlog in apps/appview/src/routes/admin.ts

AppView response shape#

{
  "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
}

Actual action token values (from mod.ts — differ from design doc)#

Token Human label
space.atbb.modAction.ban Ban
space.atbb.modAction.unban Unban
space.atbb.modAction.lock Lock
space.atbb.modAction.unlock Unlock
space.atbb.modAction.delete Hide
space.atbb.modAction.undelete Unhide

Subject column logic#

  • If subjectPostUri is non-null → show the post URI (post-targeting action)
  • Else if subjectHandle is non-null → show the handle (user-targeting action)
  • Else → show subjectDid as fallback (handle not indexed)

Permission gate#

Any of: space.atbb.permission.moderatePosts, space.atbb.permission.banUsers, space.atbb.permission.lockTopics (or *). Use canViewModLog(auth) from session.ts.

Pagination design#

  • 50 rows per page, read ?offset from query string (default 0)
  • Previous link: href="/admin/modlog?offset={offset - 50}" (hidden when offset === 0)
  • Next link: href="/admin/modlog?offset={offset + 50}" (hidden when offset + limit >= total)
  • Page indicator: Page {currentPage} of {totalPages}

Test pattern (copy from existing describe blocks)#

Each describe block in the test file:

  1. beforeEach stubs global fetch, APPVIEW_URL, and calls vi.resetModules()
  2. afterEach unstubs everything
  3. setupSession(permissions) mock sets up the two-call auth sequence (session + members/me)
  4. loadAdminRoutes() does dynamic import to get a fresh Hono app per test

Task 1: Add types and helpers to admin.tsx#

Files:

  • Modify: apps/web/src/routes/admin.tsx (add after the BoardEntry interface, around line 48)

Step 1: Add ModLogEntry interface and ACTION_LABELS map

Add these after the BoardEntry interface (before the // ─── Helpers ─── section):

interface ModLogEntry {
  id: string;
  action: string;
  moderatorDid: string;
  moderatorHandle: string;
  subjectDid: string | null;
  subjectHandle: string | null;
  subjectPostUri: string | null;
  reason: string | null;
  createdAt: string;
}

const ACTION_LABELS: Record<string, string> = {
  "space.atbb.modAction.ban": "Ban",
  "space.atbb.modAction.unban": "Unban",
  "space.atbb.modAction.lock": "Lock",
  "space.atbb.modAction.unlock": "Unlock",
  "space.atbb.modAction.delete": "Hide",
  "space.atbb.modAction.undelete": "Unhide",
};

Step 2: Add formatModLogDate helper

Add after formatJoinedDate in the // ─── Helpers ─── section:

function formatModLogDate(isoString: string): string {
  const d = new Date(isoString);
  if (isNaN(d.getTime())) return "—";
  return d.toLocaleString("en-US", {
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
    hour: "2-digit",
    minute: "2-digit",
    hour12: false,
  });
}

Step 3: No test to run yet — verify TypeScript compiles

Run: PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin pnpm --filter @atbb/web build (or skip — build happens in Task 3 after route is added)

Step 4: No commit yet — combine with Task 2


Task 2: Add ModLogRow component to admin.tsx#

Files:

  • Modify: apps/web/src/routes/admin.tsx (add in the // ─── Components ─── section, after MemberRow)

Step 1: Add ModLogRow component

Add after the MemberRow component (around line 122):

function ModLogRow({ entry }: { entry: ModLogEntry }) {
  const label = ACTION_LABELS[entry.action] ?? entry.action;
  const subject = entry.subjectPostUri
    ? entry.subjectPostUri
    : (entry.subjectHandle ?? entry.subjectDid ?? "—");

  return (
    <tr>
      <td class="modlog-table__time">{formatModLogDate(entry.createdAt)}</td>
      <td class="modlog-table__moderator">{entry.moderatorHandle}</td>
      <td class="modlog-table__action">
        <span class={`modlog-action-badge modlog-action-badge--${label.toLowerCase()}`}>
          {label}
        </span>
      </td>
      <td class="modlog-table__subject">{subject}</td>
      <td class="modlog-table__reason">{entry.reason ?? "—"}</td>
    </tr>
  );
}

Step 2: No commit yet — combine with Task 3


Task 3: Add GET /admin/modlog route to admin.tsx#

Files:

  • Modify: apps/web/src/routes/admin.tsx (add before return app; at the end of createAdminRoutes)

Step 1: Write the failing test first (see Task 4, Step 1)

Skip ahead to Task 4 to write one smoke-test first, then come back here.

Step 2: Add the route

Add before return app; (around line 1249):

// ── GET /admin/modlog ─────────────────────────────────────────────────────

app.get("/admin/modlog", async (c) => {
  const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));

  if (!auth.authenticated) {
    return c.redirect("/login");
  }

  if (!canViewModLog(auth)) {
    return c.html(
      <BaseLayout title="Access Denied — atBB Forum" auth={auth}>
        <PageHeader title="Mod Action Log" />
        <p>You don&apos;t have permission to view the mod action log.</p>
      </BaseLayout>,
      403
    );
  }

  const rawOffset = c.req.query("offset");
  const offset = rawOffset !== undefined && /^\d+$/.test(rawOffset)
    ? parseInt(rawOffset, 10)
    : 0;
  const limit = 50;

  const cookie = c.req.header("cookie") ?? "";

  let modlogRes: Response;
  try {
    modlogRes = await fetch(
      `${appviewUrl}/api/admin/modlog?limit=${limit}&offset=${offset}`,
      { headers: { Cookie: cookie } }
    );
  } catch (error) {
    if (isProgrammingError(error)) throw error;
    logger.error("Network error fetching mod action log", {
      operation: "GET /admin/modlog",
      error: error instanceof Error ? error.message : String(error),
    });
    return c.html(
      <BaseLayout title="Mod Action Log — atBB Forum" auth={auth}>
        <PageHeader title="Mod Action Log" />
        <ErrorDisplay
          message="Unable to load mod action log"
          detail="The forum is temporarily unavailable. Please try again."
        />
      </BaseLayout>,
      503
    );
  }

  if (!modlogRes.ok) {
    if (modlogRes.status === 401) {
      return c.redirect("/login");
    }
    logger.error("AppView returned error for mod action log", {
      operation: "GET /admin/modlog",
      status: modlogRes.status,
    });
    return c.html(
      <BaseLayout title="Mod Action Log — atBB Forum" auth={auth}>
        <PageHeader title="Mod Action Log" />
        <ErrorDisplay
          message="Something went wrong"
          detail="Could not load mod action log. Please try again."
        />
      </BaseLayout>,
      500
    );
  }

  const data = (await modlogRes.json()) as {
    actions: ModLogEntry[];
    total: number;
    offset: number;
    limit: number;
  };

  const { actions, total } = data;
  const totalPages = total === 0 ? 1 : Math.ceil(total / limit);
  const currentPage = Math.floor(offset / limit) + 1;
  const hasPrev = offset > 0;
  const hasNext = offset + limit < total;

  return c.html(
    <BaseLayout title="Mod Action Log — atBB Forum" auth={auth}>
      <PageHeader title="Mod Action Log" />
      {actions.length === 0 ? (
        <EmptyState message="No moderation actions yet" />
      ) : (
        <div class="card">
          <table class="modlog-table">
            <thead>
              <tr>
                <th scope="col">Time</th>
                <th scope="col">Moderator</th>
                <th scope="col">Action</th>
                <th scope="col">Subject</th>
                <th scope="col">Reason</th>
              </tr>
            </thead>
            <tbody>
              {actions.map((entry) => (
                <ModLogRow entry={entry} />
              ))}
            </tbody>
          </table>
        </div>
      )}
      <div class="modlog-pagination">
        {hasPrev && (
          <a href={`/admin/modlog?offset=${offset - limit}`} class="btn btn-secondary">
             Previous
          </a>
        )}
        <span class="modlog-pagination__indicator">
          Page {currentPage} of {totalPages}
        </span>
        {hasNext && (
          <a href={`/admin/modlog?offset=${offset + limit}`} class="btn btn-secondary">
            Next 
          </a>
        )}
      </div>
    </BaseLayout>
  );
});

Step 3: Run tests (after writing them in Task 4)

Run: PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin pnpm --filter @atbb/web test Expected: All tests pass

Step 4: Commit

git add apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx
git commit -m "feat(web): admin mod action log page — /admin/modlog (ATB-48)"

Task 4: Add tests to admin.test.tsx#

Files:

  • Modify: apps/web/src/routes/__tests__/admin.test.tsx (add new describe block at the end)

Step 1: Write the describe block with all required tests

Append to the end of admin.test.tsx:

describe("createAdminRoutes — GET /admin/modlog", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", mockFetch);
    vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
    vi.resetModules();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
    mockFetch.mockReset();
  });

  function mockResponse(body: unknown, ok = true, status = 200) {
    return {
      ok,
      status,
      statusText: ok ? "OK" : "Error",
      json: () => Promise.resolve(body),
    };
  }

  function setupSession(permissions: string[]) {
    mockFetch.mockResolvedValueOnce(
      mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
    );
    mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
  }

  async function loadAdminRoutes() {
    const { createAdminRoutes } = await import("../admin.js");
    return createAdminRoutes("http://localhost:3000");
  }

  const SAMPLE_ACTIONS = [
    {
      id: "1",
      action: "space.atbb.modAction.ban",
      moderatorDid: "did:plc:alice",
      moderatorHandle: "alice.bsky.social",
      subjectDid: "did:plc:bob",
      subjectHandle: "bob.bsky.social",
      subjectPostUri: null,
      reason: "Spam",
      createdAt: "2026-02-26T12:01:00.000Z",
    },
    {
      id: "2",
      action: "space.atbb.modAction.delete",
      moderatorDid: "did:plc:alice",
      moderatorHandle: "alice.bsky.social",
      subjectDid: null,
      subjectHandle: null,
      subjectPostUri: "at://did:plc:bob/space.atbb.post/abc123",
      reason: "Inappropriate",
      createdAt: "2026-02-26T11:30:00.000Z",
    },
  ];

  // ── Auth & permission gates ──────────────────────────────────────────────

  it("redirects unauthenticated users to /login", async () => {
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog");
    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/login");
  });

  it("returns 403 for user without any mod permission", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(403);
    const html = await res.text();
    expect(html).toContain("permission");
  });

  it("allows access for moderatePosts permission", async () => {
    setupSession(["space.atbb.permission.moderatePosts"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: [], total: 0, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(200);
  });

  it("allows access for banUsers permission", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: [], total: 0, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(200);
  });

  it("allows access for lockTopics permission", async () => {
    setupSession(["space.atbb.permission.lockTopics"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: [], total: 0, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(200);
  });

  // ── Table rendering ──────────────────────────────────────────────────────

  it("renders table with moderator handle and action label", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain("alice.bsky.social");
    expect(html).toContain("Ban");
    expect(html).toContain("bob.bsky.social");
    expect(html).toContain("Spam");
  });

  it("maps space.atbb.modAction.delete to 'Hide' label", async () => {
    setupSession(["space.atbb.permission.moderatePosts"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain("Hide");
  });

  it("shows post URI in subject column for post-targeting actions", async () => {
    setupSession(["space.atbb.permission.moderatePosts"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain("at://did:plc:bob/space.atbb.post/abc123");
  });

  it("shows handle in subject column for user-targeting actions", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain("bob.bsky.social");
  });

  it("shows empty state when no actions", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: [], total: 0, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain("No moderation actions");
  });

  // ── Pagination ───────────────────────────────────────────────────────────

  it("renders 'Page 1 of 2' indicator for 51 total actions", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain("Page 1 of 2");
  });

  it("shows Next link when more pages exist", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain('href="/admin/modlog?offset=50"');
    expect(html).toContain("Next");
  });

  it("hides Next link on last page", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 50, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog?offset=50", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).not.toContain('href="/admin/modlog?offset=100"');
  });

  it("shows Previous link when not on first page", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 50, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog?offset=50", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain('href="/admin/modlog?offset=0"');
    expect(html).toContain("Previous");
  });

  it("hides Previous link on first page", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).not.toContain('href="/admin/modlog?offset=-50"');
    expect(html).not.toContain("Previous");
  });

  it("passes offset query param to AppView", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 100, offset: 50, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    await routes.request("/admin/modlog?offset=50", {
      headers: { cookie: "atbb_session=token" },
    });
    // Third fetch call (index 2) is the modlog API call
    const modlogCall = mockFetch.mock.calls[2];
    expect(modlogCall[0]).toContain("offset=50");
    expect(modlogCall[0]).toContain("limit=50");
  });

  it("ignores invalid offset and defaults to 0", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog?offset=notanumber", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(200);
    const modlogCall = mockFetch.mock.calls[2];
    expect(modlogCall[0]).toContain("offset=0");
  });

  // ── Error handling ───────────────────────────────────────────────────────

  it("returns 503 on AppView network error", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(503);
    const html = await res.text();
    expect(html).toContain("error-display");
  });

  it("returns 500 on AppView server error", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(500);
    const html = await res.text();
    expect(html).toContain("error-display");
  });

  it("redirects to /login when AppView returns 401", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401));
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/login");
  });
});

Step 2: Run the tests (all should fail — route not added yet)

Run: PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin pnpm --filter @atbb/web test Expected: New modlog tests FAIL ("Cannot read properties of undefined" or similar)

Step 3: Now go implement Tasks 1–3 (types, component, route)

Step 4: Run tests again

Run: PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin pnpm --filter @atbb/web test Expected: All tests PASS

Step 5: Commit

git add apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx
git commit -m "feat(web): admin mod action log page — /admin/modlog (ATB-48)"

Task 5: Run full test suite#

Step 1: Run all tests

Run: PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin pnpm test Expected: All tests pass

Step 2: If tests fail — check whether it's a Turbo env var issue. See CLAUDE.md section "Environment Variables in Tests".


Task 6: Update documentation#

Step 1: Move plan doc to complete/

No — plan docs only move to docs/plans/complete/ after work is shipped (merged). Leave this plan in docs/plans/ until the PR is merged.

Step 2: Update docs/atproto-forum-plan.md

In docs/atproto-forum-plan.md, find the ATB-48 entry and mark it [x]. Add a note: ATB-48: /admin/modlog UI — see PR.

Step 3: Update Linear

  • Change ATB-48 status from Backlog → In Progress → Done
  • Add a comment: "Implemented GET /admin/modlog in apps/web/src/routes/admin.tsx. Permission gate uses canViewModLog(). Offset pagination with 50 rows per page. Action labels mapped from actual space.atbb.modAction.* tokens."

Step 4: Commit doc update (after PR is approved)

git add docs/atproto-forum-plan.md
git commit -m "docs: mark ATB-48 complete"

Task 7: Open PR#

git push -u origin HEAD
gh pr create \
  --title "feat(web): admin mod action log UI — /admin/modlog (ATB-48)" \
  --body "$(cat <<'EOF'
## Summary
- Adds `GET /admin/modlog` to the web admin panel
- Permission gate: any of `moderatePosts`, `banUsers`, `lockTopics`
- Renders a table with timestamp, moderator, action (human-readable label), subject, and reason
- Offset-based pagination: 50 rows per page, Previous/Next links
- Action tokens mapped to labels: `space.atbb.modAction.delete` → Hide, `space.atbb.modAction.lock` → Lock, etc.
- Subject column shows handle for user-targeting actions, AT URI for post-targeting actions
- Error handling: 503 for network errors, 500 for server errors, redirect for 401

## Test plan
- [ ] All new tests in `admin.test.tsx` pass
- [ ] Full `pnpm test` passes
- [ ] Manual smoke: log in with a mod permission, navigate to `/admin/modlog`
- [ ] Manual: verify pagination Next/Previous links work with offset param
- [ ] Manual: verify the admin landing page mod log card links to `/admin/modlog`
EOF
)"