Theme system
How Cusp installs, sandboxes, activates, and customises themes — all from the admin, no deploy
A Cusp theme is a signed package in R2 at themes/{theme_id}/{version}/. It carries a manifest, a set of TypeScript template modules, design-token defaults, static assets, and a screenshot. Themes install through a workflow, register their rows and blocks in D1, and render at request time inside a sandboxed sub-isolate. Installing and activating are separate steps, and editors customise the active theme by overriding design tokens that resolve at render. None of this needs a code change or a deploy.
Package layout and the manifest
Every key under a versioned prefix is immutable for the life of that version. Re-installing the same theme id and version overwrites identically; changing anything means cutting a new version. The manifest is the only stable surface — every other path is referenced through it, so a theme can reorganise its template layout without breaking the loader.
- manifest.json — the canonical, signed descriptor (id, version, compatibility range, templates map, block contributions, token path, asset prefix, screenshot).
- manifest.sig — the detached Ed25519 signature, as a small JSON object naming the algorithm, key id, payload hash, and signature.
- templates/ — render-adapter modules. The packaging CLI compiles each TypeScript template to a .js sibling before signing, so the package ships the compiled JavaScript the loader reads; install writes it to R2 verbatim.
- tokens.json — design-token defaults grouped under color, font, text, space, radius, layout, and breakpoint.
- assets/ — CSS, fonts, and images, served from a versioned public URL with a one-year immutable cache.
- screenshot.png — a mandatory package asset; a theme without one fails manifest validation. The admin theme picker does not render it (real per-theme previews would go stale on every change); it identifies a theme by name, description, and an initials tile instead.
Block keys are the load-bearing namespace concern. Theme-contributed blocks must be prefixed with the theme id and a slash (for example twenty-twenty-six/hero); core blocks live under core/*; plugin blocks live under plugin/{plugin_id}/*. Install rejects any manifest whose block keys collide with core or with an already-installed theme. The compatibility range (cuspMin, cuspMax) is checked against the version baked into the running Worker, so a theme that targets a future major version fails install with a clear error rather than breaking at render.
Signing: detached Ed25519 over a canonical tarball
The project owns the signing keypair. The public key ships in the Worker; the private key lives only as a deploy-time secret, never in the bundle and never in the repository. Verification uses Web Crypto's native Ed25519 — no extra dependency, near-zero bundle cost.
The signed payload is a SHA-256 hash of a canonicalised, uncompressed USTAR tarball of the package. Canonicalisation is necessary because tar metadata varies between machines: files are listed in lexicographic order, timestamps and ownership are zeroed, mode is always 0644, directory entries and symlinks and hardlinks are excluded, path components are restricted to a safe grammar, and manifest.sig is excluded so the signature does not sign itself. The package is distributed gzipped for size, but the signature is computed over the un-gzipped bytes because gzip output is not deterministic across implementations.
The Worker bundles a key registry keyed by a stable key id, each entry carrying a public key and a validity window. Rotation works because several key ids can be valid at once: a new key gets a new id and a cutover date, themes signed with the old key keep installing until its window closes, and a compromised key is removed by a redeploy. The signature file's key id tells verification which registry entry to use.
Local development opts out of signature verification through a single global setting, security.allow_unsigned_themes, which defaults to false. Theme authors set it to true and install unsigned packages directly while iterating; there is no development keypair to sign with. The admin Health page shows a prominent warning whenever unsigned themes are allowed.
Signing format and key rotation are pinned in ADR 0028.
The install workflow
InstallPackage is the workflow that turns a package URL into an installed theme. Each step is independently retryable and idempotent against the immutable R2 keys it writes. A failure between steps leaves a recoverable state: a partial install is either an orphaned R2 prefix swept by the nightly cleanup, or a row marked failed that the admin can retry.
- Download — fetch the package over HTTPS to a staging key namespaced by the workflow instance id, so parallel installs do not collide and a retry overwrites cleanly. The downloader is SSRF-guarded by a shared private-host rejection module.
- Verify — recompute the canonical hash and check the Ed25519 signature, validate the manifest with a hand-rolled validator against the manifest schema (ADR 0028; no JSON Schema library enters the bundle), check the compatibility range, and reject block-key collisions. The step is read-only against D1 and R2.
- Install to R2 — unpack the staging tarball to the versioned prefix verbatim (the templates were already compiled to JavaScript at pack time), setting the right content type and cache headers per file.
- Register row — write the themes row through the versioned-write helper, and write a theme_installations row tracking the per-version install state.
- Register blocks — insert the manifest's block contributions into block_definitions through the chunked-write repository (batches of fifty), each carrying owner_theme_id and each producing its own version snapshot.
- No purge — deliberately omitted. Install is preparatory; the active theme has not changed, so nothing in the HTML cache needs invalidating. Activation owns the purge.
- Cleanup — delete the staging tarball. A lingering staging key is harmless and swept by the nightly cleanup if this step fails.
Install does not purge; activation does. Decoupling the two means a failed install is a no-op for the public site and never invalidates the entire HTML cache.
Install accepts two sources: a direct HTTPS URL an editor pastes into the admin, and a Worker-internal _first_party:// scheme used only by the seed flow to install the bundled themes. The admin install route rejects _first_party:// — admins install over HTTPS. Because the seed installs the bundled themes on every fresh deploy, the whole install path is exercised end-to-end before any third-party theme ever attempts it.
Bundled bytes and first-party themes
First-party theme source lives under examples/first-party-theme/. A build script packs a directory into a signed canonical tarball, and an embed script walks every theme directory and emits one committed array of bundled themes that the _first_party:// resolver consumes. Adding a first-party theme is: drop a directory, rebundle. The bundled tarball adds a small one-time cost to fresh deploys; once the theme is installed the loader reads from R2.
The sandbox loader
Themes are third-party code, so they execute inside a Dynamic Worker Loader sub-isolate through the shared PLUGIN_LOADER binding (the same loading shape serves plugins). The loader reads a precompiled template .js from R2 and evaluates it as a sandboxed sub-isolate. A theme author writes a plain default-exported render function; the host injects a tiny WorkerEntrypoint wrapper as the entry module, imports the template as a sibling, and invokes its render method over RPC with the structured-cloned (document, context), receiving the HTML string back. No TypeScript compiler ever enters the Worker — compilation happened once at pack time, before signing. The loaded sub-isolate is cached per isolate keyed by theme id, version, and template path, so swapping versions on activation invalidates the cache by construction with no explicit purge.
The sandbox contract is the load-bearing security boundary. It is pinned at the loader factory and must not be loosened without a successor decision.
const cacheKey = `theme:${themeId}:${version}:${templatePath}`;
const sub = await env.PLUGIN_LOADER.get(cacheKey, async () => ({
mainModule: "main.js",
modules: { "main.js": ENTRYPOINT_WRAPPER, "theme.js": await fetchJs() },
env: {}, // no host bindings reach a theme
globalOutbound: null, // outbound fetch from the theme isolate is denied
}));
const html = await sub.getEntrypoint().render(document, context);- No bindings — a theme never receives DB, BUCKET, KV, EMAIL, or any Workflow, Durable Object, or loader binding. A theme with bucket access could read every document; with email it could exfiltrate content; with KV it could read sessions. None of that is needed to render.
- Outbound fetch denied — a malicious theme cannot post the render context to an attacker URL. The image path is caught separately by HTML escaping at the renderer boundary.
- Pure function — a theme is a function from (document, context) to HTML. The host performs every read and constructs the plain, serialisable context, filtering out anything sensitive such as session tokens.
- Cache key includes the template path — a single theme version exposes several templates (document, home, list), so the key carries all three of theme id, version, and template path; without the path two templates from one version would collide.
The sandbox properties and the loader contract are pinned in ADR 0028.
The render adapter and the bundled fallback
The public render adapter exposes renderDocument, renderHome, and renderList; all three are async and take the environment as their first argument. Each reads the active-theme pointer, re-validates the active theme's signing key against the current registry, then loads the template through the sandbox loader.
Re-validating the key on render closes a real gap: install-time verification cannot catch a key that is rotated out or revoked after install. On the first render in each isolate the adapter re-checks the active theme's key id against the production registry, cached per theme-and-version for the isolate's lifetime. A revocation lands as a redeploy, which spins up fresh isolates with empty caches.
Any failure — no active theme, a revoked key, an R2 miss, a sub-isolate boot error, or a render-time throw — falls back to the bundled default templates rather than serving an error page. This is the editor-first guarantee: a fresh deploy that has not yet been seeded still renders, just with the bundled chrome. The fallback is preferred over a placeholder because a placeholder would become the homepage on an un-seeded deploy.
The fallback is observable rather than silent (ADR 0061). When a theme was active but could not render — a revoked signing key, missing template bytes, or a render-time throw — the render path warns and records a self-healing KV flag at render:theme-fallback holding the theme id, version, reason, and timestamp. The write is guarded by a per-isolate flag, a key-absent check, and a one-hour TTL, so a broken theme adds no per-request KV overhead and the flag clears on its own once the theme renders again. The admin Health page shows an "Active theme is not rendering" card when the flag is set, and the Themes page flags the offending row. The local-development case, where the Dynamic Worker Loader binding is unbound and every theme falls back, is excluded from the flag because the Themes page already surfaces it via the themedRenderAvailable banner.
Activation
The active theme is a setting. The pointer is one JSON key, site.active_theme, holding a theme id and version — one atomic upsert. The reader falls back to the legacy (site.theme, site.theme_version) pair so older deploys still resolve. Activation goes through POST /api/admin/themes/:id/activate. The bundled default — the built-in theme named Aurora — needs no pointer: it renders whenever site.active_theme is unset, so the admin lists it as a permanent, selectable row, and reverting to it clears the pointer via DELETE /api/admin/themes/active. Both activating and reverting drain the R2 prerenders and bump the cache epoch so the public site re-skins immediately.
- Verify the requested theme and version exist in theme_installations with install_state ready, returning 404 if not installed or 409 if not ready.
- Write the active-theme pointer through the versioned-write helper, attributed to the acting user, so time-travel inherits theme history for free.
- Drain the rendered/ prefix from R2, purge the theme surrogate key, and bump the global cache epoch — so every page re-skins immediately, on every colo, rather than re-reading the old theme's prerendered HTML. This drain-purge-bump is logged-but-swallowed on failure: the settings write is canonical state and the cache is derivative.
Rollback is just another settings save: write the previous theme and version again and purge again. Activation is rare enough that, unlike the install kickoff route, it is not separately rate-limited. The local-dev install path is pinned in ADR 0031.
Design-token overlay and render-time resolution
Editors customise the active theme by overriding design tokens. Overrides are stored as typed settings under two key families, resolved at render, and emitted as CSS custom properties — without ever regenerating or re-uploading the theme's static stylesheet.
- theme.tokens.core.{path} — applied regardless of which theme is active; these survive a theme switch (brand colour, type scale, spacing rhythm).
- theme.tokens.{themeId}.{path} — scoped to one theme; preserved across switches but only applied when that theme is active.
For each token path the resolver takes the first non-null value in a fixed order: a theme-specific override, then a core override, then the theme's tokens.json default. The render path emits a small style block in the head, before the theme's stylesheet link, declaring the resolved values as --cusp-{path} custom properties (dots become hyphens). A theme stylesheet that consumes var(--cusp-*) automatically picks up overrides.
// color.accent -> --cusp-color-accent
// text.h1.size -> --cusp-text-h1-size
// font.body.stack -> --cusp-font-body-stackAn override changes only the per-request style block, so the static stylesheet keeps its one-year immutable cache and is never invalidated. A write to any theme.tokens key purges the same theme surrogate key activation uses, so the next render produces a style block with the new values. The token overlay and render-time resolution are pinned in ADR 0032.
No hosted marketplace
Cusp ships without a hosted theme catalogue. Themes install from any HTTPS URL or from the bundled first-party set. The trust model is the same as the rest of the product: the admin is a trusted adult who reviews and installs what they choose, the signing registry is the trust anchor, and the community signing keys ship bundled in the Worker's key registry rather than being registered by the deployer or fetched from any central service. The package format and installation flow are pinned in ADR 0028.
See how async work runs