Plugin system
How Cusp runs admin-installed plugin code in sandboxed sub-isolates that receive only the bindings their manifest declares
A plugin extends Cusp with new blocks, field types, admin pages, and handlers that run on lifecycle events. Plugins are admin-installed and admin-reviewed, but they run as code, so the host treats them as untrusted: each plugin runs in its own sandboxed sub-isolate and receives only the bindings its manifest declares. The host is the one policy enforcement point between the request lifecycle and plugin code.
Core capabilities are not plugins
Images and search are core features, not plugins. The media library with Cloudflare Image Transformations, full-text search, the ExportBackup workflow, and transactional email through the EMAIL binding all work on a fresh deploy with zero installs. A fresh Cusp can put a picture in a post, take a backup, run a search, and send a password-reset email before any plugin exists.
The plugin system exists to extend Cusp with genuine third-party code. The project ships two example plugins as reference implementations: cusp-webhook (POSTs lifecycle events to a configured URL with an HMAC-SHA256 signature) and cloudflare-web-analytics. Community and self-built packages install by signed URL through the same InstallPackage workflow. There is no plugin marketplace: the deployer reviews and installs packages directly, the same trust model as a self-hosted WordPress.
Sandboxing via Dynamic Worker Loader
Plugins load through the PLUGIN_LOADER Dynamic Worker Loader binding — the same mechanism themes use, but themes receive zero bindings (a theme is a pure function of document and context to HTML). A plugin sub-isolate is constructed with a bindings record built from the manifest and a globalOutbound fetcher that enforces the network allowlist. With no network capability declared, globalOutbound is null, which denies all outbound fetch.
The loader configuration is recomputed statelessly on every cache miss, and the cache key includes the plugin version, so activating, deactivating, or reconfiguring a plugin invalidates the per-isolate cache. A plugin can never reach the raw DB, BUCKET, KV, or EMAIL bindings; it only ever sees host-supplied scoped wrappers.
Manifest and capability model
The manifest is the negotiation surface for what the host hands the sub-isolate. The wire shape is structured: requestedBindings groups the storage and network requests, subscribesTo lists hook subscriptions with priorities, and contributesBlocks, contributesFieldTypes, and adminPages declare contributions. The install workflow validates the manifest with a hand-rolled validator against the manifest schema fixed in ADR 0036 — not a JSON Schema library, which would cost bundle budget — and rejects it on any structural drift before the plugin is registered. The plugins and plugin_config rows are versioned entities, so capability changes go through versionedWrite() and time travel covers a plugin's capability history.
Capabilities map to bindings at load time as follows:
- kv.read / kv.write — a per-plugin KV namespace prefix, never the global KV.
- d1.read / d1.write — prepared statements against a per-plugin D1 facade; core tables are unreachable.
- r2.read / r2.write — a per-plugin R2 prefix only.
- email.send — the host EMAIL binding with the from address fixed to the deployment's verified sending domain; the plugin cannot forge from.
- http.fetch:{hostname} — outbound fetch to an enumerated hostname (wildcard subdomains allowed, http.fetch:* forbidden, HTTPS only).
- content / media / setting reads and writes — a designed capability, not one of the five requestedBindings above and not shipped in v1: the host would inject scoped content, media, and settings objects through the hook payload rather than as raw bindings. Because it rides on the hook payload, it lands with the plugin runtime (see the note at the top of this page), not on a deploy today — a plugin currently reaches the CMS only through the five bindings above.
Webhook-style plugins that fetch a user-configured hostname declare user-config:{settingKey} inside requestedBindings.httpFetch.hostnames. The egress shim resolves that key from typed settings (plugin.{pluginId}.config.{key}) and runs the SSRF guard against the actual request hostname on every fetch — load-time resolution would go stale the moment the operator changed config, so the per-fetch check is the only correct enforcement point.
Install and trust
Plugins are installed and reviewed by an administrator. Cusp's v1 trust model treats the administrator as the reviewer — there is no hosted marketplace, so the package is fetched from a URL the administrator chose and verified at install. The enforcement boundary is the manifest: the install workflow snapshots the declared capabilities, and the host grants only those capabilities as bindings, so a plugin can never reach a binding it did not declare. The install dialog today posts the plugin reference (id, version, source URL) and relies on that binding-layer enforcement plus administrator review. A consent step that surfaces the declared capabilities in plain English in the install dialog, and an upgrade re-prompt that shows newly-requested capabilities, are planned for a later release.
Storage scoping
There is one physical KV namespace, one D1 database, and one R2 bucket. Isolation is enforced by host-supplied wrappers that pin a per-plugin prefix at every call site, so the plugin still sees the platform's normal binding shape but cannot read or write outside its own subtree. A KV list with no prefix is force-scoped; a D1 query that references a table outside the plugin's namespace is rejected by the query rewriter; an R2 list is prefixed and the returned keys are stripped back.
Plugin config is versioned; plugin operational data (the rows in the plugin's own tables, its KV keys, its R2 objects) is not in the canonical versioned-entity list. Time travel does not roll plugin data back, but backups include it. The host also owns three coordinating tables that are not versioned entities: plugin_installations (install state and idempotency for InstallPackage retries), plugin_subscriptions (the indexed event-to-handler rows the dispatcher loads), and plugin_invocations (a per-call log backing the admin Health-page metrics, with short retention).
Hooks
Core fires real lifecycle events that plugins subscribe to: document.published and document.unpublished fire from the publish and unpublish workflows, and media.uploaded fires from the media-upload path. A plugin declares its subscriptions in the manifest with a priority; every hook a plugin handles must be declared, and the host rejects an undeclared registration at load time.
The dispatcher loads every subscriber for an event, ordered by ascending priority then plugin id, and invokes each one in turn. Each handler runs inside its own try/catch and races a per-call timeout, so a throw, a timeout, or a missing handler is logged to plugin_invocations and the dispatcher moves on — the host degrades gracefully and the calling request never sees a 500. Responses merge according to the event's merge mode (notification, first-non-null, HTML, or list aggregation). Hook payloads are JSON-serialisable snapshots.
This dispatch path is the experimental runtime flagged at the top of the page: the events fire from core (the publish, unpublish, and media-upload paths), the dispatcher and merge logic are built and tested, but plugin handlers do not yet execute on a deployed site. That is the piece landing in a v1.x release.
Block renderers
A plugin can contribute block types. Plugin block renderers run server-side in their own Dynamic Worker Loader sub-isolate, and the HTML they emit is passed through the host sanitiser, which strips script tags, event handlers, nested iframes, and other client-side script vehicles. A plugin never injects client-side script onto the public site: the sanitiser is the boundary, and the renderer never hands a plugin a browser context.
Like hooks, plugin-contributed block rendering is part of the experimental runtime and does not execute on a deployed site yet — a plugin installs and declares its blocks today, but the public site renders them once the sandboxed execution path ships in v1.x.
Read the plugin author guideRead the versioning guide