Skip to content

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 → authMiddlewarecsrfGuard → 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 — prefers CLERK_JWT_KEY (PEM, fully offline) else CLERK_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 trusts sub without verifying the signature; hard-blocked in production (NODE_ENV==='production' disables it). authZ still runs.
  • agentVerifier — the rat_ token: rat_<base64url(claims)>.<base64url(HMAC-SHA256(body, AGENT_TOKEN_SECRET))>. Claims { sub, scope: string[], exp }. Verify is entirely offline (recompute HMAC, timingSafeEqual after a length guard, check exp). sub is the principal user; scope is 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:

  1. Scope narrowing first — if the token has a scope, it doesn't include '*', and the route's :id isn'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.
  2. Platform-admin bypassisPlatformAdmin(userId) (the PLATFORM_ADMIN_IDS allowlist, the one cross-tenant escape hatch) → allow.
  3. MembershipSELECT role FROM memberships WHERE clerk_user_id=$1 AND tenant_id=$2; no row → 403 (deny-by-default).
  4. Role — if allowed non-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 success UPDATE … status='done', result=$2on failure DELETE … WHERE status='running' (failures are never cached — retryable). A still-running key in TTL → IdempotencyInProgressError (409). An expired running is reclaimed by an age-guarded UPDATE so exactly one racer wins. Only /assistant uses 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-internal token.
  • CSRF — checks Origin against the allowlist, but skips Bearer requests entirely (browsers never attach a bearer cross-site; the guard exists only for the ambient __session cookie). 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

  1. Scope check precedes the platform-admin bypass — admin power does not leak into a scoped agent token.
  2. denyNarrowedScope vs the requireRole scope check are two different guards — forgetting the former on a new store-less mutating route would let a scoped token act unscoped.
  3. insecureDevClerkVerifier trusts an unsigned sub — safe only because the production gate disables it; it's in the default verifier list.
  4. Idempotency failure-delete is best-effort — if the DELETE fails, the key stays running and blocks retries until the 10-min reclaim.
  5. The rate limiter fails open — a Postgres outage disables throttling.
  6. Audit only records mutations by authenticated actors, and the write is swallowed on failure — an action can succeed with no audit row.
  7. request_id/source/actor_name/email are client-controlled (capped) — don't treat them as trustworthy.
  8. scope: ['*'] passes denyNarrowedScope — a leaked onboarding-session assistant token is materially more dangerous than a store-scoped one (mitigated only by the 15-min exp).
  9. add_or_edit_page is two non-atomic calls (save then publish) — a crash between leaves a saved-but-unpublished draft.