Accepted

ADR 0036 — Plugin manifest and capability model

Every plugin declares exactly what it can do; the host hands it nothing more

Plugins run inside Dynamic Worker Loader sub-isolates. The manifest is the negotiation surface: it lists the capabilities the plugin needs, and the host hands the sub-isolate bindings for exactly those capabilities and nothing else. A plugin that does not declare a capability cannot exercise it, because the binding for it is never constructed.

The decision

A plugin manifest is validated against a JSON Schema at install time. Capabilities are a fixed, enumerable vocabulary. The host translates the declared capabilities into the loader's bindings and outbound-egress configuration at the moment the isolate loads; that translation is the single policy-enforcement point.

Capability families

The vocabulary is grouped into five families. Each grant is narrow and explicit.

  • Storage — kv.read / kv.write, d1.read / d1.write, r2.read / r2.write. Every storage grant is scoped to the plugin's own prefix or tables (see the Plugin storage scoping record). Write implies read.
  • Communication — email.send sends through the host's email service with the from address fixed to the deployment's verified domain; the plugin cannot forge it. http.fetch:{hostname} grants outbound fetch to one enumerated hostname over HTTPS.
  • Content — content.read / content.write reach documents through a host-supplied API, never raw D1; writes go through the versioned-write helper. media.read / media.write and setting.read:{key} work the same way, filtered by the host.
  • Hooks — hook:{event} subscribes to a host lifecycle event. Every hook the plugin registers must be declared, or the host rejects it at load time.
  • Admin — admin.page:{slug} contributes a React page under /admin/plugins/{pluginId}/{slug}; admin.api:{path} contributes an endpoint under /api/admin/plugins/{pluginId}/{path}.

A plugin's own configuration namespace is always granted without a declaration: it reads and writes settings under plugin.{pluginId}.config.*. Unknown capability strings and the reserved core and theme prefixes fail validation cleanly at install.

Manifest shape

The manifest is a structured object, not a flat string array. Requested bindings, hook subscriptions, contributed blocks and field types, and admin pages each have their own section, validated independently.

manifest.json
{
  "requestedBindings": {
    "kv": true,
    "r2": true,
    "d1": { "tables": ["widgets"] },
    "email": { "from": "sender@example.com" },
    "httpFetch": { "hostnames": ["api.example.com", "*.example.com", "user-config:webhookUrl"] }
  },
  "subscribesTo": [{ "hookName": "document.published", "priority": 100 }],
  "contributesBlocks": [],
  "contributesFieldTypes": [],
  "adminPages": []
}

The validator lives at src/worker/install/manifest-plugin.ts and collects every error in one pass, so a plugin author gets one round-trip per pack iteration rather than one per error. A regression test round-trips every bundled first-party plugin manifest through validation, so structural drift fails before merge.

Hostname-scoped HTTP egress

Outbound fetch is the largest exfiltration channel, so it is hostname-enumerated. A bare wildcard is forbidden; every hostname must be named, though a single leading wildcard subdomain such as *.example.com is allowed. HTTPS only — the egress shim rejects plain http URLs.

A plugin that legitimately needs a user-supplied hostname declares user-config:{settingKey}. The egress shim resolves the configured value and runs the private-host SSRF guard on every fetch, against the URL actually requested — load-time resolution would go stale the moment the operator changed the setting.

The decided design surfaces capabilities at install in editor language with a plain-English expansion per item — "Send email" rather than email.send — and requires the operator to affirm trust before install is enabled. Consent is per install and per version, not remembered per author, because the capability set belongs to the version; an upgrade that broadens the set re-prompts showing only the additions, while a strictly narrower or unchanged set upgrades without a prompt. This consent surface is not yet implemented: the install dialog today posts the plugin reference (id, version, source URL), and binding-layer enforcement of the declared capabilities plus administrator review are the active trust boundary. The consent prompt and the upgrade re-prompt are planned for a later release.

Why

  • The capability surface is enumerable, greppable, and schema-validated. A reviewer reads the manifest and knows exactly what the plugin can do.
  • The host is the single enforcement point. Bindings are scoped at construction, so the plugin never reaches raw DB, BUCKET, KV, or EMAIL.
  • Coarse permission tiers were rejected — a "writer" tier that grants storage, mail, and arbitrary fetch at once is no better than an unsandboxed plugin. Reading capabilities from the plugin's code by static analysis was rejected too: an eval-based dodge makes false negatives catastrophic, so the declarative manifest is the source of truth.

Consequences

  • The capability vocabulary is load-bearing. Adding a new capability is a manifest-schema change and goes through its own decision record.
  • Plugin configuration and capability history are versioned through the versioned-write helper, so site-wide time travel covers what a plugin was allowed to do at any past moment.
  • An operator can still choose to install a plugin that asks for a lot. The planned consent prompt will not stop a determined trust decision; it ensures the operator actively makes one. Until it ships, administrator review of the manifest is that active decision point, and binding-layer enforcement bounds what the plugin can do regardless.
Read the plugin storage scoping record