fix(core): let sandboxed beforeSave hooks reject saves - #2858
Conversation
🦋 Changeset detectedLatest commit: 2cc7ab8 The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | fd703ed | Sep 02 2026, 01:08 PM |
Scope checkThis PR changes 702 lines across 26 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
🚀 Deploying Preview to Cloudflare 🚀Preview URL: https://fix-sandboxed-before-save-rejection.try.emdashcms.com, https://fix-sandboxed-before-save-rejection-emdash-playground.emdash-cms.workers.dev (commit 2cc7ab8)This URL reflects your latest Preview deploymentPreview Deployments by commit
|
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-moderation
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
This is a focused, well-scoped bug fix for the sandboxed half of #2197. The approach is sound: introduce a versioned __emdashSandboxHookResult envelope, validate it at the sandbox boundary, return the existing SAVE_REJECTED error shape with plugin id and reason in details, and let the admin wrap that in a localized message. I checked the new inspectSandboxHookResult validator, the runtime hook dispatch in emdash-runtime.ts, the admin client error formatting, the workerd/Cloudflare transport tests, and the updated docs/changeset.
Code-wise this looks solid: malformed/overlong/empty envelopes and sandbox exceptions all fall back to CONTENT_HOOK_ERROR without leaking internal strings, the save is stopped before persistence on rejections, and the existing trusted-hook path is untouched. The tests cover the validation boundary, real create/update routes, the workerd/Cloudflare RPC transport, and admin UI text wrapping.
The only block of issues is documentation drift: the prose was updated to say sandboxed hooks can return a rejection envelope, but the quick-reference tables and the reference Return Value bullets still describe content:beforeSave as returning only "modified content or void". Those need to stay consistent with the shipped behavior.
Findings
-
[needs fixing]
docs/src/content/docs/reference/hooks.mdx:83-86The prose right above says a sandboxed
content:beforeSavehook can reject the save by returning a version-1 error envelope, but theReturn Valuebullet list still only documents modified content andvoid. This contradicts the new behavior.#### Return Value - Return modified content object to apply changes - Return a sandbox hook error envelope to reject the save with a bounded plain-text reason - Return `void` to pass through unchanged -
[needs fixing]
docs/src/content/docs/plugins/creating-plugins/hooks.mdx:139The opening sentence says
content:beforeSavereturns only modified content orvoid, but the section later documents the new sandbox rejection envelope. Update the first line so the summary matches the rest of the page.Runs before content is saved. Return modified content, a sandbox hook error result, or `void` to leave it unchanged. -
[needs fixing]
docs/src/content/docs/plugins/creating-plugins/hooks.mdx:407The hook overview table still lists the
content:beforeSavereturn type as "Modified content orvoid", omitting the new rejection envelope that the surrounding prose describes.| `content:beforeSave` | Before content save | Modified content, rejection envelope, or `void` | No | -
[needs fixing]
skills/creating-plugins/references/hooks.md:469The quick-reference table says
content:beforeSavereturns only "Modified content orvoid", while the prose earlier in the same file already documents theSandboxHookErrorEnvelope. The same outdated table is duplicated in every template undertemplates/*/.agents/skills/creating-plugins/references/hooks.md; update them all in lockstep.| `content:beforeSave` | Before save | `content:write` | Modified content, `SandboxHookErrorEnvelope`, or `void` |
|
Addressed all four documentation consistency findings in
Verified with ~ 🤖 Codex |
What does this PR do?
Lets sandboxed
content:beforeSavehooks reject content creation and updates with a bounded editor-facing reason. A sandboxed hook returns a typed version 1SAVE_REJECTEDresult; the host validates the envelope, trims the plain-text reason, enforces the exported 500-character limit, and returns the normalSAVE_REJECTED/ 422 API envelope with the rejecting plugin ID in structured details.The admin wraps that validated data in a localized host-controlled message (
Plugin {id} rejected the save: {reason}). React continues to render both values as text. Empty, overlong, malformed, and unknown error results, plus unexpected sandbox exceptions, stop the write with the genericCONTENT_HOOK_ERRORresponse and do not expose exception messages or stacks.The existing #2617 trusted-hook behavior is unchanged. Publishing and pre-publish validation, media hooks, and
content:afterSaveremain out of scope.Closes #2197
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been run (the configured formatters were scoped to changed files to preserve unrelated untracked worktree files)messages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.AI-generated code disclosure
Screenshots / test output
No visual component or layout change.