Skip to content

fix(core): let content:beforeSave hooks reject a save with an editor-facing message - #2617

Open
danielmlr wants to merge 8 commits into
emdash-cms:mainfrom
danielmlr:fix/before-save-rejection
Open

fix(core): let content:beforeSave hooks reject a save with an editor-facing message#2617
danielmlr wants to merge 8 commits into
emdash-cms:mainfrom
danielmlr:fix/before-save-rejection

Conversation

@danielmlr

@danielmlr danielmlr commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

A content:beforeSave hook is documented to cancel a save by throwing, but a cancelled save reaches the client as an unstructured 500 instead of the API's error envelope. The hook pipeline rethrows on the default errorPolicy: "abort", and neither the runtime handlers nor the content routes catch it. This PR implements the trusted-hook half of the contract @ascorbic wrote into #2197 on 2026-08-16 (milestone 1.0).

ContentSaveRejectedError, exported from the package root, is the expected rejection: throwing it produces { code: "SAVE_REJECTED", message } with HTTP 422, and the admin's existing save and autosave toasts show the message to the editor. Any other exception from the hook run is logged and mapped to a generic CONTENT_HOOK_ERROR, keeping hook internals out of API responses. The hooks docs and the plugin-authoring skill document both, and say that a plugin running in the sandbox cannot cancel a save; scripts/sync-template-skills.sh carries that edit into the skill's nine template copies, which is why ten near-identical files change. Runtime integration tests cover create, update, and the sandboxed case.

Related to #2197 — the trusted-hook half only, so it does not close it. Sandboxed hooks stay out, and so does the publish path @virafb reported on 2026-08-27: handleContentPublish never calls the hook at all. The changeset stays minor: the fix adds one export.

Details

Sandboxed hooks are out of scope, and the runtime now says so where it matters. When a sandboxed beforeSave hook throws, runSandboxedBeforeSave logs that a sandboxed plugin cannot cancel a save and that the save continues, in place of a generic hook-error line. The same plugin cancels under plugins: [] (the host process, documented in choosing-a-format.mdx) and did not cancel in the sandbox, and nothing in the log said which of the two had happened.

That stays a log line rather than a cancel because a throw cannot carry the rejection out of the sandbox. The workerd wrapper answers a thrown hook error with a plain-text 500 body (packages/workerd/src/sandbox/wrapper.ts:443) and the runner rethrows a new Error around that text (packages/workerd/src/sandbox/runner.ts:965-967); on Cloudflare, Workers RPC keeps an error's message and prototype name but drops own properties, and ContentSaveRejectedError sets name as a class field. In both cases the catch sees a plain Error. A sandboxed cancel is a follow-up: a returned sentinel, the way runSandboxedBeforeDelete already treats return false, with the editor message from a host-side code table rather than plugin text.

isContentSaveRejection accepts the error by name as well as by prototype. Bundlers can duplicate the module across SSR chunks, and an instanceof against the wrong copy would misreport a rejection as a crash.

beforeSaveFailure takes both codes from ErrorCode, because mapErrorStatus matches on the value of ErrorCode.SAVE_REJECTED and a literal at the throw site would drift from it and fall to a default 400. The twelve other literal codes in emdash-runtime.ts stay as they are.

Revisions

  • d6b9f7b3 — the sandboxed catch logs that a sandboxed plugin cannot cancel a save, with a runtime test that fakes the sandbox runner; the changeset sentence says the same.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Fable 5 (review pass: Claude Opus 5)

Screenshots / test output

packages/core/tests/integration/runtime/before-save-rejection.test.ts
  — SAVE_REJECTED with the plugin message on create and update, nothing persisted;
    the 422 that the REST reference table names for that code;
    unexpected exception returns CONTENT_HOOK_ERROR without the exception text;
    a non-throwing hook still applies its content changes
  with the runtime catch reverted: 4 of 5 fail (the non-throwing case stays green)
  with the SAVE_REJECTED case removed from mapErrorStatus: 2 of 5 fail

packages/core/tests/integration/runtime/sandboxed-before-save-throw.test.ts
  — a sandboxed hook that throws ContentSaveRejectedError: the item is saved and
    the log names the limitation
  with the log line reverted: 1 of 1 fails

packages/core  vitest run                6172 tests passed, 9 skipped
pnpm typecheck / lint / format / build   clean

@changeset-bot

changeset-bot Bot commented Aug 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d6b9f7b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@pkg-pr-new

pkg-pr-new Bot commented Aug 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/admin@2617

@emdash-cms/auth

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/auth@2617

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/auth-atproto@2617

@emdash-cms/blocks

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/blocks@2617

@emdash-cms/cloudflare

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/cloudflare@2617

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/contentful-to-portable-text@2617

emdash

npm i https://pkg.pr.new/emdash-cms/emdash@2617

create-emdash

npm i https://pkg.pr.new/emdash-cms/emdash/create-emdash@2617

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/gutenberg-to-portable-text@2617

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-cli@2617

@emdash-cms/plugin-types

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-types@2617

@emdash-cms/registry-client

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-client@2617

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-lexicons@2617

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-moderation@2617

@emdash-cms/registry-verification

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-verification@2617

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/sandbox-workerd@2617

@emdash-cms/x402

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/x402@2617

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-ai-moderation@2617

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-atproto@2617

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-audit-log@2617

@emdash-cms/plugin-color

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-color@2617

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-embeds@2617

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-field-kit@2617

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-forms@2617

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-webhook-notifier@2617

commit: d6b9f7b

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approach is sound: this is the right change for the contract described in #2197. Introducing a dedicated ContentSaveRejectedError, catching it at the runtime's save entry points, and mapping it to a structured SAVE_REJECTED (422) envelope hides unexpected hook crashes behind CONTENT_HOOK_ERROR (500) while still letting trusted plugins show editors a clear message. It fits EmDash's handler/routing pattern and the existing admin API-client error path.

I checked the changed files, traced the two runContentBeforeSave call sites, verified the routes use unwrapResult (so mapErrorStatus maps the new codes to 422/500), and confirmed the admin's throwResponseError surfaces error.message to toasts. The tests correctly exercise success/rejection/crash paths for create and update.

Two non-blocking suggestions remain:

  1. beforeSaveFailure uses raw string codes instead of the ErrorCode constants the codebase provides. It's a convention drift in the handler layer and worth fixing while the code is new.
  2. The new integration test's afterEach can crash with a secondary error if boot() fails, because runtime is still undefined. A small guard makes failures easier to read.

Documentation and changeset are accurate and proportionate.

Comment thread packages/core/src/emdash-runtime.ts Outdated
Comment thread packages/core/tests/integration/runtime/before-save-rejection.test.ts Outdated
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review overlap and removed review/needs-review No maintainer or bot review yet labels Aug 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this won't prevent the save: it just logs the error in runSandboxedBeforeSave and continues.

@danielmlr

Copy link
Copy Markdown
Contributor Author

Docs narrowed and pushed: cancelling a save now says it needs the hook in the host isolate. That was wrong for sandboxed plugins before this PR too, and plugins/creating-plugins/hooks.mdx:10 says the page covers them.

The trusted path does cancel the save: emdash-runtime.ts:3000 and :3104 return before the write, and the tests assert the row is not there afterwards.

What that leaves is the part worth your judgement. Standard is the default format, and wherever a runner is configured — sandbox() on Cloudflare, @emdash-cms/sandbox-workerd on Node — the rejection is swallowed at :4159. So the feature ships for native plugins only. If that contract is too thin, say so and I will pull it until the sandbox path exists.

Otherwise I would do that path as a follow-up. Rethrowing in runSandboxedBeforeSave is not it: runSandboxedBeforeDelete takes return false as a cancel (:4177) and ignores a throw, so a crashing plugin cannot block editing. The shape is invokeRoute's envelope (cloudflare/src/sandbox/wrapper.ts:276, runner.ts:369), and it needs one decision from you: routes pass a code and rebuild the message host-side, while a rejection has to carry the plugin's own text to an editor. Is plugin-authored text allowed across that boundary?

@danielmlr danielmlr changed the title feat(core): let content:beforeSave hooks reject a save with an editor-facing message fix(core): let content:beforeSave hooks reject a save with an editor-facing message Aug 25, 2026
danielmlr and others added 7 commits August 29, 2026 00:55
…-facing message

A content:beforeSave hook is documented to cancel a save by throwing, but
handleContentCreate and handleContentUpdate did not catch the abort-policy
rethrow. The exception escaped the content routes, which have no try/catch,
so a cancelled save surfaced as an unstructured 500 instead of the
normal API error envelope.

Add ContentSaveRejectedError, exported from the package root. A trusted
hook that throws it now produces { code: "SAVE_REJECTED", message } with
HTTP 422, and the admin's existing save and autosave toasts show the
message. Any other exception from the hook pipeline is logged and mapped
to a generic CONTENT_HOOK_ERROR response, so plugin internals stay out of
API responses. The rejection is matched by error name as well as by
prototype because bundlers can duplicate the class across SSR chunks.

Sandboxed beforeSave hooks are unchanged: their errors are still logged
and the save proceeds. Letting sandboxed plugins reject saves needs an
error envelope across the sandbox RPC boundary, which is a separate
decision.
If boot() throws in beforeEach, runtime is still undefined and the
unconditional stopCron() call reports a second teardown error on top of
the real one. Optional chaining matches the teardown in
media-usage-scheduled-driver.test.ts.
The route turns the runtime result into a response through
mapErrorStatus, which recognizes SAVE_REJECTED by the value of
ErrorCode.SAVE_REJECTED. A string literal in the runtime and that
constant can drift apart, and the response then falls to the default
400 instead of 422.

No test caught that, because the existing assertions compared the
runtime literal against a test literal. Both SAVE_REJECTED cases now
assert the mapped status as well. The reference table and the changeset
name 422 for this error, so the status is part of the contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The hooks page states that it covers sandboxed plugins, and
runSandboxedBeforeSave logs a thrown error and continues, so the rejection
contract never held there. Standard plugins run in an isolate on Cloudflare and
in-process elsewhere, which makes the promise true in local development and
false in production on Cloudflare.

The reference page and the plugin-authoring skill document definePlugin, the
standard format, and carried the same unqualified sentence.
runSandboxedBeforeSave logs a thrown error and continues, so the "throw to
cancel" contract never held for sandboxed plugins. The hooks page under
creating-plugins covers that format: the caution now says so, and the example
no longer throws to cancel a save it cannot cancel. ContentSaveRejectedError is
a runtime export of emdash while emdash/plugin carries types only, so a
sandboxed plugin cannot import it either.

2f2d141 narrowed two further documents on the assumption that definePlugin is
the sandboxed format. It is the native one: it requires id and version and runs
in the host process, so the rejection works there as written. The hook
reference and the plugin-authoring skill go back to the plain sentence, each
with one clause on the sandboxed case.
"A sandboxed plugin cannot cancel a save" is false for a sandboxed plugin
moved into plugins: [], which runs in the host process and takes the trusted
hook path. The constraint belongs to the sandbox, not to the plugin format.

Drop the throw from the skill reference's example: it sat directly under the
sentence saying a plugin in the sandbox cannot cancel, and creating-plugins is
read by sandboxed plugin authors.

Sync the template copies, which had not been updated with the earlier docs
commit on this branch.
The entry lands verbatim in the emdash CHANGELOG and carried the absolute
that 003597d narrowed everywhere else.
@danielmlr
danielmlr force-pushed the fix/before-save-rejection branch from 5bf0db8 to 0d6c5dd Compare August 28, 2026 23:00
@danielmlr

Copy link
Copy Markdown
Contributor Author

Rebased onto 1717d31b, conflict-free across the seven commits, so the branch is mergeable again. Verified at 0d6c5ddd: core 6171 passed / 9 skipped, the before-save integration test 5/5, typecheck, lint, format and build clean.

The rebase moved the emdash-runtime.ts lines I cited on 25 August. Re-anchored so the pointers still land:

:3000 → :2921   trusted create path, returns before the write
:3104 → :3025   trusted update path, same
:4159 → :4090   catch that swallows the sandboxed rejection
:4177 → :4108   `result === false` cancel in runSandboxedBeforeDelete

cloudflare/src/sandbox/wrapper.ts:276 and runner.ts:369 are unchanged.

Nothing in the argument moves with them. The open question is still the last one in that comment: whether plugin-authored text may cross the sandbox boundary, given that routes carry a code and rebuild the message host-side, while a rejection has to reach an editor in the plugin's own words.

A plugin developed with plugins: [] cancels a save by throwing; the same
plugin running in the sandbox has its throw swallowed by
runSandboxedBeforeSave, and the editor sees a successful save. The
generic "hook error" line did not say that the cancel was lost, so the
divergence between host and sandbox was silent. The log line now states
that a sandboxed plugin cannot cancel a save and that the save
continued.

Detecting the rejection in that catch is not possible: the workerd
wrapper turns a thrown error into a plain-text 500 body and the runner
rethrows a new Error around it, and Workers RPC drops own properties
such as the class-field name. Every throw from a sandboxed beforeSave
therefore gets the same line.
@danielmlr

Copy link
Copy Markdown
Contributor Author

The silent case is now loud: d6b9f7b3 makes the catch in runSandboxedBeforeSave log that a sandboxed plugin cannot cancel a save and that the save continues, with a runtime test against a fake sandbox runner; the changeset says the same and the body is updated. What you described stays true: no sandboxed hook cancels a save, on any runner.

The boundary question from my first comment is withdrawn: a throw cannot carry the rejection out either way. The workerd wrapper turns it into a plain-text 500 (packages/workerd/src/sandbox/wrapper.ts:443) that the runner rewraps as a new Error, and Workers RPC drops an error's own properties, ContentSaveRejectedError's class-field name included; the body has both paths with lines. So the sandbox cancel is a follow-up of a different shape: a returned sentinel, as runSandboxedBeforeDelete already reads return false, with the editor message from a host-side code table. No plugin text crosses the boundary.

One yes/no: merge this as the native-plugin half with that follow-up, or should I pull it until the sandbox path exists?

@danielmlr
danielmlr requested a review from ascorbic August 29, 2026 01:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants