github emdash-cms/emdash @emdash-cms/cloudflare@0.39.0

Minor Changes

  • #3180 6e151ef Thanks @ascorbic! - Adds binary-safe ctx.http.fetch() behavior to sandboxed plugins on Cloudflare Worker Loader and Node/workerd. Request and response bodies are buffered with an 8 MiB decoded limit, and the returned WHATWG Response preserves bytes, status text, headers, final URL, redirect state, and clones across both runners.

    Redirected requests follow Fetch method and body rules. The Node/workerd runner also applies the installed version's current network capability and host list immediately after a plugin update.

    Reading binary responses

    Read bytes from the buffered response with the standard Response API:

    const response = await ctx.http!.fetch("https://api.example.com/report");
    const bytes = new Uint8Array(await response.arrayBuffer());

    Testing external HTTP

    createPluginRuntimeTestHost() adds http.respond(), http.requests(), and http.clear() for deterministic production-bridge tests:

    await host.http.respond("https://api.example.com/report", new Response(new Uint8Array([0, 255])));
    await host.transport.invokeRoute("import-report");
    expect(host.http.requests()).toContainEqual(
    	expect.objectContaining({ url: "https://api.example.com/report" }),
    );
  • #3171 80ccfaf Thanks @ascorbic! - Adds capability-gated schema, translation, public URL, and content revision discovery for plugins.

    Declare schema:read to list collection and field definitions through ctx.schema. Existing content:read access can inspect safe content identity, discover locale siblings with getTranslations(), and resolve published routes with getPublicUrl(). Public URL resolution follows the site's collection pattern, locale routing, and trailing-slash policy and returns null for content without a public route.

    Revision snapshots require the separate content:revisions:read capability because retained history can contain field values that an administrator removed later. This capability implies ordinary content:read access. Installation and plugin updates show both new authorities for consent, and the native, Cloudflare Worker Loader, and Node.js workerd runtimes expose the same methods.

  • #3184 46784e1 Thanks @ascorbic! - Adds capability-gated redirect access for sandboxed plugins. Declare redirects:read to list redirect rules with cursor pagination and read a rule with an opaque _rev. Declare redirects:write to create, update, and delete redirect rules; write access implies read access and installation consent states that the plugin can change where visitors are sent.

    Redirect mutations use EmDash's redirect validation and cache invalidation path. Writes are serialized across runtimes so duplicate-source and loop validation use a consistent rule graph. The expanded redirect schema remains compatible with writes from previous host processes during rolling deployments. Loop validation runs when a rule is created or its source or destination changes; enabled-only updates retain the host API's existing behavior. Updates and deletes require the latest _rev, reject concurrent changes with CONFLICT, and do not let plugins set the host-owned automatic redirect marker. The Cloudflare Worker Loader and Node.js workerd runners expose the same API, and createPluginRuntimeTestHost() includes redirect fixtures and inspection for production-boundary tests.

  • #3170 3538bb8 Thanks @ascorbic! - Adds comments:read and comments:moderate for sandboxed plugins. ctx.comments can get, count, and cursor-page through non-trashed comments, and can change a comment between approved, pending, and spam when the caller supplies the status it previously observed.

    comments:read exposes comment bodies, author names and email addresses, pseudonymous IP hashes, user agents, and moderation metadata. It does not expose the linked EmDash user-account ID. comments:moderate implies that read access, and installation or an update that requests either capability requires operator consent.

    Status changes use the core moderation path. A stale expected status rejects with COMMENT_STATUS_CONFLICT, and an overlapping transition can reject with COMMENT_MODERATION_IN_PROGRESS; a successful transition runs comment:afterModerate once with the calling plugin's origin and preserves approval notifications. Hard deletion and bulk status replacement are not included.

  • #3172 2818e66 Thanks @ascorbic! - Adds separate sandboxed-plugin capabilities for reading media bytes and editing media metadata.

    Declare media:bytes:read to use ctx.media.readBytes(). Reads are available only for ready media, default to a 10 MiB limit, enforce the caller's limit while consuming the storage stream, and cannot request more than 16 MiB. The result includes the content hash; ordinary media:read metadata excludes content hashes, storage keys, and author identity.

    Ready-media metadata URLs use an authenticated media ID route. Authenticated callers with the media:read permission can fetch the asset without receiving its storage key; logged-out requests are rejected before the route queries media.

    Declare media:metadata:write to use ctx.media.updateMetadata() for alt text, captions, and focal points. This capability cannot upload, replace, move, or delete media. It does not imply media:read or media:bytes:read.

    @emdash-cms/plugin-test also provides binary media fixtures and inspection through the runtime-backed host so plugin tests can exercise the production Worker Loader bridge.

  • #3173 7aa12b3 Thanks @ascorbic! - Adds ctx.settings for plugin configuration and encrypts fields declared as type: "secret" before writing them to the database. Native plugins, Cloudflare Worker Loader plugins, and Node/workerd plugins share the same versioned AES-GCM envelope and plugin-scoped API. @emdash-cms/plugin-test can update generated settings through the runtime host and inspect their raw persisted envelope.

    Set EMDASH_ENCRYPTION_KEY in the runtime process environment before saving secret settings. A standalone Node server does not load .env automatically. To rotate the key, place the new key first in a comma-separated list and retain old keys until every plugin secret has been saved again. EmDash does not currently report which key IDs remain in use, so track each resaved credential and verify its integration before removing an old key. Restores need both the database and every encryption key referenced by its stored envelopes.

    Cloudflare sites using nodejs_compat with a compatibility date before 2025-04-01 must also add nodejs_compat_populate_process_env before saving secrets through the generated admin form. Cloudflare enables that behavior by default for later compatibility dates.

    Existing plaintext secrets remain readable and are encrypted when saved again. The ctx.kv.get("settings:<key>") compatibility alias remains available throughout the EmDash 0.x release line; new plugin code should use ctx.settings.get("<key>").

    Only fields declared as type: "secret" in admin.settingsSchema use this encryption path. Arbitrary plugin KV and state values are unchanged; credentials stored by the bundled AT Protocol and webhook notifier plugins are not migrated by this release.

  • #3194 1e13daa Thanks @ascorbic! - Adds separately consented publication and restore actions to native and sandboxed plugin contexts.

    Plugins with content:publish can read an entry with an opaque revision and publish, unpublish, schedule, or unschedule it through the same runtime behavior as REST and MCP. Each mutation requires the revision returned by the read or preceding action, and a plugin cannot recursively run the same action for the same entry. The capability implies content:read but not content:write.

    Plugins with content:restore can read and restore trashed entries without receiving ordinary content-read or write authority. Restore is revision-fenced and returns the next revision. Existing plugin installations receive neither capability unless a new version declares it and the administrator approves the expanded access.

  • #3185 c029134 Thanks @ascorbic! - Adds hooks.content-policy:register for sandboxed and native plugins that need to inspect and reject publication, scheduling, or unpublication without receiving content read, write, or publication-action access.

    Policy plugins can register content:beforePublish, content:beforeSchedule, and content:beforeUnpublish. Each event identifies the API, MCP, visual editor, plugin, scheduler, or system origin and includes the authenticated actor when one exists. Return { cancel: true, reason } to reject the action with a stable error code. EmDash validates the reason as 1–500 plain-text characters. For allowed actions, the revision read before policy evaluation becomes the mutation precondition.

    Scheduled content runs content:beforePublish again when it becomes due. A policy rejection unschedules the entry, lists its public-safe reason and entry link on the dashboard, and avoids retrying the same permanent rejection on every scheduler tick. Successful rescheduling, publication, or deletion clears the record; administrators can dismiss stale records. @emdash-cms/plugin-test exposes stored scheduler rejections through inspect.scheduledPolicyRejections().

  • #3190 6daffea Thanks @ascorbic! - Adds declared request and raw response contracts for sandboxed plugin routes across the native,
    Cloudflare Worker Loader, and Node/workerd runtimes.

    Use methods to have the host reject other HTTP methods with 405 Method Not Allowed. Use
    request.body with json, text, bytes, form-data, or none for bounded buffered parsing, and
    list the safe request headers the handler needs. Undeclared routes retain their existing
    method-agnostic JSON and query-string behavior.

    Routes with response: "raw" return pluginResponse() with an unwrapped text or byte body, status,
    and allowlisted representation, download, or redirect headers. Raw responses are limited to 8 MiB.
    The host removes all other plugin-supplied headers, applies the route's cache and browser security
    policy, and rejects active same-origin content types.

    pluginRoute() infers a sandboxed handler's input from its declared body mode.
    definePluginRoute() provides the equivalent inference for trusted native routes.
    createPluginRuntimeTestHost() accepts rawBody for testing the production request parser with
    text, bytes, URL-encoded data, and multipart form data.

  • #3169 8ad06e9 Thanks @ascorbic! - Adds the taxonomies:write sandboxed-plugin capability for creating taxonomy terms and adding or removing term assignments through ctx.taxonomies.

    Assignment methods accept term row IDs or translation-group IDs and apply idempotent deltas, so they do not replace existing assignments and concurrent additions are preserved. EmDash validates collection attachment, entry existence, term ownership, configured locales, translation identity, and hierarchy before changing taxonomy state. Sandboxed createTerm() rejects parentId for a non-hierarchical taxonomy instead of ignoring it. The capability implies taxonomies:read and requires renewed consent when an installed plugin first declares it.

    Existing REST and MCP term mutations also reject creating or updating a term with a parent in a non-hierarchical taxonomy. Callers that assign parents must mark the taxonomy as hierarchical before creating or reparenting terms.

    This release includes migration 082_taxonomy_translation_locale_unique, which enforces one term per translation group and locale. If an existing database contains duplicate rows, the migration preserves them as independent term groups and copies their assignments before adding the unique index. It can restart safely after any completed statement.

    @emdash-cms/plugin-test adds taxonomy fixtures and an assignment inspector for production-boundary tests. Taxonomy definition management, assignment replacement, term updates, and term deletion remain unavailable to sandboxed plugins.

Patch Changes

  • #3272 fc4a7be Thanks @ascorbic! - Fixes revision restore on Cloudflare D1 so the restored content and its audit revision commit atomically. If either write fails, the entry and its revision history remain unchanged.

  • #3164 6ce67bb Thanks @danielmlr! - Fixes concurrent core migrations on Cloudflare D1 failing partway with errors such as table "_plugin_storage" already exists. emdash migrate and runtime migrations in auto mode take a migration lock in the D1 database. A second run waits up to 10 seconds: it succeeds without applying anything if the first run finishes in that time, and otherwise fails without applying migrations.

    A run that stops before releasing the lock leaves it held, because it may have stopped partway through a migration. This happens when a CI job is cancelled during emdash migrate, when a Worker in auto mode stops during a runtime migration, or when a development server is stopped while it applies migrations. Until the lock is released, pending migrations do not run and a D1 site in auto mode fails to initialize EmDash. Once the lock is older than a minute, migration runs fail at once with the lock's time and id, and the runtime retries after its migration-failure backoff.

    Adds emdash migrate --release-lock <id> to release such a lock. emdash migrate --status reports the lock and its id. After confirming that no migration is running, release the lock with that id:

    pnpm emdash migrate --release-lock 1788264000000

    Releasing the lock of a remote D1 database needs a build manifest and an API token with D1 Edit permission. A lock in the local D1 database of a development server is released with Wrangler. See Release a stuck migration lock for both procedures.

  • #3095 f88db94 Thanks @dchaudhari7177! - Fixes emdash migrate --d1 <name> failing for every database name with "Cloudflare D1 database list total_pages is invalid". The D1 list endpoint does not return total_pages, so the page count is now derived from total_count and per_page when it is absent. A preview database whose name only contains the requested name (the name filter matches substrings) no longer fails the lookup either.

  • #3244 5129196 Thanks @ascorbic! - Fixes playground retries reusing a partially initialized database after setup fails, which could make every retry return another 500 error. Trying again now discards the incomplete session and creates a fresh playground database.

  • #3052 34e9bb5 Thanks @logelog! - Fixes sandboxed ctx.content.create() accepting author_id and primary_byline_id from plugin data on Cloudflare and Workerd. Those values are ignored during creation, and sandboxed reads omit the raw primary_byline_id field from item.data.

  • #3176 b11095d Thanks @connorblack! - Fixes cloudflareImages() and cloudflareStream() so their *EnvVar options (accountIdEnvVar, accountHashEnvVar, apiTokenEnvVar) read process.env on the Node adapter. Previously they only checked Cloudflare Workers bindings, so a Node-hosted site with credentials exported as environment variables failed with a "Missing ..." error even though the variable was set. A Cloudflare Workers binding of the same name still takes precedence when one exists.

  • #3152 a823276 Thanks @ascorbic! - Fixes standard sandboxed plugins so lifecycle, content, media, comment, email, cron, and page metadata hooks run through the same ordered, capability-gated host pipeline as trusted plugins on Cloudflare Workers and Node.js.

    Sandbox contexts now expose canonical capabilities, database-backed ctx.cron, complete content metadata and filtering, and a real Response shape from ctx.http.fetch(). Cloudflare response bodies still cross the bridge as text. Admin-managed settings now share the ctx.kv settings namespace, lifecycle hooks run once at the correct install/enable boundary, and uninstall cleanup runs before plugin data or bundles are removed.

    Plugin builds also preserve hook, route permission and cache, MCP, settings, and field-widget metadata in registry bundles and npm descriptors.

  • #3199 a487ae3 Thanks @ascorbic! - Adds emdash/plugins/host as a narrow runtime entry for platform sandbox adapters. The Cloudflare Worker loads scheduled maintenance and sandbox bridge dependencies when those capabilities first run, reducing startup CPU while preserving existing Worker exports and behavior.

  • #3162 a4af578 Thanks @ascorbic! - Adds createPluginRuntimeTestHost() for sandboxed plugin tests that must exercise EmDash orchestration instead of invoking an isolate directly. The host separates direct transport calls, fixtures, production actions, observable-state inspectors, scheduled time control, cold restart, and disposal.

    Runtime actions cover the shipped content lifecycle, plugin activation and deactivation, media upload, public comment submission, comment moderation, plugin-route policy, and scheduled task execution. The controlled scheduler clock applies to cron tasks and scheduled publishing. restart() retains D1, plugin storage, media storage, and plugin state while replacing runtime and isolate memory. The host captures delivered email for assertions.

    createPluginTestHost() and its top-level invokeHook() and invokeRoute() methods remain compatible for fast transport-level tests. emdashPluginTest() supplies the runtime modules required by the documented Vitest configuration. Generated plugin projects continue to use Worker Loader by default and describe Node/workerd parity as an opt-in test for runner-sensitive behavior.

  • #3174 06bad83 Thanks @ascorbic! - Adds structured Block Kit navigation and host-attested administrator locale context for sandboxed plugin pages and dashboard widgets.

    Plugins can return link elements that target saved content, another page declared by the same plugin, generated plugin settings, or an external HTTP, HTTPS, or mailto: URL. EmDash constructs internal admin URLs and opens external links with noopener noreferrer. Links never dispatch block actions and cannot appear as form fields.

    Block Kit route handlers receive routeCtx.ui with the validated surface, administrator locale, and text direction. The host validates every sandboxed page and widget response before rendering it, rejects undeclared plugin-page targets and active URL protocols, and permits external images only over HTTPS to hosts declared in allowedHosts under network:request consent or under network:request:unrestricted consent. Responses are limited to 256 KiB, 20 levels, 2,000 nodes, 1,000 items per array, and 64 KiB per string.

    createPluginRuntimeTestHost() adds admin.loadPage(), loadWidget(), act(), and submit() helpers that exercise the private production route, Worker Loader isolate, host UI context, and response validation.

    This is a breaking security tightening for sandboxed plugins that return an external Block Kit image without matching network authority. EmDash rejects the complete page or widget response instead of allowing the administrator's browser to contact an unapproved host.

    What should I do?

    If a plugin returns external Block Kit images, add network:request and every image hostname to allowedHosts, or add network:request:unrestricted when the plugin genuinely requires any hostname. Publish a plugin update so administrators can review and approve the expanded authority. Root-relative images need no manifest change.

  • #3182 70ab2f8 Thanks @ascorbic! - Adds translation-aware sandboxed plugin content creation through ctx.content.create(collection, data, { locale, translationOf }).

    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 takes non-translatable field values from the source. Content validation and save hooks run in both the Cloudflare Worker Loader and Node/workerd runners. Save-hook-originated creates do not re-enter save hooks, and the creating plugin's own content:afterSave hook is not re-entered.

    Each translation group permits one active entry per locale. Duplicate locale creates return CONFLICT, missing sources return NOT_FOUND, invalid or unconfigured locales return VALIDATION_ERROR, and save hooks can return SAVE_REJECTED.

  • Updated dependencies [5510725, fc4a7be, 6e151ef, 4fef109, 80ccfaf, 46784e1, f6bf82f, 3538bb8, 4ebd2a8, dbd77ef, 9bffbfa, 2818e66, 6ce67bb, 9c61f93, a10f9ca, ad1dee2, 7aa12b3, dd885e5, e9c4433, e9c4433, 222f329, 71901fc, 3cec6f9, 363dd56, 3533d2c, a6b9884, 71572ba, f0e3817, 1e13daa, 27e9450, c029134, 1fea699, 6e151ef, 71572ba, a823276, 5510725, a487ae3, 6daffea, 71572ba, b3433d1, a4af578, 06bad83, a4af578, 808f473, 808f473, 9ca2de5, 26e035d, dda36bf, 70ab2f8, 9e17b18, 8ad06e9, 7f1a49d, 3030d09, 93df4e8]:

    • emdash@0.39.0

Don't miss a new emdash release

NewReleases is sending notifications on new releases.