Authoring a plugin
Extend Cusp with lifecycle hooks, blocks, admin pages, and integrations inside a capability-scoped sandbox
A Cusp plugin extends the CMS with lifecycle hooks, custom blocks, admin pages, and third-party integrations. Every plugin runs inside its own Dynamic Worker Loader sub-isolate and receives only the bindings its manifest declares. The host is the single enforcement point: a plugin never reaches the global database, bucket, KV namespace, or email binding directly.
Plugins are admin-installed and admin-reviewed. There is no marketplace and no catalogue. The admin Plugins screen offers two install sources: Browse bundled, which installs a first-party example through the internal _first_party:// resolver, and paste-a-URL, which downloads a signed package from any HTTPS URL the deployer chose. Administrator review is the trust boundary, and the manifest is both a declaration and a contract: the host grants only the capabilities the manifest declares as bindings, so a plugin cannot reach a binding it did not declare. A consent prompt that surfaces those declared capabilities in the install dialog is planned for a later release.
Quickstart: the webhook plugin
Cusp ships two bundled example plugins that bracket the range of what a plugin can be. The cusp-webhook plugin walked through here exercises the callable surface — lifecycle hooks, scoped network egress, and signed delivery. The other, Cloudflare Web Analytics under examples/first-party-plugin/cloudflare-web-analytics, is the config-only counterexample: it ships no callable surface at all. It surfaces a beacon token as a versioned setting and supplies the configuration UI, and themes opt in by reading that setting and emitting the beacon snippet themselves — the minimal shape a plugin takes when all it needs is a piece of deployer configuration.
The smallest plugin with a callable surface is cusp-webhook, under examples/first-party-plugin/cusp-webhook (its hook handlers land on a deploy in a v1.x release, per the note above). It subscribes to three lifecycle hooks and POSTs a signed JSON envelope to a deployer-configured URL. A plugin's default export is a factory: the host calls it once with the plugin's scoped env and you return an object of hook handlers.
export interface PluginEnv {
// Declared via requestedBindings.httpFetch — the host injects a fetch
// shim pre-configured with this plugin's hostname allowlist and SSRF guard.
httpFetch: typeof fetch;
}
export interface WebhookConfig {
webhookUrl: string;
secret: string;
}
export default function plugin(env: PluginEnv) {
return {
// document.published resolves to the handler name publishedDocument.
// The host passes (payload, config): payload is the event snapshot,
// config is this plugin's resolved settings.
async publishedDocument(payload, config: WebhookConfig) {
const envelope = JSON.stringify({ event: "document.published", data: payload });
const signature = await hmacSha256Base64Url(config.secret, envelope);
const response = await env.httpFetch(config.webhookUrl, {
method: "POST",
headers: {
"content-type": "application/json",
"cusp-webhook-signature": signature,
"cusp-webhook-event": "document.published",
},
body: envelope,
});
if (!response.ok) throw new Error(`delivery failed: ${response.status}`);
return { ok: true, status: response.status };
},
};
}Two facts to internalise from this example. First, the second argument to every handler is the resolved config object — the plugin's own settings, read from typed-settings keys plugin.{pluginId}.config.* before the handler runs. The host provides no shared content or settings API; a plugin reaches the CMS only through the bindings its manifest declared. Second, env.httpFetch is the only network access this plugin has, because httpFetch is the only binding its manifest requested.
The manifest and the capability model
Every plugin ships a manifest with kind set to "plugin", an integrity block listing each shipped file's SHA-256, and a structured requestedBindings object. The capability set you declare is exactly what the host grants — nothing more. A plugin that does not declare email cannot send mail; a plugin that does not list a hostname cannot fetch it. The integrity block below is shown for completeness: the pack tooling computes and injects it at pack time, so your source manifest must not declare one.
{
"kind": "plugin",
"id": "cusp-webhook",
"version": "1.0.0",
"name": "Cusp webhook",
"description": "POSTs lifecycle events to a deployer-configured webhook URL with an HMAC-SHA256 signature header.",
"author": { "name": "Cusp project" },
"license": "MIT",
"cuspMin": "0.6.0",
"cuspMax": "1.x",
"requestedBindings": {
"httpFetch": { "hostnames": ["user-config:webhookUrl"] },
"kv": false
},
"subscribesTo": [
{ "hookName": "document.published", "priority": 100 },
{ "hookName": "document.unpublished", "priority": 100 },
{ "hookName": "media.uploaded", "priority": 100 }
],
"contributesBlocks": [],
"contributesFieldTypes": [],
"adminPages": [],
"integrity": {
"files": { "main.js": "sha256:..." }
}
}requestedBindings has exactly five keys. Each declares a capability the host grants only if you list it:
- kv (boolean) — grants the plugin its own scoped KV namespace. Every key is prefixed with plugin/{pluginId}/ transparently; a list() with no prefix returns only the plugin's own keys.
- r2 (boolean) — grants a scoped R2 prefix under plugins/{pluginId}/data/. list() is forced-prefix; the plugin cannot reach its own bundle area or any other prefix.
- d1 ({ tables: string[] }) — grants scoped D1 access to the named tables, each of which must be named plugin_{pluginId}_* (hyphens in the id become underscores). A hand-rolled SQL parser rejects any statement referencing a table outside the prefix, plus CTEs, sub-selects, PRAGMA, ATTACH, DDL, upserts, and multi-statement strings.
- email ({ from: string }) — lets the plugin send through the host email binding. The from address is the one declared here, subject to Cloudflare's verified-domain enforcement at the platform layer; the plugin cannot forge it at runtime.
- httpFetch ({ hostnames: string[] }) — enumerates every hostname the plugin may reach over HTTPS. A blanket wildcard is rejected at validation; each host is listed, or declared per-config with the user-config: form below.
Alongside requestedBindings, the manifest declares the surfaces the plugin contributes: subscribesTo lists hook subscriptions, contributesBlocks and contributesFieldTypes declare content surfaces (each key or type namespaced plugin/{pluginId}/*), and adminPages contributes React pages. Each adminPages entry is { path, label } — path is a slash-separated slug, not a slug field. adminPages is declared and validated today but nothing renders plugin admin pages yet: like the hook runtime, admin-page rendering is deferred to v1.x (ADR 0059).
The manifest is validated at install with a default-deny, error-collecting validator. A hook the vocabulary does not contain, a block key outside the plugin/{pluginId}/* namespace, or a wildcard-or-malformed hostname fails the install with a typed error. Two things do not fail at install: an unknown key inside requestedBindings is silently ignored (the validator only reads the five known keys), and a private, loopback, or link-local hostname passes install grammar and is instead rejected at runtime by the per-fetch SSRF guard. The host re-validates the manifest on upgrade and grants only the upgraded version's declared capabilities. An upgrade re-prompt that surfaces newly-requested capabilities to the deployer is planned for a later release.
Hostname-scoped network access
Outbound fetch is the largest exfiltration channel, so every destination is enumerated. List each static hostname under httpFetch.hostnames. A single leading wildcard subdomain such as *.example.com is allowed and bounded by the parent domain; a bare * — or a wildcard anywhere other than the leading label — is rejected at validation.
When the destination is configured by the deployer rather than fixed at build time — a webhook URL, a source site to import from — declare it as user-config:settingKey. At runtime the host reads the configured value from the typed-settings key plugin.{pluginId}.config.{settingKey}, resolves the hostname, and matches it against the URL the plugin actually requested. The SSRF guard runs on every fetch against the resolved hostname, with no DNS cache across calls, so changing the configured URL takes effect on the next invocation. Private, loopback, link-local, and metadata hostnames are rejected even after the allowlist passes.
Per-plugin scoped storage
Cusp keeps one D1 database, one R2 bucket, and one KV namespace. Plugins do not get their own physical resources — that would require a redeploy on every install. Instead the host hands each plugin a wrapper that scopes every call to a per-plugin prefix. The plugin sees the platform's normal binding shape and calls env.KV.get("foo") or env.DB.prepare(sql); the wrapper rewrites the key, prefix, or table reference before it reaches the underlying resource.
- KV — every key is prefixed with plugin/{pluginId}/. A list() with no prefix returns only the plugin's own keys, and returned keys are stripped back to what the plugin stored. The prefix consumes part of the 512-byte key budget, so prefer short keys such as ULIDs.
- D1 — every referenced table must be named plugin_{pluginId}_* (hyphens become underscores). The narrow SQL grammar is the isolation property. Tables are created by the install workflow from the plugin's hand-written CREATE TABLE.
- R2 — every key lives under plugins/{pluginId}/data/. The plugin's bundle area is install-workflow territory and is not reachable at runtime. list() is forced-prefix and multipart uploads are scoped at both create and resume.
Plugin configuration is versioned and time-travels with the rest of the site. Plugin-owned tables, KV keys, and R2 objects are operational state: they are not rolled back by time-travel, and they are not captured by ExportBackup either — the backup dumps only the host coordination tables (plugins, plugin_installations, plugin_subscriptions, plugin_invocations, plugin_field_types), never a plugin's own scoped data. Uninstall plus reinstall loses plugin data — design plugins to rebuild from versioned content or to ship a single-table schema that does not evolve.
Editor-facing configuration
The config object every handler receives is read from typed-settings keys, but a deployer needs somewhere to set those values. Ship a config.json at the package root and Cusp builds the settings form for you. Each top-level key is one typed field — type is string, number, or boolean — with a label, an optional description, and a default. Keys match the grammar ^[a-z][a-zA-Z0-9_]{0,63}$, the same suffix grammar the settings store accepts.
{
"webhookUrl": {
"type": "string",
"label": "Webhook URL",
"description": "Where lifecycle events are POSTed.",
"default": ""
},
"secret": {
"type": "string",
"label": "Signing secret",
"default": ""
}
}On install Cusp validates config.json and stores it in R2 alongside the bundle. Each installed plugin then gets a Configure action in /admin/plugins that opens a form generated from the schema — a text input for string, a number input for number, a checkbox for boolean — pre-filled with the current value or the declared default. Saving writes each value to the versioned setting plugin.{pluginId}.config.{key}, which is exactly the config object your handlers receive. A plugin that ships no config.json simply has no form, and its config argument is an empty object.
The hook system
Hooks are how a plugin reacts to what happens in the CMS. The host calls every subscribed plugin at each hook point through a single dispatcher, races each call against a per-call timeout, isolates errors per subscriber, and logs every invocation. A plugin that throws or times out is logged and skipped — it never fails the request, the publish, or the workflow step.
The handler method name is derived from the hook name: the last dotted segment becomes the verb and the preceding segments form the noun, giving verb + Noun. So document.published resolves to publishedDocument, media.uploaded to uploadedMedia, and auth.signin to signinAuth. Export a method with that exact name; exporting any other name records the dispatch as skipped and delivers nothing.
Every handler is called as handler(payload, config): the first argument is the event snapshot, the second is the plugin's resolved config (the plugin.{pluginId}.config.* settings, keyed by the bare config key). Payloads are JSON-serialisable snapshots, not live references — once the runtime lands they will cross the isolate boundary by structured clone. A plugin reads its own configuration from config and reaches anything else only through the bindings it declared.
The v1 hook vocabulary is fixed (adding a hook is a decision record). The families are:
- Document lifecycle — document.published, document.unpublished, document.deleted, document.restored, document.scheduled. Asynchronous, fire-and-forget.
- Media lifecycle — media.uploaded and media.deleted. Asynchronous.
- Auth lifecycle — auth.signin runs synchronously and returns the user to sign in (or null to decline); auth.signin.success and auth.signout are async notifications.
- Render lifecycle — render.block and render.field run synchronously on the render path. Runtime rendering of plugin-contributed blocks is deferred to v1.x: block registration is wired today, but a contributed block currently renders the unknown-block placeholder.
- Import and export — import.batch transforms a batch of normalised documents; import.complete and export.complete are notifications.
The dispatcher races each call against a per-call timeout — 100 ms by default, with render.block allowed a slightly longer 200 ms because it produces HTML on the render path. Synchronous render and auth hooks keep to those tight bounds because they sit on the request path; the asynchronous document, media, and import families are budgeted more generously by their callers. The intended per-tier budgets are pinned in the hook decision record; treat the timeout as host-controlled, not plugin-controlled.
Each hook has one merge shape, declared by the event. Notification hooks ignore return values. auth.signin uses first-non-null: the first plugin to return a user wins and the rest are skipped. render.block has a single owning plugin per block key. import.batch composes left to right, each plugin transforming the previous plugin's output. Within an event, subscribers run in ascending priority order with alphabetical plugin-id tie-breaking, so ordering is deterministic across deployments — the lowest priority number runs first, which is why the lowest-numbered auth.signin subscriber is the one that gets to decide.
Blocks, iframe trust, and the sandbox
A plugin block renderer runs server-side inside the plugin's own sub-isolate and returns HTML as a string. The host sanitises that output before splicing it into the page: it strips script tags, event-handler attributes, javascript: URLs, iframes, and object, embed, and applet elements. A plugin cannot run JavaScript in a visitor's browser through a block.
The customise live-preview iframe is sandboxed with allow-same-origin allow-scripts. allow-same-origin is kept deliberately: the parent reads the preview's inline <style> block to detect token drift, which a cross-origin sandbox would forbid. This is safe because the preview loads the site's own signed, trusted active theme — not untrusted plugin output — so the iframe trust posture (ADR 0039) rests on the theme being first-party and signed, not on origin isolation. Plugin block output never runs JavaScript in a visitor's browser regardless, because the host sanitises it server-side. For interactive UI a plugin contributes an admin page instead; admin-page JavaScript runs inside the admin SPA, admitted by the administrator's review at install.
How the host loads and runs your plugin
At install, the InstallPackage workflow downloads the package, verifies its signature against the project signing key, writes the bundle to R2, runs any table migrations, registers the plugin and its subscriptions, and records the install state. First-party plugins are signed with the project's CI keypair. An invalid signature is rejected.
At invocation, the host translates the manifest's capabilities into the Dynamic Worker Loader configuration: scoped KV, D1, R2, and email bindings for the storage and communication grants, and a per-isolate globalOutbound shim built from the declared hostnames. A plugin with no network grant gets a sub-isolate with egress denied. The mapping is keyed by plugin version, so reconfiguring or upgrading a plugin invalidates the cached isolate.
Every call writes a plugin_invocations row recording the plugin_id, hook_name, duration_ms, and outcome — ok, timeout, error, or skipped, with an optional error_message — but never the payload. The admin Health page aggregates these into per-plugin latency and error rate, so a misbehaving plugin is visible and can be disabled without leaving the admin.
Read the cusp-webhook example under examples/first-party-plugin/cusp-webhook alongside this guide: it is a stateless dispatcher that subscribes to three lifecycle hooks, fetches a per-config hostname through the egress shim, and signs each delivery with HMAC-SHA256. The capability model and hook contract are pinned in the decision records below.
Read the plugin manifest and capability decisionRead the plugin hooks decision