Capabilities and security
Sandboxed plugins are isolated by default. To do anything beyond reading and writing their own KV and storage, a plugin has to declare a capability in its manifest. The sandbox bridge gates every host-provided API based on those declarations — a plugin that didn’t declare content:read doesn’t get a ctx.content, and one that didn’t declare network:request doesn’t get ctx.http.
This page covers what each capability grants, how the sandbox enforces them, and what’s not enforceable.
Declaring capabilities
Section titled “Declaring capabilities”Capabilities live in emdash-plugin.jsonc, alongside slug and the rest of the trust contract:
{ "slug": "plugin-hello", // ...identity + profile...
"capabilities": ["content:read", "network:request"], "allowedHosts": ["api.example.com"]}Declare only what the plugin actually needs. The registry shows these capabilities to site operators before installation, so every extra declaration asks them to approve access the plugin does not use.
Capability reference
Section titled “Capability reference”| Capability | Grants access to |
|---|---|
content:read |
ctx.content.get(), ctx.content.list(), ctx.content.getTranslations(), ctx.content.getPublicUrl() |
content:revisions:read |
ctx.content.listRevisions(), ctx.content.getRevision() (implies content:read) |
content:write |
ctx.content.create(), ctx.content.update(), ctx.content.delete() (implies content:read) |
content:publish |
Versioned publish, unpublish, schedule, and unschedule operations (implies content:read) |
content:restore |
Read and restore trashed content |
comments:read |
ctx.comments.get(), ctx.comments.list(), ctx.comments.count() and comment personal data |
comments:moderate |
ctx.comments.setStatus() with expected-status concurrency control (implies comments:read) |
schema:read |
ctx.schema.listCollections(), ctx.schema.getCollection() |
hooks.content-policy:register |
content:beforePublish, content:beforeSchedule, and content:beforeUnpublish policy hooks |
taxonomies:read |
ctx.taxonomies.getAll(), ctx.taxonomies.getTerms(), ctx.taxonomies.getEntryTerms() |
taxonomies:write |
ctx.taxonomies.createTerm(), ctx.taxonomies.addEntryTerms(), ctx.taxonomies.removeEntryTerms() (implies taxonomies:read) |
redirects:read |
ctx.redirects.list(), ctx.redirects.get() |
redirects:write |
ctx.redirects.create(), ctx.redirects.update(), ctx.redirects.delete() (implies redirects:read) |
media:read |
ctx.media.get(), ctx.media.list() |
media:bytes:read |
ctx.media.readBytes() for ready media, with a bounded buffered response |
media:metadata:write |
ctx.media.updateMetadata() for alt text, captions, and focal points |
media:write |
ctx.media.getUploadUrl(), ctx.media.upload(), ctx.media.delete() (implies media:read) |
network:request |
ctx.http.fetch() — restricted to allowedHosts |
network:request:unrestricted |
ctx.http.fetch() with no host restriction (for user-configured URLs only) |
users:read |
ctx.users.get(), ctx.users.getByEmail(), ctx.users.list() |
email:send |
ctx.email.send() (requires a configured email provider plugin) |
hooks.email-transport:register |
Allows registering the exclusive email:deliver hook (transport providers) |
hooks.email-events:register |
Allows registering email:beforeSend / email:afterSend hooks |
hooks.page-fragments:register |
Allows registering the page:fragments hook (native plugins only) |
The following rules affect which capabilities a plugin needs:
- Implications.
content:write,content:revisions:read, andcontent:publishautomatically implycontent:read;comments:moderateimpliescomments:read;taxonomies:writeimpliestaxonomies:read;media:writeimpliesmedia:read;redirects:writeimpliesredirects:read;network:request:unrestrictedimpliesnetwork:request. You don’t need to list both. - Media authorities are separate.
media:read,media:bytes:read, andmedia:metadata:writedo not imply one another. Declare each operation the plugin uses. The existingmedia:writecapability continues to implymedia:readfor compatibility. - Taxonomies are separate from content. Taxonomy capabilities do not grant
content:readorcontent:write. Declare the matching content capability if the plugin also reads or edits entry fields. - Publication policy is separate from content access.
hooks.content-policy:registerlets a plugin inspect and reject publication state changes through policy hook events. It does not providectx.contentor grant content editing or publication actions. network:request:unrestrictedexists for user-configured URLs. A webhook plugin where the operator types in the destination URL needs to reach hosts that aren’t in the manifest. Plugins that always call known APIs should usenetwork:request+allowedHosts.email:sendis gated by configuration, not just the capability. A plugin can declareemail:send, butctx.emailwill only be populated if some other plugin has registered anemail:delivertransport.
content:read returns safe entry identity, including the author ID, translation group, revision pointers, and row version. Use getTranslations() to discover locale siblings and getPublicUrl() to resolve a published route with the site’s locale and trailing-slash rules. getPublicUrl() returns null for drafts, unroutable collections, missing slugs, and locales the site does not serve. It never returns a preview URL.
Revision snapshots can contain field values that an administrator later removed. Declare content:revisions:read only when the plugin needs retained history. Revision results omit the revision author’s identity.
schema:read exposes collection and field definitions without database IDs, timestamps, migration metadata, or SQL column types. Hidden collections remain visible because hidden controls admin navigation rather than data access.
Creating and translating content
Section titled “Creating and translating content”ctx.content.create() accepts an optional third argument for the new entry’s locale:
const post = await ctx.content.create( "posts", { title: "繁體中文" }, { locale: "zh-tw" },);Locale matching is case-insensitive and stores the casing from the site’s locale configuration, so zh-tw becomes zh-TW when that is the configured form. A malformed explicit locale always throws; when i18n is configured, an explicit locale outside the configured locale list also throws. When the option is omitted, EmDash uses the site’s configured default locale; sites without i18n configuration retain the en default.
To add a locale to an existing entry, pass its database ID as translationOf:
const translatedPost = await ctx.content.create( "posts", { title: "Bienvenue", sku: "ignored-for-shared-fields" }, { locale: "fr", translationOf: sourcePost.id },);The source must be an active entry in the same collection. The new entry joins its translation group, inherits its byline credits and taxonomy assignments, and starts with the source values for fields marked as non-translatable. A value supplied for a non-translatable field does not replace the source value during translation creation. Content validation and save hooks run through the same runtime path as other content creates. EmDash does not re-enter the creating plugin’s own content:afterSave hook, and content created from inside a save hook does not run save hooks again.
Each translation group can contain one active entry per locale. Creating a second entry for the same group and locale throws a CONFLICT error. A missing source throws NOT_FOUND, an invalid or unconfigured locale throws VALIDATION_ERROR, and a save hook can stop the create with SAVE_REJECTED.
Change publication state
Section titled “Change publication state”Declare content:publish to publish, unpublish, schedule, or unschedule an entry. Each action requires the opaque _rev returned by getVersioned() or the preceding action. EmDash routes these methods through the same policy hooks, revision promotion, locale synchronization, redirects, media-usage updates, cache invalidation, and after-hooks as REST and MCP actions.
The following route publishes the current draft only when the entry has not changed since it was read:
const current = await ctx.content!.getVersioned!("posts", postId);if (!current) return { ok: false, error: "NOT_FOUND" };
try { const published = await ctx.content!.publish!("posts", postId, { _rev: current._rev, }); return { ok: true, content: published.item, _rev: published._rev };} catch (error) { return { ok: false, error: "PUBLISH_FAILED" };}schedule() accepts { scheduledAt, _rev }; the other publication methods accept { _rev }. These methods do not accept a publishedAt override.
Declare content:restore separately to read and restore trashed entries. getTrashedVersioned() returns null for a live or missing entry. Pass its _rev to restore() so a concurrent change returns a conflict instead of restoring stale state.
Creating and assigning taxonomy terms
Section titled “Creating and assigning taxonomy terms”taxonomies:write lets a plugin create terms and apply assignment deltas. Pass term row IDs or translation-group IDs. Term slugs are not accepted because they are scoped by taxonomy and locale.
The following example creates a child category and assigns it without replacing the entry’s other categories:
const releaseNotes = await ctx.taxonomies!.createTerm!("category", { label: "Release notes", parentId: productUpdatesId, locale: "en",});
await ctx.taxonomies!.addEntryTerms!("posts", postId, "category", [releaseNotes.id]);addEntryTerms() and removeEntryTerms() are idempotent set deltas. Concurrent additions preserve every assignment. EmDash verifies that the taxonomy is attached to the collection, the entry exists, and each term belongs to the named taxonomy. createTerm() rejects parentId when the taxonomy is not hierarchical instead of ignoring it. Creating a translated term with translationOf joins the source term’s translation group; the source must belong to the same taxonomy, and the group can contain only one term per locale.
Taxonomy definition creation, collection attachment, replacement, term updates, and term deletion are not available through taxonomies:write.
Reading media metadata and bytes
Section titled “Reading media metadata and bytes”media:read returns ready media records with dimensions, alt text, caption, focal point, blurhash, dominant color, folder ID, and an authenticated ID-based asset URL. Authenticated callers with the media:read permission can follow the URL; logged-out requests are rejected before the route reads the media record. The metadata does not return the storage key, author identity, content hash, or file bytes. The content hash is available only from readBytes() because it can reveal whether the site stores a known file.
Inside a hook or route handler, the following call reads at most 2 MiB from one ready media item:
const file = await ctx.media!.readBytes!(mediaId, { maxBytes: 2 * 1024 * 1024,});
const digest = file.contentHash;const bytes = file.bytes;readBytes() buffers the result. It defaults to 10 MiB when maxBytes is omitted and rejects values above the 16 MiB host maximum. EmDash counts bytes while consuming the storage stream, so an incorrect stored size cannot bypass the requested limit. Missing, pending, and failed media are rejected without revealing their storage location.
The following update changes the accessibility text and focal point without granting upload, replacement, or deletion authority:
const updated = await ctx.media!.updateMetadata!(mediaId, { alt: "Two people reviewing a printed proof", focalX: 0.42, focalY: 0.36,});Provide both focal coordinates as numbers from 0 to 1, or set both to null. Concurrent patches to different metadata fields do not replace one another.
Managing redirects safely
Section titled “Managing redirects safely”redirects:read provides cursor-paginated rule listing and versioned single-rule reads. Add redirects:write when the plugin creates, updates, or deletes rules. Write access can change where visitors are sent.
Pass the _rev returned by get(), create(), or update() back unchanged when updating or deleting a rule. EmDash rejects a stale revision so the plugin can re-read the rule and recompute its change instead of overwriting concurrent work.
The revision tracks redirect configuration. Visitor hit counting does not make a revision stale.
The following example updates a redirect only if it has not changed since the read:
const current = await ctx.redirects!.get(redirectId);if (current) { await ctx.redirects!.update!(redirectId, { destination: "/guides/current", _rev: current._rev, });}Create operations validate path patterns, terminal 410 and 451 rules, duplicate sources, self-loops, and multi-hop loops with the same rules as the EmDash redirect API. Updates apply loop validation when the source or destination changes. An enabled-only update can reactivate a pre-existing loop, which the Redirects page reports. The auto marker belongs to redirects created from host content changes; plugin input cannot set it.
Reading and moderating comments
Section titled “Reading and moderating comments”comments:read grants access to non-trashed comments. Results include the author name and email address, comment body, pseudonymous IP hash, user agent, moderation metadata, status, target content IDs, and timestamps. They exclude the linked EmDash user-account ID. Declare users:read separately when a plugin also needs to look up user accounts.
list() returns newest comments first. It accepts status, collection, and contentId filters, a cursor, and a limit from 1 to 100. The default limit is 50. count() accepts the same filters without pagination.
The following route approves a comment only if it is still pending:
const comment = await ctx.comments!.setStatus!(commentId, "approved", { expectedStatus: "pending",});If another moderator changed the status after the plugin read it, setStatus() rejects with COMMENT_STATUS_CONFLICT. Read the comment again and recompute the decision before retrying. A request that overlaps an earlier transition before its status is visible rejects with COMMENT_MODERATION_IN_PROGRESS; wait for that transition to finish, then read the current comment before retrying. A successful transition runs comment:afterModerate once with origin: { source: "plugin", pluginId }. Approval sends the same core author notification as an administrator approval. Setting a comment to its current status is a no-op and does not run the hook or send another notification.
Network host allowlists
Section titled “Network host allowlists”Plugins with network:request can only fetch hosts listed in allowedHosts. A leading *. matches both the named domain and its subdomains:
"capabilities": ["network:request"],"allowedHosts": [ "api.example.com", // exact host "*.cdn.example.com" // cdn.example.com and any subdomain]The bridge checks the request URL’s host against the allowlist before forwarding the request. A request to a host that wasn’t declared throws inside the plugin without ever leaving the sandbox.
network:request:unrestricted skips the manifest host allowlist. The sandbox bridge still accepts only HTTP and HTTPS, blocks known internal hosts and private literal addresses, rechecks every redirect, and removes credential headers when a redirect crosses origins. Use unrestricted access only when an operator supplies the destination at runtime. For fixed destinations, declare network:request with explicit hosts so the consent dialog names them.
ctx.http.fetch() buffers request and response bodies and limits each decoded body to 8 MiB. The returned WHATWG Response preserves binary bytes, status text, headers, final URL, redirect state, and clone() behavior in both sandbox runners. Read binary data with arrayBuffer() or blob().
What the sandbox enforces
Section titled “What the sandbox enforces”When a sandbox runner is active, the runtime enforces:
-
Capability gating. The PluginContext factory only populates
ctx.content,ctx.comments,ctx.schema,ctx.taxonomies,ctx.redirects,ctx.media,ctx.http,ctx.users,ctx.emailwhen the corresponding capability is declared. Calling a method on an undeclared capability isn’t possible — there’s no object there. -
Storage and KV scoping. Every storage and KV operation is scoped to the runtime plugin ID. A plugin can’t read another plugin’s KV or storage collections, and it can access only collections declared in its manifest.
-
Network isolation. Direct
fetch()and other network primitives are blocked by the runner. The only way to reach the network isctx.http.fetch(), which goes through the bridge’s host validation. -
No host bindings. Sandboxed plugins don’t see environment variables, the filesystem, or any platform bindings — even if your host worker has them. The plugin runtime is a clean isolate with only the bridge and the declared capabilities.
-
Resource limits. The Cloudflare runner defaults to 50 ms of CPU, 10 subrequests, and 30 seconds of wall time per invocation. Worker Loader enforces CPU and subrequests; the runner enforces wall time. Worker Loader has a platform memory ceiling, but its per-plugin
memoryMboption is not currently enforceable. The Node.js workerd runner enforces only the 30-second wall-time default; it warns when a site configures CPU, memory, or subrequest limits that standalone workerd cannot enforce. A per-hooktimeoutapplies only when the sandboxed-format plugin runs in process.
What the sandbox doesn’t enforce
Section titled “What the sandbox doesn’t enforce”A few things the capability system doesn’t and can’t cover:
- Behaviour within a granted capability. A plugin with
content:writecan edit any content, not only its own. Capabilities are coarse — they say “this plugin can write content,” not “this plugin can write only the content it created.” An operator must evaluate the plugin’s code and publisher before granting that access. - Entry edit locks.
ctx.content.update()andctx.content.delete()are programmatic writes. An editor holding the entry’s advisory edit lock does not block them. Coordinate plugin writes with editors when both may update the same entry. - Operator trust on Node.js. When the configured sandbox runner reports unavailable (no Cloudflare Worker Loader, no Node-side runner installed, etc.),
sandboxed: []plugins are skipped at startup. You can move them intoplugins: []to run them in-process — but then there’s no V8 isolate, no resource limits, and the plugin can callfetch()directly or read environment variables. Treat that as native-level trust. - Side channels. Timing, log output, and stored data are all visible to anyone with appropriate access to the host environment. Don’t use the sandbox as a confidentiality boundary against the operator running it.
Capability consent
Section titled “Capability consent”When an operator installs a sandboxed plugin from the registry, EmDash shows a consent dialog listing the declared capabilities. Updates that add capabilities — for example, a plugin that previously only read content now wants to make network requests — surface as a capability diff and require fresh approval before the new version takes effect.
Declaring capabilities for possible future use makes every installation or update ask for unnecessary access. List what the current version uses, then add a capability in the version that starts using it.
Bundle-time validation
Section titled “Bundle-time validation”emdash-plugin bundle and emdash-plugin publish perform additional checks:
- Every declared capability must be in the recognised set (typos fail the build).
network:requestrequires a non-emptyallowedHosts;network:request:unrestrictedrequires it to be empty. See Capabilities and hosts.- The bundled
backend.jscan’t import Node.js built-ins (fs,path,child_process, etc.) — sandbox runtimes don’t provide them.
See the manifest reference for the authoring fields and Bundling and publishing for bundle checks.