Appearance
Control-plane internals
Chapter 17. This chapter opens the admin API from Chapter 7: the guard chain every request runs, and how the assistant is held to exactly the same guards. It closes the deep-dive internals; two final chapters — the admin SPA and how it's all tested — follow in Part V.
The admin-api request lifecycle, guard by guard. Base: apps/admin-api/src/{middleware,services}. The rule (ADR-010): Clerk verifies identity; the memberships table authorizes per-store; deny-by-default. The AI drives the same API as humans.
Middleware chain (in order)
Every request runs, in this exact order: CORS → body limit → authMiddleware → csrfGuard → rate limiter → auditMiddleware → route (then onError maps ConflictError/IdempotencyInProgressError → 409, ThemeNotInStore → 404, else a generic 500).
AuthN — middleware/auth.ts
The default verifier is composeVerifiers(agentVerifier, insecureDevClerkVerifier, clerkVerifier) — first non-null identity wins, agent tokens tried first (cheap local HMAC).
clerkVerifier— prefersCLERK_JWT_KEY(PEM, fully offline) elseCLERK_SECRET_KEY(JWKS fetched+cached); returns{ userId: sub, name, email }(a best-effort claims snapshot for the audit trail). Neither key →null(composes to a 401, never throws).insecureDevClerkVerifier— base64url-decodes the JWT and trustssubwithout verifying the signature; hard-blocked in production (NODE_ENV==='production'disables it). authZ still runs.agentVerifier— therat_token:rat_<base64url(claims)>.<base64url(HMAC-SHA256(body, AGENT_TOKEN_SECRET))>. Claims{ sub, scope: string[], exp }. Verify is entirely offline (recompute HMAC,timingSafeEqualafter a length guard, checkexp).subis the principal user;scopeis the tenant ids it may touch ('*'= all).
The presence of scope on the identity is the single signal "this is an agent token" — used by authZ, audit (actor_kind), and the mint self-block.
AuthZ — requireRole / requireMembership
requireRole(...allowed) runs in a security-critical order:
- Scope narrowing first — if the token has a
scope, it doesn't include'*', and the route's:idisn't in it → 403 out of scope. Checked before the platform-admin bypass, so a scoped agent token held by staff still can't reach beyond its scope. - Platform-admin bypass —
isPlatformAdmin(userId)(thePLATFORM_ADMIN_IDSallowlist, the one cross-tenant escape hatch) → allow. - Membership —
SELECT role FROM memberships WHERE clerk_user_id=$1 AND tenant_id=$2; no row → 403 (deny-by-default). - Role — if
allowednon-empty and the row's role isn't in it → 403.
Agent-token scope can only narrow: the token's sub is a real user, and authZ re-runs getMembership(sub, tenant) on every use — the token can never exceed what that human can access; the scope check additionally restricts. denyNarrowedScope guards store-less routes (POST /stores, /assistant, /admin/users) that have no :id to bind a scope against — a narrowing token there is refused.
Idempotency, rate limit, CSRF
- Idempotency (Postgres, TTL 10 min): claim
INSERT … status='running' ON CONFLICT DO NOTHING(the PK makes one instance the owner) → on successUPDATE … status='done', result=$2→ on failureDELETE … WHERE status='running'(failures are never cached — retryable). A still-running key in TTL →IdempotencyInProgressError(409). An expiredrunningis reclaimed by an age-guarded UPDATE so exactly one racer wins. Only/assistantuses it (keyed per user + message hash). - Rate limit (Postgres fixed-window, shared across instances): general 300/min, assistant 20/min; fail-open (a DB outage disables throttling — availability over enforcement). The assistant fan-out is exempted via a per-process
x-ratio-internaltoken. - CSRF — checks
Originagainst the allowlist, but skips Bearer requests entirely (browsers never attach a bearer cross-site; the guard exists only for the ambient__sessioncookie). Safe methods pass.
Audit — middleware/audit.ts
Post-handler (needs res.status), mutations only, authenticated actors only. Writes an audit_log row: actor, actor_kind = scope ? 'agent' : 'user', action (a resource:verb scope string from a route catalogue, else the raw METHOD /route/:id), method, path, status, duration_ms, request_id, source (capped UA), actor_name/email. The write is best-effort (a logging failure must not fail a succeeded request). queryAudit powers the Activity tabs (all / you / ai / team / needs-attention) with FILTER counts and a whitelisted ORDER BY.
The AI assistant goes through the same guards
The assistant mints a self-scoped agent token (scope=[storeId], 15-min exp) and drives a RatioControlPlane SDK whose fetch is viaSelf — it re-enters app.fetch in-process, so every tool call passes the identical middleware chain (authN → csrf → rate-limit → audit → route guards). There is no AI backdoor: the model can only do what its scoped token allows, and every effect is audited as actor_kind='agent'. The tools are list_stores, create_store, get_store, add_or_edit_page (= savePageDraft then publishPage), connect_domain; a graceful MAX_STEPS=8 backstops a looping agent.
Agent-token mint (POST /stores/:id/agent-tokens) is requireRole('owner') and human-only (a token can't mint a token — it would defeat the short-lived guarantee), short-lived (1 h) and store-scoped.
Gotchas
- Scope check precedes the platform-admin bypass — admin power does not leak into a scoped agent token.
denyNarrowedScopevs therequireRolescope check are two different guards — forgetting the former on a new store-less mutating route would let a scoped token act unscoped.insecureDevClerkVerifiertrusts an unsignedsub— safe only because the production gate disables it; it's in the default verifier list.- Idempotency failure-delete is best-effort — if the DELETE fails, the key stays
runningand blocks retries until the 10-min reclaim. - The rate limiter fails open — a Postgres outage disables throttling.
- Audit only records mutations by authenticated actors, and the write is swallowed on failure — an action can succeed with no audit row.
request_id/source/actor_name/emailare client-controlled (capped) — don't treat them as trustworthy.scope: ['*']passesdenyNarrowedScope— a leaked onboarding-session assistant token is materially more dangerous than a store-scoped one (mitigated only by the 15-minexp).add_or_edit_pageis two non-atomic calls (save then publish) — a crash between leaves a saved-but-unpublished draft.