ReferenceSite builder

Site builder

A store’s storefront is data, not a fork (ADR 0003: “customization is data”; ADR 0014: the manifest is part of the developer contract). Everything a visitor sees — brand, theme tokens, and the ordered list of page sections — is one JSON document, the manifest, owned by the stores module and versioned in an append-only table. The renderer (hosted or headless) reads that document and paints the store; it never needs store-specific code.

This page describes the manifest shape, the public read every consumer resolves against, and the self-serve draft/publish/rollback lifecycle a merchant drives with an fdk_ credential.

The manifest

The manifest is merchant-authored and deliberately lenient: the renderer treats every block as pass-through, so the schema is structured-but-open — the top-level blocks a renderer relies on are named, and additional properties are allowed everywhere.

BlockWhat it is
storeStore identity (id, slug, status).
brandBrand identity — name and media refs (logoRef / faviconRef / ogImageRef, see Media).
themeDesign tokens (color/type/layout) the renderer maps onto CSS custom properties.
template{ id, sections[] } — the home-page section list, in render order.
seoOpen SEO block.
behaviorFeature toggles (search, accounts, consent surfaces).

Each entry in template.sections is a registry section type plus an open props bag whose shape varies per type:

{
  "store": { "slug": "nova-peptide", "status": "live" },
  "brand": { "name": "Nova Peptide" },
  "theme": { "tokens": { "color.brand": "#0b6" } },
  "template": {
    "id": "base-v1",
    "sections": [
      { "type": "announcement-bar", "props": { "text": "Free shipping over $150" } },
      { "type": "header-nav", "props": {} },
      { "type": "epic-hero", "props": { "heading": "Research-grade peptides" } },
      { "type": "product-grid", "props": { "collection": "peptides" } },
      { "type": "legal-footer", "props": {} }
    ]
  }
}

Additional top-level keys and additional section props are preserved verbatim — the contract names the blocks a renderer depends on without freezing the rest.

Reading the live manifest — public

GET /v1/tenants/{tenant}/site

The one public, unauthenticated read of a store’s site. It returns the active manifest version for a live store and nothing else:

{
  "tenant_ref": "nova-peptide",
  "version": 3,
  "manifest": { "brand": { "name": "Nova Peptide" }, "theme": { "tokens": {} }, "template": { "sections": [] } }
}

Behavior, all fail-closed:

  • Live-only. A store that is draft or suspended, or an unknown tenant, is an honest 404 unknown_tenant — never an empty manifest, and no oracle that distinguishes “exists but hidden” from “doesn’t exist”. No draft or suspended content ever leaks here.
  • A failed read is a 503, never absence (resolve_unavailable).
  • Conditional GET. The response carries an ETag (the manifest row hash). A follow-up request with If-None-Match: <etag> answers 304 Not Modified when the active version is unchanged — an unchanged release costs no re-parse.

This is the route the SDK’s store.site.get() wraps, and the same data the hosted renderer resolves; a headless frontend and the platform’s own storefront bind to the identical contract.

The lifecycle — self-serve

The authoring lifecycle runs under /v1/my/*, authenticated by the caller’s fdk_ tenant credential (the tenant is resolved from the credential per credential-carries-ownership — never a path or body param). A draft is simply a version row the active pointer does not yet reference; publishing moves the pointer; publishing an older version is rollback. The version table has been append-only since the module’s first migration — no version is ever mutated or deleted.

These routes reuse the same store scopes the manifest read/write already gate on — no new scope was added.

MethodPathScopePurpose
GET/v1/my/siteread:own_storeThe site overview — active manifest, latest draft, and full version history.
PUT/v1/my/site/draftwrite:own_storeSave a draft (a new version row; the active pointer is untouched).
POST/v1/my/site/publishwrite:own_storeMove the active pointer to an existing version (publish, or roll back to an older one).
POST/v1/my/site/preview-tokenread:own_storeMint a short-lived signed token to preview one version.
GET/v1/preview-resolve(token)Public: resolve a manifest version by preview token (no auth; the token is the capability).

Site overview

GET /v1/my/site is one honest read — any leg failing fails the whole response, never a partial presented as complete. draft is the newest version when it is not the active one, and null when the newest version is already live:

{
  "active": { "version": 1, "manifest": { "brand": { "name": "Acme v1" } }, "rowHash": "…" },
  "draft":  { "version": 2, "manifest": { "brand": { "name": "Acme v2 draft" } }, "rowHash": "…", "createdAt": "2026-09-06T12:00:00.000Z" },
  "versions": [
    { "version": 2, "rowHash": "…", "createdAt": "…", "active": false, "brandName": "Acme v2 draft" },
    { "version": 1, "rowHash": "…", "createdAt": "…", "active": true,  "brandName": "Acme v1" }
  ]
}

Save a draft

PUT /v1/my/site/draft with { "manifest": { … } } writes a new version row without touching what visitors see, and returns the version it minted:

{ "version": 2, "rowHash": "…" }

A saved draft never moves the active pointer — GET /v1/tenants/{tenant}/site keeps serving the previously published manifest byte-for-byte until you publish.

Publish and roll back

POST /v1/my/site/publish with { "version": 2 } moves the active pointer to that version. Because any existing version can be published, rollback is the same route — re-publishing { "version": 1 } restores the earlier manifest:

{ "version": 2, "rowHash": "…" }

An unknown version is a 404 version_not_found. Publishing does not re-scan the manifest: every version row already entered through a scanned write path (see below), and re-scanning would let a later scanner-rule change strand a version a merchant can no longer re-activate.

Preview a version

POST /v1/my/site/preview-token with { "version": 1 } mints a signed, short-lived token for one existing version (a 404 if the version does not exist). Nothing is written to the database — the expiry rides in the signed payload, and the token grants a render of exactly one immutable manifest row:

{ "token": "fdpv_…", "version": 1, "expiresAt": "2026-09-06T12:30:00.000Z" }

Preview is a read capability, so a read:own_store-only key can mint one. The token has a 30-minute TTL.

GET /v1/preview-resolve?token=<token> is the public, draft-mode counterpart to the live read — keyed by the token instead of tenant. It returns the tokened version with Cache-Control: no-store (a preview must never enter a cache that could pollute live rendering), and no ETag:

{
  "tenantRef": "acme-labs",
  "storeNumber": 1,
  "status": "live",
  "manifestVersion": 1,
  "manifest": { "brand": { "name": "Acme v1" } },
  "preview": { "version": 1, "expiresAt": "2026-09-06T12:30:00.000Z" }
}

Expired, tampered, malformed, and unknown-version tokens all resolve to the same honest 404 invalid_preview_token — one code, no oracle. The claims are signed, so tampering (including swapping the tenant to peek at another store) fails the signature: there is no cross-tenant preview via the API. A database failure is a 503, never absence.

The RUO claim-scan gate

Every write that can create a published-able version is scanned by @dscodotco/guardrails before the row is written. The scanner walks the whole manifest object — every string, anywhere in the tree — for prohibited RUO claim language, and is fail-closed: a scanner exception is a 503 (“scan did not run”), never treated as a pass.

Self-serve (merchant) writesPUT /v1/my/site/draft and PUT /v1/my/store/manifest: a CRITICAL finding is a hard 422 and no version row is written. The merchant path has no override — the response tells the merchant to contact the platform operator to review:

{
  "error": {
    "code": "ruo_claims_violation",
    "message": "manifest copy contains 1 CRITICAL RUO claim finding(s); publish blocked — contact the platform operator to review"
  },
  "findings": [
    { "path": "hero.heading", "term": "cures cancer", "rule": "…", "severity": "critical", "context": "Cures cancer fast" }
  ]
}

Operator publishPUT /v1/stores/{tenant}/manifest (operator-token gated): the same scan runs, but a blocked publish can be forced with a visible, audited escape hatch. Supplying x-guardrails-override: <reason> re-runs the publish; the header value is the operator’s written reason and is recorded verbatim in the audit trail (event manifest.published.guardrails_override, with the critical findings). Without it, a CRITICAL finding is the same 422 ruo_claims_violation. Non-critical findings never block — they ride back as ruoWarnings on a successful publish.

Operators can also dry-run the gate without writing anything: POST /v1/stores/{tenant}/manifest/scan returns { "passed": …, "findings": [ … ] } so the portal/console can preflight a manifest while a merchant is still editing it.

Provisioning event

A store is first created by an operator (POST /v1/stores with tenant_ref + display_name). That is the first real publisher of store.provisioned.v1:

EventPayloadFires when
store.provisioned.v1{ store_ref, display_name }A new store row is created.

The envelope carries the required tenant_ref, and the payload is self-contained (a consumer never calls back into stores to use it) — the platform convention for every event. Downstream onboarding steps (provisioning) react to it to stand up the rest of a tenant’s surfaces.