github emdash-cms/emdash @emdash-cms/admin@0.39.0

Minor Changes

  • #3188 4fef109 Thanks @ascorbic! - Adds saved-entry panels and actions for sandboxed plugins. Declare collection-filtered admin.editorPanels and admin.editorActions entries that point to private plugin routes.

    Panels load Block Kit only when an editor opens them. Actions support confirmation and can return a toast, request an entry refresh, or navigate through a structured link target. EmDash reloads and ownership-authorizes the saved entry before invocation, then exposes only its canonical identity, locale, and version through routeCtx.ui; unsaved editor values never cross the sandbox boundary.

    createPluginRuntimeTestHost() includes panel and action helpers that exercise the production authorization, response-validation, and Worker Loader path.

  • #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.

  • #3145 f6bf82f Thanks @ascorbic! - Updates plugin discovery to show only the registry. Sites with an enabled sandboxRunner use the hosted aggregator at https://registry.emdashcms.com by default. The new top-level registry option accepts a registry URL or configuration object, while registry: false disables registry discovery and registry-installed plugins without disabling the sandbox runner.

    The former experimental.registry location is deprecated but remains supported when the top-level option is omitted. A top-level value takes precedence.

    The marketplace integration option is deprecated but remains supported for plugins already installed from Marketplace. Those plugins continue to run and can still be updated or uninstalled from Plugins. Marketplace browse and install pages are hidden, and configured sites display a migration guide banner.

    What should I do?

    Move an existing experimental.registry value to the top-level registry option. The deprecated location continues to work during the pre-1.0 compatibility period.

    Set registry: false if the site needs its sandbox runner but should not load registry-installed plugins or expose registry discovery.

    Keep marketplace configured while any installed Marketplace plugin still needs updates. Replace or uninstall those plugins, then remove the option by following the Marketplace migration guide.

  • #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.

  • #3251 dbd77ef Thanks @ascorbic! - Adds explicit, consented access to selected unsaved content for sandboxed editor panels and actions.

    Plugins can request admin.editor-draft:read to receive extension-selected field values after an editor invokes them, and admin.editor-draft:patch to propose atomic whole-field set or clear operations. Patch access does not imply read access. Each extension must declare explicit collection scope and narrow its access to field slugs, translatable fields, or both.

    EmDash authenticates and authorizes the saved entry, reloads its schema and revision, validates snapshot and patch limits, and rejects stale or invalid responses. The admin shows a host-rendered before-and-after preview, applies accepted changes to the visible form, marks it dirty, and leaves saving to the editor. Panel load and ordinary typing do not expose draft data or invoke the plugin.

    createPluginRuntimeTestHost() now provides draft capture and host-validated patch application helpers for production-boundary plugin tests.

  • #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.

  • #2880 ad1dee2 Thanks @danielmlr! - Adds the field constraints declared in a collection schema to the content editor, so authors see a limit before a save can fail on it.

    Text fields with maxLength show a live character count below the input and stop accepting input at the limit; a minLength is shown as a hint. Number fields with min or max show the allowed range and set it on the input. Content that is outside its bounds, such as text saved before a limit was lowered, is marked in the editor before a save is attempted.

    The admin manifest now carries a field's validation object for every field type. Previously only repeater, file and image fields exposed it, so length and range rules never reached the editor. Plugin field widgets for trusted plugins receive the same validation object as a prop, so a custom widget can enforce the limits without hardcoding them.

  • #3235 808f473 Thanks @swissky! - The rich text editor's link input now searches existing content by title as you type, so authors can link to pages and posts without copying URLs. Results include drafts (marked as such) when the signed-in user may read them, and picking a result inserts the entry's public URL. Entries whose URL pattern needs a publish date can't be picked until they are published. Typing or pasting a URL works as before.

  • #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.

  • #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.

  • #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

  • #3289 bdbe41c Thanks @ascorbic! - Fixes unreadable warning and info text in the light admin theme, including the marketplace migration banner on the dashboard. Info, success, warning, and danger colours now use the Kumo design system defaults.

  • #3146 4ebd2a8 Thanks @ascorbic! - Fixes datetime sorting and range queries by storing every content datetime as a UTC ISO string with fixed milliseconds. The admin converts date-and-time fields through the site's configured timezone, while API, MCP, and CLI writes now require Z or an explicit UTC offset.

    The core migration reports noncanonical values before changing them, then normalizes content columns and revision snapshots in bounded batches. Legacy values without an offset use the site timezone. If a value falls in a repeated or skipped daylight-saving hour, the migration stops before writing and reports the content row or revision that needs an explicit offset.

  • #3228 9bffbfa Thanks @ascorbic! - Fixes content writes when a database schema contains a field type that the running EmDash version does not support. Entries remain readable, but the admin makes them read-only and content create or update requests return UNSUPPORTED_FIELD_TYPE instead of treating the unknown field as text and risking data loss.

    Deploy this release to every runtime before enabling a later EmDash feature that adds a new field type. Sites whose schemas use only supported field types require no action.

  • #3200 4ebcb07 Thanks @khoinguyenpham04! - Separates scheduling from immediate publishing in the content editor. Schedule controls now sit below the publishing summary, scheduled entries show change and remove actions side by side, and publishing requires confirmation. Slug and content language settings move into a dedicated URL & language section.

  • #3229 89bd85b Thanks @danielmlr! - Completes the German admin translations so German-speaking editors see localized text in the plugin consent dialog's capability list and public-route warning, the marketplace deprecation and plugin registry configuration banners, plugin editor panels and actions, the field length and range hints in the content editor, the scheduled-publication policy notices on the dashboard, the notices for field types this EmDash version does not support, and the visual editing toolbar.

  • #3247 3ad2b50 Thanks @danielmlr! - Fixes the taxonomy screen offering no way to remove a taxonomy. Delete taxonomy, in the screen's actions menu, deletes the taxonomy together with its terms in every language and removes those terms from the content filed under them; the content entries themselves are kept. Removing a taxonomy previously meant a direct DELETE /_emdash/api/taxonomies/{name} call or the taxonomy_delete MCP tool, so a taxonomy created by mistake stayed in the admin sidebar.

    The action requires the taxonomies:manage permission that the route already enforced, so editors and administrators can perform it.

  • #3243 dc685eb Thanks @khoinguyenpham04! - Fixes the code block language selector so typing keeps a scrollable suggestion list open and selecting a language applies its highlighting immediately.

  • #3248 3cec6f9 Thanks @ascorbic! - Fixes registry plugins appearing in discovery but failing installation when their signed profiles predated repository metadata.

    Profiles without the optional repository extension permit releases without provenance. Manual publishing adds an available canonical HTTPS repository with optional provenance, preserves explicit profile policies on later releases, and refuses manual releases when the publisher requires provenance. EmDash routes installation verification correctly and shows site administrators actionable publisher guidance when signed records fail verification.

  • #3267 2375b4a Thanks @sitechfromgeorgia! - Adds Georgian (ქართული) to the admin UI with a complete translation catalog: labels, descriptions, dialogs, and form fields. The locale is selectable from the language picker.

  • #3094 6c23ff3 Thanks @dchaudhari7177! - Fixes the admin editor showing "Image not found" for local media whose storage key contains a folder, such as 2026/08/photo.jpg. Image fields, featured images, galleries and the asset editor now request /_emdash/api/media/file/2026/08/photo.jpg instead of 2026%2F08%2Fphoto.jpg, which the file route answered with 404. Query and fragment characters in a key are still encoded.

  • #3118 667f62e Thanks @danielmlr! - Fixes the content editor saving the writer's copy over a newer version of an entry while the notice that the entry changed somewhere else is shown. Publishing, scheduling, removing a schedule, unpublishing, and changing the publication date each saved that copy first, and publishing then made it live.

    During the conflict, the publishing controls are disabled, a publication date change is refused in its dialog, and a save that was already waiting when the conflict arrived is not sent. Changing the author or the SEO fields still writes, and still replaces what it sends on the newer version, but it no longer clears the notice, and what the writer typed stays in the form instead of being replaced by the newer version. Save anyway still saves the writer's copy.

  • #3285 71572ba Thanks @ascorbic! - Fixes registry and marketplace installation failing after the plugin bundle and state were written because the request runtime did not expose plugin lifecycle hooks. Failed plugin updates now restore the previous state, remove the failed bundle, and reactivate the previous version after resynchronizing the runtime. Registry update and uninstall requests are also registered in generated Astro sites instead of returning 404 Not Found.

    Registry consent now uses a neutral summary when a release has no build provenance, keeps record identifiers and publisher-policy mechanics under collapsed technical details, and shows the requested permission count with a scroll cue for longer lists.

  • #3245 563aa73 Thanks @emdashbot! - Fixes the Repeater field editor dialog so the configuration form scrolls when many sub-fields are added, keeping the Save/Confirm actions reachable.

  • #3252 fc32ebf Thanks @khoinguyenpham04! - Fixes native plugin pages declared at / disappearing from the admin sidebar and command palette.

  • #3125 c783951 Thanks @emdashbot! - Fixes the content editor's distraction-free mode shortcut so ⌘⇧\ (or Ctrl+Shift+\) toggles the mode both in and out, keeps the exit button visible without hovering, and no longer treats Escape as an exit trigger.

  • #3233 801a7ca Thanks @khoinguyenpham04! - Updates the content editor with a compact, searchable picker for categories and tags that supports keyboard controls, term creation, and comma- or newline-separated tag entry.

  • #3205 93df4e8 Thanks @danielmlr! - Fixes the content editor reporting a failed save with the field's slug and the validator's wording, such as excerpt: Too big: expected string to have <=160 characters. When a save, autosave, new entry or new translation fails field validation, the error toast now names each field by the label the editor shows and says what the field needs, for example "Summary can have at most 160 characters."

  • #3109 ce1659c Thanks @dandaka! - Fixes images stretching in the rich-text editor when their custom display size has a different aspect ratio from the original image. Images crop to the display size, matching published images from the local media library.

  • Updated dependencies [71901fc, 4fef109, 80ccfaf, 46784e1, 3538bb8, dbd77ef, 2818e66, 1e13daa, c029134, a823276, 6daffea, fc32ebf, 06bad83, 8ad06e9]:

    • @emdash-cms/registry-lexicons@0.6.0
    • @emdash-cms/blocks@0.39.0
    • @emdash-cms/plugin-types@0.4.0
    • @emdash-cms/registry-client@0.6.1

Don't miss a new emdash release

NewReleases is sending notifications on new releases.