Accepted
ADR 0031 — Theme-author local install path
How pnpm theme:dev installs a work-in-progress theme into a running local CMS without weakening the install SSRF guard
Theme authors need an inner loop: change a template, save, and see the rendered page update on the local CMS within a few seconds. The install workflow fetches a package over HTTPS, but its SSRF guard rejects private addresses — correctly, for production — which blocks serving a tarball from a local address. This record settles how a local theme reaches a running wrangler dev Worker without weakening that guard.
The decision
pnpm theme:dev packs the theme, gunzips the artefact to its canonical USTAR bytes, and uploads those raw bytes to a temporary R2 staging key over a dev-only admin endpoint. It then kicks the existing InstallPackage workflow with a source URL the downloader resolves against R2 instead of the network. The verifier remains the single trust boundary — every install path, local or production, flows through the same workflow steps.
The upload endpoint
POST /api/admin/themes/install-upload accepts the canonical, uncompressed tar bytes as the request body — the form the verifier expects byte-for-byte. The route mints a ULID staging id, streams the body to a key under _staging/install-uploads/ with a one-hour expiry stamp, kicks the workflow pointed at that key, and returns 202 with the same { workflow: { instanceId } } body as the regular install route, so the existing status poller is reused unchanged. The payload is capped at 50 MB, rejected with a Content-Length pre-check and a post-read check before any R2 write.
The endpoint is gated four ways. A dev-mode middleware returns 404 unless the Worker both lacks the plugin loader binding and has CUSP_THEME_DEV=1 set — so the route does not even acknowledge its own existence in production. Behind that gate it requires the administrator role, requires the security.allow_unsigned_themes setting to be true (otherwise 403), and enforces the same per-actor install rate limit as the regular install route.
The internal source scheme
The downloader gains an _uploaded:// scheme that mirrors the existing _first_party:// resolver: on a match it reads the staged bytes from R2 and never touches the network, so it sits cleanly before the SSRF guard. The bytes come from a bucket the Worker already controls and were uploaded by an authenticated administrator action, and the scheme is unforgeable from outside the Worker — the regular install route rejects any source URL not beginning with https://. The verifier still runs in full: a staged tarball that fails the canonical-tar parse, the manifest schema, or the signature check (when unsigned themes are not allowed) is rejected with the same error codes as a network install.
Staging cleanup
- Eager — when the workflow source URL uses the _uploaded:// scheme, the workflow's cleanup step deletes the uploaded staging key.
- Lazy — each new upload first sweeps and deletes expired staging keys (capped per request) as a safety net for installs whose workflow never reached the cleanup step. In the steady state of an author saving every few seconds, the staging prefix holds at most one or two keys.
Alternatives considered
- A setting that relaxes the SSRF guard for localhost — rejected. It turns the guard from an absolute black box into a conditional one, and a future fail-open bug would re-open the SSRF window.
- A scheme that names a filesystem path — rejected. The Worker is sandboxed and cannot read the author's filesystem; the bytes must come from somewhere it can read.
- A public tunnel via cloudflared — rejected. It adds a heavy external binary, makes the inner loop depend on an external service, and routes work-in-progress through a public hostname. The chosen design is fully offline.
Consequences
Authors get a working inner loop against the local CMS with no external dependency, while the SSRF guard stays absolute and the verifier stays the single trust boundary. The cost is that the Worker accepts a large binary body on an admin endpoint, bounded by the dev-mode gate, the role gate, the unsigned-themes gate, and the rate limit. Locally this runs against the R2 emulation, so it carries no real-money cost during normal development.
Read the theme packaging record