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.
| Block | What it is |
|---|---|
store | Store identity (id, slug, status). |
brand | Brand identity — name and media refs (logoRef / faviconRef / ogImageRef, see Media). |
theme | Design tokens (color/type/layout) the renderer maps onto CSS custom properties. |
template | { id, sections[] } — the home-page section list, in render order. |
seo | Open SEO block. |
behavior | Feature 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}/siteThe 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
draftorsuspended, or an unknown tenant, is an honest404 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 withIf-None-Match: <etag>answers304 Not Modifiedwhen 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.
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /v1/my/site | read:own_store | The site overview — active manifest, latest draft, and full version history. |
PUT | /v1/my/site/draft | write:own_store | Save a draft (a new version row; the active pointer is untouched). |
POST | /v1/my/site/publish | write:own_store | Move the active pointer to an existing version (publish, or roll back to an older one). |
POST | /v1/my/site/preview-token | read:own_store | Mint 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) writes — PUT /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 publish — PUT /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:
| Event | Payload | Fires 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.