Appearance
Control plane & AI
Chapter 7. So far, the shopper's side. This chapter turns to the merchant's: the admin API that writes changes — and the AI assistant that drives the very same API.
The admin SPA and the AI assistant use the same logged-in API, through the same guards. Clerk says who you are; the memberships table says what you can do. Whatever the AI does is as real — and as audited — as a human click.
The write path, guard by guard
The guards, in order
Every control-plane request passes the same gates (apps/admin-api):
- AuthN — Clerk. The SPA signs in with Clerk and sends a Bearer JWT; the admin-api verifies it against Clerk's JWKS. (Agents send a
rat_token instead — see below.) - AuthZ —
memberships. The(clerk_user_id, tenant_id) → roletable decides what the caller may do, deny-by-default. No membership row → no access. - Rate-limit + idempotency. Per-user (and per-
/assistant) limits in Postgres; write requests carry an idempotency key so a retry can't double-apply. - Tenant-scoped effect. All reads/writes go through the same
forTenant(id)repository as the storefront — a query with notenantIdthrows. - Audit. Every action is written to
audit_log(actor, actor-kind user/agent, action, method, path, status).
The AI assistant is not special-cased
The assistant (POST /assistant) runs through the exact same guards as a human click. It calls Anthropic with a prompt + a tool schema; the tool calls it gets back are executed as validated, tenant-scoped effects (onboard a store, add a page, connect a domain) — each one authorised, rate-limited, idempotent, and audited.
Why this matters
There's no "AI backdoor". The model can only do what the signed-in caller could already do, and every effect leaves an audit trail marked actor_kind = agent.
Agent tokens (for automation / AI agents)
Instead of a Clerk session, an automation caller can present a rat_ agent token:
- Short-lived + HMAC-signed — verified by the admin-api without a round trip.
- Store-scoped — bound to one tenant.
- Scope-narrowing only — a token can only reduce what the caller may do, never widen it.
The API surface (reference)
Grouped, with the auth each needs (requireMembership = any member; requireRole('owner') = owner-only; platform-admin = allowlisted super-admin):
| Area | Endpoints (method) | Auth |
|---|---|---|
| Stores | GET/POST /stores · GET/PATCH/DELETE /stores/:id · POST /stores/:id/agent-tokens · GET …/audit | member / owner |
| Domains | GET/POST/DELETE /stores/:id/domains · GET /stores/:id/domain | member / owner |
| Commerce | POST /commerce/verify · GET/PUT /stores/:id/commerce · GET/PUT /stores/:id/integrations | member / owner |
| Themes | GET/POST /stores/:id/themes · PATCH/DELETE …/:themeId · POST …/:themeId/activate · GET …/versions | member / owner |
| Theme bundle | PUT/GET …/theme/bundle/draft · POST …/{preview,publish,rollback,reset,adopt} | member / owner |
| Base themes | GET /base-themes · GET /base-themes/:id/preview | any member |
| Page builder | GET /page-builder/catalog · GET/PUT /stores/:id/page-builder · POST …/publish | member |
| Assistant | POST /assistant | member (no scope) |
| Super-admin | GET /admin/users · GET/POST /admin/base-theme… · …/edit/{draft,preview,publish,reset} | platform-admin |
| System | GET /health /ready /openapi.json /me · POST /webhooks/commerce | public / HMAC |
Note what's not here: there are no membership-management endpoints yet (invite / change-role / remove) — the owner row is written at onboarding and roles are only enforced. "Settings" is spread across rename, commerce, integrations, and domains rather than one surface.
Adding a route here?
A new store-less mutating route (no :id to bind a scope against — like POST /stores or /assistant) must add denyNarrowedScope, or a store-scoped agent token could act unscoped. See Control-plane internals and the guardrails.
Cache invalidation & prewarm on a live change
Every live change — publish, rollback, activate, rebase, commerce/integrations edit, rename — runs the same post-change routine so shoppers see the new version fast and consistently:
- The durable outbox means a purge survives a failed purge call (it's retried), so an edit can't get stuck cached.
purgeStoreUrlsis fail-closed in prod — if it can't confirm the purge it doesn't pretend success.
Commerce webhook — honest status
POST /webhooks/commerce receives GoKwik order/product/collection events (HMAC-SHA256 over the raw body) and maps each to surrogate tags. In production it is effectively a no-op today — it returns the mapped tags with invalidated: [] and a "pending" note, because Cloudflare cache-tag purge is Enterprise-only and there's no tag→URL index yet. So don't assume live per-URL purge from a backend change (tracked follow-up).
Activity & audit
Every mutation is recorded post-handler by auditMiddleware into audit_log (actor, actor-kind user/agent, action, method, path, status, duration, request id). That table powers the store's Activity view — filterable by you / AI / team and a needs-attention tab (GET /stores/:id/audit).
Super-admin — base library & propagation
Platform admins (allowlisted via PLATFORM_ADMIN_IDS, the one cross-tenant escape hatch) own the shared base and its rollout — a destructive-to-many surface, so every route is platform-admin + not-scopeable:
This is the operator face of base propagation — the editor cuts the version, the console rolls it out in stages.
Deeper reading
Confluence: control-plane security is ADR-010 (Clerk authN + Postgres authZ) and agent tokens are ADR-007. See the ADR index. For how the admin write reaches the live storefront, see the write path.