Skip to content

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):

  1. 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.)
  2. AuthZ — memberships. The (clerk_user_id, tenant_id) → role table decides what the caller may do, deny-by-default. No membership row → no access.
  3. Rate-limit + idempotency. Per-user (and per-/assistant) limits in Postgres; write requests carry an idempotency key so a retry can't double-apply.
  4. Tenant-scoped effect. All reads/writes go through the same forTenant(id) repository as the storefront — a query with no tenantId throws.
  5. 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):

AreaEndpoints (method)Auth
StoresGET/POST /stores · GET/PATCH/DELETE /stores/:id · POST /stores/:id/agent-tokens · GET …/auditmember / owner
DomainsGET/POST/DELETE /stores/:id/domains · GET /stores/:id/domainmember / owner
CommercePOST /commerce/verify · GET/PUT /stores/:id/commerce · GET/PUT /stores/:id/integrationsmember / owner
ThemesGET/POST /stores/:id/themes · PATCH/DELETE …/:themeId · POST …/:themeId/activate · GET …/versionsmember / owner
Theme bundlePUT/GET …/theme/bundle/draft · POST …/{preview,publish,rollback,reset,adopt}member / owner
Base themesGET /base-themes · GET /base-themes/:id/previewany member
Page builderGET /page-builder/catalog · GET/PUT /stores/:id/page-builder · POST …/publishmember
AssistantPOST /assistantmember (no scope)
Super-adminGET /admin/users · GET/POST /admin/base-theme… · …/edit/{draft,preview,publish,reset}platform-admin
SystemGET /health /ready /openapi.json /me · POST /webhooks/commercepublic / 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.
  • purgeStoreUrls is 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.