A House Divided A House DividedDocumentation
Changelog
Engineering/Conventions

API Route Checklist

Last updated 2026-08-21

Use this checklist when creating or reviewing any route.ts file under src/app/api/.

Canonical Route Structure#

import { NextResponse } from "next/server";
import { getDb } from "@/lib/mongodb";
import { requireAuth } from "@/lib/api/requireAuth"; // or requireAdmin / requireCron
import { parseJsonBody } from "@/lib/api/validate";
import { handleRouteError } from "@/lib/api/errors";
import { z } from "zod";

const schema = z.object({/* ... */});

export async function POST(request: Request) {
  try {
    const auth = await requireAuth();
    if (!auth.ok) return auth.response;

    const parsed = await parseJsonBody(request, schema);
    if (!parsed.success)
      return NextResponse.json(
        { error: parsed.error },
        { status: parsed.status },
      );

    const db = await getDb();
    // ... business logic ...
    return NextResponse.json({ success: true });
  } catch (error) {
    return handleRouteError(error);
  }
}

Checklist#

1. Auth Guard#

Route type Correct guard Wrong pattern
Admin route requireAdmin() getAuthUser() + if (!user?.isAdmin)
Player (basic) requireBasicAuth() Manual required-auth branching
Player (character) requireAuthWithCharacter() requireAuth() + manual character check
Human session requireHumanSession*() Token-capable guard on human-only write
Moderator requireModerator() Manual role check
Bot token requireBotToken() Ad hoc header parsing
Cron route requireCron(request) → return 401 No check / JWT check

2. Error Handling#

3. Body Validation#

4. Dynamic Route Params (Next.js 16)#

5. Response Shape#

6. Rate Limiting#

7. Authorization (Resource Ownership)#

8. Data Safety#


Common Mistakes#

Mistake Risk Fix
getAuthUser() + if (!user?.isAdmin) on an admin route Stale JWT isAdmin claim bypasses DB-authoritative check Use requireAdmin()
Missing try/catch wrapper DB errors crash with unformatted 500, no Sentry capture Wrap full handler in try { ... } catch { return handleRouteError(error); }
request.json() without Zod validation Type coercion, missing fields, object injection Use parseJsonBody(request, schema)
params.id without await Next.js 16 params is a Promise; accessing .id returns undefined const { id } = await params
Returning full DB document Exposes password hash, IP, admin flags Explicitly project/whitelist fields
new ObjectId(untrustedString) without validation Throws on invalid input, may return unhandled 500 Validate with schemas.objectId or ObjectId.isValid() first

Auth Helper Quick Reference#

Helper Import Returns When to use
requireAuth() @/lib/api/requireAuth { ok, user } with optional character Most player routes
requireBasicAuth() @/lib/api/requireAuth { ok, user } without character lookup Fast auth-only checks
requireAuthWithCharacter() @/lib/api/requireAuth { ok, user } with guaranteed character Routes needing character data
requireHumanSession() @/lib/api/requireAuth Authenticated human session Human-only mutations
requireHumanSessionWithCharacter() @/lib/api/requireAuth Human session with character Human-only character writes
requireAdmin() @/lib/api/requireAdmin { ok, admin } All /api/admin/* routes
requireModerator() @/lib/api/requireModerator Moderator authorization Moderator routes
requireBotToken() / requirePublicBotToken() @/lib/api/requireBotToken Bot authorization Bot-facing routes
requireAdminOrApiKey(request) @/lib/api/requireAdminOrApiKey { ok, via } Script/automation routes
requireCron(request) @/lib/api/requireCron boolean Cron-triggered routes

All require* helpers return { ok: false; response } on failure. Pattern:

const auth = await requireAdmin();
if (!auth.ok) return auth.response;

Audit History#

These dated route counts and findings are snapshots, not a current assertion that every route remains covered. The repository has grown substantially since the 2026 audits.

Date Auditor Findings
2026-03-23 Claude (automated audit) admin/bills GET/POST missing try/catch; admin/politician-pages/backfill and admin/campaigns/[id]/assign-manager using getAuthUser()+isAdmin instead of requireAdmin(); assign-manager POST using request.json() without Zod validation. All three fixed.
2026-04-15 Claude (automated audit) 615 routes scanned. No raw request.json() usage found. No admin routes using getAuthUser() instead of requireAdmin(). 4 cabinet position routes missing COUNTRY_CONFIGS validation on as CountryId cast (allocation, briefing, order, setting). All auth guards accounted for (routes use requireAuth, requireAdmin, requireModerator, requireBotToken, requireCron, or are documented public). See findings below.