SpectreSkills
← All skills

cms-publish-and-seam

v1.0.0

Publish a CMS theme as an append-only snapshot and serve the public site over a bearer-gated read seam the renderer caches per HOST — the site never touches the database, and a shared cache key is a cross-tenant leak. Trigger when adding publish, rollback, ISR, preview tokens, or a public renderer to a page builder.

CMS & Page Builderscmspublishisrseam
Install
npx @spectre-apps/skills add cms-publish-and-seam

Writes .claude/skills/cms-publish-and-seam/SKILL.md. Add --user to install globally.

What it does

Publish, and the read seam

The single most important structural fact of a multi-tenant page builder: the public site never touches the database. It fetches the live version's frozen bundle, its pre-compiled CSS, the workspace's pages, and its imported sections over a bearer-gated HTTP endpoint, cached with ISR.

That seam is what makes the published site independently deployable, edge-friendly, and later movable to its own domains. It is also the surface that has to be right, because one deployment serves every tenant.

The cache key carries the host

Under multi-tenancy a shared cache key is not a stale-content bug. It is one customer's site served on another customer's domain.

The host is the only key available at fetch time — the workspace id arrives in the response the call is about to make — so the tag is cms-active-theme:<host> and the publish path expires one tag per host the workspace answers on. The renderer's cache key and revalidation tag both carry the host. A shared key is a security incident.

Publishing is one transaction

Snapshot a version and move the live pointer in one transaction. theme_versions is append-only. The live pointer uses FK RESTRICT so the live theme cannot be deleted out from under the site.

The snapshot holds the bundle and the compiled CSS, plus the imported section definitions in force that day. It does not hold page rows. See cms-pages-and-templates: a rollback restores a design and leaves a month of copy edits alone.

There is one writer of versions and of the live pointer. Everything else calls it. A second writer is how a route-level gate gets bypassed.

Publishing the live theme is saving it. A save on the live theme carries through to a publish, because the public site renders the live theme's draft. The entitlement gate therefore lives on publishTheme, not on the publish route — there is a third caller, and it is the one that matters. A route-level gate leaves "save the live theme" as an unmetered way to change the public site. See cms-tenancy.

Stale-while-revalidate is not a lost publish

After the transaction, best-effort POST the site's /api/revalidate. The first request after a publish can still serve the previous version — that is stale-while-revalidate, not a lost publish. The next request is fresh. A 60s TTL is the backstop if the revalidate ping never lands. Do not "fix" this by blocking the publish response on the ping; the ping failing would then fail a write that already committed.

The seam authenticates the renderer, not a tenant

One site app serves everyone, so it holds one platform credential and names the host. The pair matters:

  • the bearer alone would read any workspace
  • the host alone would let anyone read one

/api/cms/* and /api/site/* are exempt from session auth so bearer callers get through — and every handler runs its own guard. Nothing else gets exempted. The seam must not call auth() / read a session: it is not in the session middleware, and routing a call through a session-based resolver turns an unknown host into a 500 instead of a 404.

Resolution on this path never creates a workspace. Self-healing from a missing slug is right for an empty dev database and catastrophic the moment the input is a host header a stranger controls. An unverified custom domain resolves to nothing — that is what stops someone adding a domain they do not control and serving their content from it.

Media stores URLs, not row ids

Deleting a library row must not break a theme that already used the file. Settings embed the resolved URL. The object store owns the bytes; the CMS row is the catalog entry. A publish freezes those URLs into the version, which is why a later delete is safe.

What the seam returns, in one response

Enough for the renderer to paint without a second hop:

  • the frozen bundle
  • the pre-compiled CSS (tokens already resolved)
  • the workspace's pages (live copy)
  • the imported section definitions (the snapshot, not today's drafts)

The renderer keys the cache by host, falls back to a compiled-in default theme when nothing is published, and never opens a database connection. If you are about to add a column the renderer needs, add it to this payload — do not let the site grow a Prisma client "just for this one thing".