Skip to content

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

Merged
ascorbic merged 9 commits into
emdash-cms:mainfrom
danielmlr:fix/before-save-rejection
Sep 1, 2026
Merged

ascorbic merged 9 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?

Lets a content:beforeSave hook cancel a save with a message the editor sees, but only for a plugin running in the host process. A sandboxed plugin cannot cancel. A throw reaches the host only as a plain Error, so a rejection is indistinguishable from a crash; the save proceeds and the runtime logs why. Sandboxed is the format the plugin docs tell authors to default to, so this ships the capability for the other one. Closing that gap needs a different mechanism: a returned sentinel, the way content:beforeDelete already reads return false. That is a follow-up, and whether the host-process half is worth merging without it is unresolved.

Until now a cancelled save reached the client as an unstructured 500: the hook pipeline rethrows on the default errorPolicy: "abort", and neither the runtime handlers nor the content routes caught it. Throwing ContentSaveRejectedError, exported from the package root, now 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 is logged and mapped to a generic CONTENT_HOOK_ERROR, keeping hook internals out of API responses. The REST reference error table and the generated OpenAPI document both carry that 422 on content create and update.

The hooks docs and the plugin-authoring skill state the sandbox limitation; scripts/sync-template-skills.sh copies the skill into nine templates, which is why ten near-identical files change. Integration tests cover create, update and the sandboxed case.

Part of #2197 — the host-process half only, so it does not close it. The publish path @virafb reported on 2026-08-27 is untouched: handleContentPublish never calls the hook.

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, 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/tests/unit/api/openapi.test.ts
  — content create and update each carry a 422 response described "Unprocessable Entity"
  with the openapi/document.ts change reverted: 1 of 1 fails

packages/core  vitest run                6173 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: 0410b36

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: 0410b36

@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
@danielmlr
danielmlr marked this pull request as draft August 31, 2026 02:57
@danielmlr
danielmlr marked this pull request as ready for review August 31, 2026 02:57

@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.

This is the right fix for the trusted-hook half of #2197: it introduces a dedicated ContentSaveRejectedError, catches it at the runtime's save entry points, maps it to a structured SAVE_REJECTED (422) envelope, and keeps unexpected hook crashes behind CONTENT_HOOK_ERROR (500). The new integration tests cover create, update, and the sandboxed case, and the docs/changeset explain the sandbox limitation accurately.

Since the last review, the two suggestions have been addressed: beforeSaveFailure now uses the ErrorCode constants instead of literal strings, and the new integration test's afterEach guards against runtime being undefined.

I did find one remaining gap: the OpenAPI generator for the content create/update endpoints still lists only 400/500 (and 404/409 for update). Because beforeSaveFailure can now return SAVE_REJECTED, which mapErrorStatus maps to 422, both endpoints can return a 422 response that isn't reflected in the machine-readable spec. The hand-written REST reference table was updated; the generated OpenAPI document should be too.

No logic bugs, security issues, or query-count regressions on logged-out routes.


Findings

  • [needs fixing] packages/core/src/api/openapi/document.ts:258

    The content create endpoint can now return 422 SAVE_REJECTED when a trusted content:beforeSave hook throws ContentSaveRejectedError, but the OpenAPI responses still only list 400 and 500. The REST reference table in docs/src/content/docs/reference/rest-api.mdx was updated to include SAVE_REJECTED at 422; the generated OpenAPI spec should match.

    				...standardErrors(400, 422, 500),
    
  • [needs fixing] packages/core/src/api/openapi/document.ts:316

    The content update endpoint can likewise return 422 SAVE_REJECTED after this change, but its OpenAPI responses omit 422. Add it alongside the existing 400/404/409/500 entries so the generated spec stays consistent with the implemented behavior.

    				...standardErrors(400, 404, 409, 422, 500),
    

The content create and update operations can return SAVE_REJECTED, which
mapErrorStatus maps to 422, but their OpenAPI responses listed only
400/500 and 400/404/409/500. A client generated from the spec had no 422
case for either endpoint, while the hand-written REST reference table
already documents one.

standardErrors carries no 422 in its description map, so adding the code
by itself would have emitted "Error 422" as the description.
@danielmlr
danielmlr marked this pull request as draft August 31, 2026 12:55
@danielmlr
danielmlr marked this pull request as ready for review August 31, 2026 12:55

@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.

This re-review confirms the two needs_fixing items from the previous pass are resolved: packages/core/src/api/openapi/document.ts now lists 422 on both content create and update, and beforeSaveFailure uses the ErrorCode constants instead of string literals. The new integration test also guards afterEach against an undefined runtime.

The approach remains the right fix for the trusted-hook half of #2197. ContentSaveRejectedError is caught at the runtime's save entry points, mapped to a structured SAVE_REJECTED envelope with HTTP 422, and unexpected hook exceptions are masked as CONTENT_HOOK_ERROR (500) so internals don't leak. The sandbox limitation is documented accurately and the runtime now logs it explicitly without canceling the save.

I checked the full diff and conventions:

  • Error responses follow the ApiResult / unwrapResult / mapErrorStatus shape.
  • No new queries on logged-out routes.
  • No SQL, auth, locale-filter, or index issues.
  • The changeset is user-facing, proportional, and documents both behavior and the sandbox limitation.
  • The admin client path (lib/api/client.tsApiResponseError) already surfaces error.message in router toasts, so the plugin rejection message reaches the editor as claimed.

One non-blocking docs polish remains: the content:beforeSave prose tells plugin authors to throw ContentSaveRejectedError, but the accompanying example only demonstrates a slug transform. Adding a rejection branch would make the page demonstrate the capability it just described.


Findings

  • [suggestion] docs/src/content/docs/plugins/creating-plugins/hooks.mdx:148-156

    The prose directly above the example tells plugin authors to throw ContentSaveRejectedError to cancel a save, but the code block only demonstrates transforming a slug. Without a rejection branch, the page describes a new capability and then doesn't show readers how to use it. Consider adding a concrete rejection branch to the example so the prose and code agree.

    "content:beforeSave": async (event, ctx) => {
    	const { content, isNew } = event;
    
    	if (typeof content.slug === "string") {
    		content.slug = content.slug.toLowerCase().replace(/\s+/g, "-");
    	}
    
    	if (isNew && !content.title) {
    		throw new ContentSaveRejectedError("A post title is required.");
    	}
    
    	return content;
    },
    

    If you update this example, remember to re-run scripts/sync-template-skills.sh so the nine template copies stay in sync.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview Author pushed changes since the last review labels Aug 31, 2026

@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.

Thanks!

@ascorbic
ascorbic merged commit 1c9fb43 into emdash-cms:main Sep 1, 2026
54 checks passed
@emdashbot emdashbot Bot mentioned this pull request Sep 1, 2026
@danielmlr
danielmlr deleted the fix/before-save-rejection branch September 1, 2026 21:53
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