Accepted

ADR 0028 — Theme package format, signing, and install

How a theme is packaged, signed, verified, and installed into a Cusp deployment

A theme is an installable package, not a code change. It lives in R2 under a versioned key prefix, arrives through the InstallPackage workflow, and renders from R2 at request time. No theme code enters the Worker bundle, and installing a theme never requires a redeploy. This record pins the package layout, the manifest, the signing scheme, the loader sandbox, the install steps, and the install-versus-activate boundary.

Package layout

Every theme version is a flat directory tree under r2://main/themes/{theme_id}/{version}/. Each key under the prefix is immutable for the lifetime of that version: re-installing the same theme and version overwrites identically, and changing content means cutting a new version. The directory holds:

  • manifest.json — the canonical, signed descriptor. The only stable surface; every other path is referenced through it.
  • manifest.sig — the detached Ed25519 signature.
  • templates/ — render-adapter modules as plain TypeScript (page.ts and listing.ts are required; home, sitemap, feed, and per-block renderers are optional), compiled to JavaScript at install time.
  • tokens.json — design-token defaults (see the design-token overlay record).
  • assets/ — CSS, fonts, and images served immutably from /_themes/{theme_id}/{version}/assets/{path}.
  • previews/ — optional low-fidelity admin previews.
  • screenshot.png — a 1200x800 PNG, at most 500 kB, carried in the signed package.

Manifest

The manifest is JSON-Schema validated at install. It carries the theme id (lowercase, hyphenated, must not collide with core/*), a semver version, a cuspMin/cuspMax compatibility range checked against the running Worker version, display metadata, a templates path map, and a blocks array. Theme-contributed block keys are namespaced under the theme id (for example twenty-twenty-six/hero); core blocks live under core/* and plugin blocks under plugin/{plugin_id}/*. Install rejects a manifest whose block keys collide with core or with an already-installed theme.

Signing — detached Ed25519, project-owned keys

A theme is signed by computing a SHA-256 hash over the canonicalised, uncompressed USTAR tarball of the package, then signing that hash with Ed25519. Canonicalisation removes the variance that would otherwise break verification: files are listed lexicographically, directory entries are dropped, mtime/uid/gid are zeroed, mode is always 0644, and symlinks, hardlinks, device files, and PAX extensions are rejected. The tarball is distributed gzipped for size, but verification recomputes from the uncompressed bytes because gzip output is not deterministic across implementations.

Verification uses the Web Crypto Ed25519 primitive — no third-party crypto dependency. The signature file names a keyId; the verifier looks that keyId up in a key registry bundled into the Worker and rejects an unknown key (UNKNOWN_KEY_ID), an expired key (KEY_EXPIRED), or a malformed signature (MALFORMED_SIGNATURE). The verifier always recomputes the payload hash from the bytes it extracted — it never trusts a hash carried in the signature file.

The bundled key registry

The registry is a list of trusted public keys, each tagged with a purpose so one verifier path can serve themes and plugins without cross-contamination. A signature whose key purpose does not match the artefact being verified is rejected with WRONG_PURPOSE — a plugin key cannot vouch for a theme, and vice versa. The bundled keys are:

  • cusp-project-2026 (purpose: theme) — signs the bundled first-party themes. The private key lives only in .dev.vars locally and as a Wrangler secret in CI.
  • cusp-plugin-2026 (purpose: plugin) — signs the bundled example plugins.

Key rotation works because the registry is a list: a new key gets a new keyId with an overlapping validity window, themes signed with the old key keep installing until its validUntil passes, and a compromised key is removed by redeploy. The render adapter also re-checks the active theme's key on first render per isolate, so a key rotated out after install falls back to the bundled default theme rather than serving an unverifiable one.

Local-development opt-out

The single, deployment-wide opt-out from signature verification is the security.allow_unsigned_themes setting, default false. Local theme authors set it to true and install unsigned packages directly; the admin Health page shows a red banner whenever it is on. A bundled well-known dev keypair was rejected as an alternative because it would be a deployment-wide skeleton key.

Loader sandbox — themes run with no bindings

Themes load from R2 at runtime through the PLUGIN_LOADER Dynamic Worker Loader binding (shared between themes and plugins), which evaluates the precompiled JavaScript in a sandboxed sub-isolate cached per (theme_id, version, template path). The sub-isolate receives no host bindings — no DB, BUCKET, KV, or EMAIL — and outbound network egress is denied. A theme is a pure function from (document, context) to HTML; every datum it needs is supplied through the host-constructed, filtered context. This is the load-bearing security boundary of the theme system.

Precompiled JavaScript, not TypeScript, runs at render time: shipping a TypeScript compiler in the Worker would blow the bundle budget, so the install workflow compiles each template once and stores the JavaScript alongside the source in R2.

The InstallPackage workflow

Install runs as a multi-step workflow, each step independently retryable and idempotent against the immutable R2 keys it writes:

  1. download — fetch the package to a staging R2 key namespaced by the workflow instance id.
  2. verify — recompute and check the signature, validate the manifest schema, check the version range, and reject block-key collisions. Read-only; fail-fast.
  3. install to R2 — unpack to the versioned prefix, compiling each template to JavaScript and setting per-file Content-Type and Cache-Control.
  4. register the themes row through the versioned-write helper, and write an operational theme_installations row tracking install_state.
  5. register theme-supplied block_definitions through the chunked-write repository (batches of 50), each row versioned.
  6. cleanup — delete the staging tarball.

Activation is a separate, settings-driven flow

Installing a theme does not switch the site to it. Activation is POST /api/admin/themes/:id/activate with the version in the request body. It verifies the version is installed and ready, writes site.theme and site.theme_version through the versioned-write helper, and purges the theme surrogate key so cached pages re-render against the new theme. Because the active theme and version are versioned settings, site-wide time travel inherits theme history for free — reconstructing past state deterministically resolves the theme version active at that timestamp.

Consequences

  • Themes are hot-installable with no redeploy, and the public render path stays well under the Worker bundle budget because no theme code is bundled.
  • The bundled first-party themes install through this same workflow on every fresh deploy, so the install path is exercised end-to-end before any third-party theme is tried.
  • The signing keypair lifecycle is a project operations concern — generation, rotation, and secret storage — which is the accepted cost of the no-external-services posture. Sigstore and cosign were considered and rejected for that reason.
  • Block keys are globally unique across installed themes; a clear install-time collision error is preferred over render-time disambiguation.

Read the theme local-dev record