fix(core): let content:beforeSave hooks reject a save with an editor-facing message - #2617
fix(core): let content:beforeSave hooks reject a save with an editor-facing message#2617danielmlr wants to merge 8 commits into
Conversation
🦋 Changeset detectedLatest commit: d6b9f7b The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 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 |
@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.
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:
beforeSaveFailureuses raw string codes instead of theErrorCodeconstants the codebase provides. It's a convention drift in the handler layer and worth fixing while the code is new.- The new integration test's
afterEachcan crash with a secondary error ifboot()fails, becauseruntimeis stillundefined. A small guard makes failures easier to read.
Documentation and changeset are accurate and proportionate.
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
ascorbic
left a comment
There was a problem hiding this comment.
I think this won't prevent the save: it just logs the error in runSandboxedBeforeSave and continues.
|
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 The trusted path does cancel the save: What that leaves is the part worth your judgement. Standard is the default format, and wherever a runner is configured — Otherwise I would do that path as a follow-up. Rethrowing in |
…-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.
5bf0db8 to
0d6c5dd
Compare
|
Rebased onto The rebase moved the
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.
|
The silent case is now loud: 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 ( One yes/no: merge this as the native-plugin half with that follow-up, or should I pull it until the sandbox path exists? |
What does this PR do?
A
content:beforeSavehook 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 defaulterrorPolicy: "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 genericCONTENT_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.shcarries 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:
handleContentPublishnever calls the hook at all. The changeset staysminor: the fix adds one export.Details
Sandboxed hooks are out of scope, and the runtime now says so where it matters. When a sandboxed
beforeSavehook throws,runSandboxedBeforeSavelogs 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 underplugins: [](the host process, documented inchoosing-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 newErroraround that text (packages/workerd/src/sandbox/runner.ts:965-967); on Cloudflare, Workers RPC keeps an error's message and prototypenamebut drops own properties, andContentSaveRejectedErrorsetsnameas a class field. In both cases the catch sees a plainError. A sandboxed cancel is a follow-up: a returned sentinel, the wayrunSandboxedBeforeDeletealready treatsreturn false, with the editor message from a host-side code table rather than plugin text.isContentSaveRejectionaccepts the error by name as well as by prototype. Bundlers can duplicate the module across SSR chunks, and aninstanceofagainst the wrong copy would misreport a rejection as a crash.beforeSaveFailuretakes both codes fromErrorCode, becausemapErrorStatusmatches on the value ofErrorCode.SAVE_REJECTEDand a literal at the throw site would drift from it and fall to a default 400. The twelve other literal codes inemdash-runtime.tsstay 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
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain. (n/a: no admin changes)AI-generated code disclosure
Screenshots / test output