Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/before-save-rejection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": minor
---

Adds `ContentSaveRejectedError` so a `content:beforeSave` hook can reject a save with a message for the editor. Throwing it from a trusted plugin makes the content API respond with a structured `SAVE_REJECTED` error (HTTP 422) that carries the message, and the admin shows it in the save and autosave toasts. Any other exception thrown by the hook still cancels the save, but now returns a generic `CONTENT_HOOK_ERROR` response instead of escaping as an unstructured 500 that could leak exception details. A plugin running in the sandbox cannot reject a save yet; the save proceeds as before, and the sandbox log states that a sandboxed plugin cannot cancel a save. Moving the plugin into `plugins: []` runs it in the host process, where rejection works.
14 changes: 8 additions & 6 deletions docs/src/content/docs/plugins/creating-plugins/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,17 @@ Run during create, update, and delete operations on site content.

### `content:beforeSave`

Runs before content is saved. Return modified content, or `void` to leave it unchanged. Throw to cancel.
Runs before content is saved. Return modified content, or `void` to leave it unchanged.

<Aside type="caution">
A plugin running in the sandbox cannot cancel a save: the sandbox logs the thrown error and the save continues. Cancelling works in the host process — in a native plugin, or in a sandboxed plugin [moved to `plugins: []`](/plugins/creating-plugins/choosing-a-format/#sandbox-runners-and-platform-support).
</Aside>

To cancel the save from a plugin running in the host process, throw `ContentSaveRejectedError` (exported from `emdash`): the API responds with a `SAVE_REJECTED` error that carries your message, and the admin shows it to the editor. Any other thrown error also cancels the save, but the response replaces its message with a generic one.

```typescript
"content:beforeSave": async (event, ctx) => {
const { content, collection } = event;

if (collection === "posts" && !content.title) {
throw new Error("Posts require a title");
}
const { content } = event;

if (typeof content.slug === "string") {
content.slug = content.slug.toLowerCase().replace(/\s+/g, "-");
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ The following table lists every hook, what triggers it, what it can modify, and

### `content:beforeSave`

Runs before content is saved to the database. Use to validate, transform, or enrich content.
Runs before content is saved to the database. Use to validate, transform, or enrich content. To reject a save, throw `ContentSaveRejectedError` (exported from `emdash`): the API responds with a `SAVE_REJECTED` error that carries the message, and the admin shows it to the editor. Any other thrown error also cancels the save, but the response replaces its message with a generic one. A plugin running in the sandbox cannot reject a save: the sandbox logs the thrown error and the save continues.

```ts
import { definePlugin } from "emdash";
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/reference/rest-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1135,6 +1135,8 @@ POST /_emdash/api/admin/plugins/:id/disable
| `CONTENT_LIST_ERROR` | 500 | Failed to list content |
| `CONTENT_CREATE_ERROR` | 500 | Failed to create content |
| `CONTENT_UPDATE_ERROR` | 500 | Failed to update content |
| `SAVE_REJECTED` | 422 | Save rejected by a plugin hook |
| `CONTENT_HOOK_ERROR` | 500 | Plugin hook failed during save |
| `CONTENT_DELETE_ERROR` | 500 | Failed to delete content |
| `MEDIA_LIST_ERROR` | 500 | Failed to list media |
| `MEDIA_CREATE_ERROR` | 500 | Failed to create media |
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export const ErrorCode = {
// Content
CONTENT_CREATE_ERROR: "CONTENT_CREATE_ERROR",
CONTENT_UPDATE_ERROR: "CONTENT_UPDATE_ERROR",
SAVE_REJECTED: "SAVE_REJECTED",
CONTENT_HOOK_ERROR: "CONTENT_HOOK_ERROR",
CONTENT_DELETE_ERROR: "CONTENT_DELETE_ERROR",
CONTENT_LIST_ERROR: "CONTENT_LIST_ERROR",
CONTENT_GET_ERROR: "CONTENT_GET_ERROR",
Expand Down Expand Up @@ -485,6 +487,7 @@ export function mapErrorStatus(code: string | undefined): number {
return 410;

// 422 Unprocessable Entity
case ErrorCode.SAVE_REJECTED:
case ErrorCode.CHECKSUM_MISMATCH:
case ErrorCode.INVALID_BUNDLE:
case ErrorCode.BUNDLE_EXTRACT_FAILED:
Expand Down
53 changes: 44 additions & 9 deletions packages/core/src/emdash-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Kysely, type Dialect } from "kysely";
import virtualConfig from "virtual:emdash/config";
import { z } from "zod";

import { ErrorCode } from "./api/errors.js";
import { assertMediaUsageActivationWriteAllowed } from "./api/media-usage-write-fence.js";
import { validateRev } from "./api/rev.js";
import type {
Expand Down Expand Up @@ -216,6 +217,7 @@ import {
type RouteCallerInput,
type RouteMeta,
} from "./plugins/routes.js";
import { isContentSaveRejection } from "./plugins/save-rejection.js";
import type { CronScheduler } from "./plugins/scheduler/types.js";
import { PluginStateRepository } from "./plugins/state.js";
import { syncDeclaredStorageIndexes } from "./plugins/storage-indexes.js";
Expand Down Expand Up @@ -423,6 +425,28 @@ export interface EmDashRuntimeParts {
pipelineRef: { current: HookPipeline };
}

/**
* A `ContentSaveRejectedError` carries a message the plugin wrote for the
* editor; every other exception stays internal and is replaced by a generic
* message so hook internals cannot leak through the API.
*/
function beforeSaveFailure(error: unknown) {
if (isContentSaveRejection(error)) {
return {
success: false as const,
error: { code: ErrorCode.SAVE_REJECTED, message: error.message },
};
}
console.error("EmDash: content:beforeSave hook failed:", error);
return {
success: false as const,
error: {
code: ErrorCode.CONTENT_HOOK_ERROR,
message: "A plugin hook failed while saving content",
},
};
}

/**
* Convert a ContentItem to Record<string, unknown> for hook consumption.
* Hooks receive the full item as a flat record.
Expand Down Expand Up @@ -2890,8 +2914,12 @@ export class EmDashRuntime {
// Run beforeSave hooks (trusted plugins)
let processedData = body.data;
if (this.hooks.hasHooks("content:beforeSave")) {
const hookResult = await this.hooks.runContentBeforeSave(body.data, collection, true);
processedData = hookResult.content;
try {
const hookResult = await this.hooks.runContentBeforeSave(body.data, collection, true);
processedData = hookResult.content;
} catch (error) {
return beforeSaveFailure(error);
}
}

// Run beforeSave hooks (sandboxed plugins)
Expand Down Expand Up @@ -2986,12 +3014,16 @@ export class EmDashRuntime {
let processedData = bodyWithoutRev.data;
if (bodyWithoutRev.data) {
if (this.hooks.hasHooks("content:beforeSave")) {
const hookResult = await this.hooks.runContentBeforeSave(
bodyWithoutRev.data,
collection,
false,
);
processedData = hookResult.content;
try {
const hookResult = await this.hooks.runContentBeforeSave(
bodyWithoutRev.data,
collection,
false,
);
processedData = hookResult.content;
} catch (error) {
return beforeSaveFailure(error);
}
}

// Run sandboxed beforeSave hooks
Expand Down Expand Up @@ -4056,7 +4088,10 @@ export class EmDashRuntime {
result = record;
}
} catch (error) {
console.error(`EmDash: Sandboxed plugin ${id} beforeSave hook error:`, error);
console.error(
`EmDash: Sandboxed plugin ${id} beforeSave hook threw; a sandboxed plugin cannot cancel a save, so the save continues:`,
error,
);
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,8 @@ export {
PluginManager,
createPluginManager,
PluginRouteError,
ContentSaveRejectedError,
isContentSaveRejection,
// Scheduler (Node timer heartbeat — used by virtual:emdash/scheduler)
NodeCronScheduler,
// Sandbox
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type { PluginContextFactoryOptions } from "./context.js";
// Hooks
export { HookPipeline, createHookPipeline } from "./hooks.js";
export type { HookResult } from "./hooks.js";
export { ContentSaveRejectedError, isContentSaveRejection } from "./save-rejection.js";

// Email pipeline
export { EmailPipeline, EmailNotConfiguredError, EmailRecursionError } from "./email.js";
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/plugins/save-rejection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Thrown by a `content:beforeSave` hook to reject a save with a message
* that is safe to show to the editor. The runtime converts it into a
* structured `SAVE_REJECTED` API error; any other exception thrown by the
* hook cancels the save with a generic error that hides the exception
* message.
*/
export class ContentSaveRejectedError extends Error {
override readonly name = "ContentSaveRejectedError";
}

/**
* Matches by name as well as by prototype: bundlers can duplicate this
* module across SSR chunks, and an `instanceof` against the wrong copy of
* the class would misreport a rejection as a plugin crash.
*/
export function isContentSaveRejection(error: unknown): error is ContentSaveRejectedError {
if (error instanceof ContentSaveRejectedError) return true;
return error instanceof Error && error.name === "ContentSaveRejectedError";
}
148 changes: 148 additions & 0 deletions packages/core/tests/integration/runtime/before-save-rejection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { randomUUID } from "node:crypto";

import Database from "better-sqlite3";
import { SqliteDialect } from "kysely";
import { describe, it, expect, beforeEach, afterEach } from "vitest";

import { mapErrorStatus } from "../../../src/api/errors.js";
import { ContentRepository } from "../../../src/database/repositories/content.js";
import { EmDashRuntime } from "../../../src/emdash-runtime.js";
import type { RuntimeDependencies } from "../../../src/emdash-runtime.js";
import { definePlugin } from "../../../src/plugins/define-plugin.js";
import { ContentSaveRejectedError } from "../../../src/plugins/save-rejection.js";
import type { ContentBeforeSaveHandler } from "../../../src/plugins/types.js";
import { SchemaRegistry } from "../../../src/schema/registry.js";

function createDeps(
sqlite: Database.Database,
handler: ContentBeforeSaveHandler,
): RuntimeDependencies {
return {
config: {
database: {
entrypoint: `test-before-save-rejection-${randomUUID()}`,
config: {},
type: "sqlite",
},
},
plugins: [
definePlugin({
id: "editorial-gate",
version: "1.0.0",
capabilities: ["content:read", "content:write"],
hooks: {
"content:beforeSave": { handler },
},
}),
],
createDialect: () => new SqliteDialect({ database: sqlite }),
createStorage: null,
sandboxEnabled: false,
sandboxedPluginEntries: [],
createSandboxRunner: null,
};
}

describe("content:beforeSave cancellation", () => {
let runtime: EmDashRuntime;
let repo: ContentRepository;

async function boot(handler: ContentBeforeSaveHandler) {
const sqlite = new Database(":memory:");
runtime = await EmDashRuntime.create(createDeps(sqlite, handler));
const registry = new SchemaRegistry(runtime.db);
await registry.createCollection({ slug: "post", label: "Posts", labelSingular: "Post" });
await registry.createField("post", { slug: "title", label: "Title", type: "string" });
repo = new ContentRepository(runtime.db);
}

afterEach(async () => {
await runtime?.stopCron();
});

describe("ContentSaveRejectedError", () => {
beforeEach(() =>
boot(async () => {
throw new ContentSaveRejectedError("Posts need a summary");
}),
);

it("returns SAVE_REJECTED with the plugin message on create", async () => {
const result = await runtime.handleContentCreate("post", { data: { title: "Hi" } });

expect(result).toEqual({
success: false,
error: { code: "SAVE_REJECTED", message: "Posts need a summary" },
});
if (result.success) return;
expect(mapErrorStatus(result.error.code)).toBe(422);
const rows = await repo.findMany("post");
expect(rows.items).toHaveLength(0);
});

it("returns SAVE_REJECTED with the plugin message on update", async () => {
const item = await repo.create({ type: "post", data: { title: "Original" } });

const result = await runtime.handleContentUpdate("post", item.id, {
data: { title: "Changed" },
});

expect(result).toEqual({
success: false,
error: { code: "SAVE_REJECTED", message: "Posts need a summary" },
});
if (result.success) return;
expect(mapErrorStatus(result.error.code)).toBe(422);
const kept = await repo.findById("post", item.id);
expect(kept?.data.title).toBe("Original");
});
});

describe("unexpected hook exception", () => {
beforeEach(() =>
boot(async () => {
throw new Error("secret internal detail");
}),
);

it("returns a generic error that hides the exception message on create", async () => {
const result = await runtime.handleContentCreate("post", { data: { title: "Hi" } });

expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("CONTENT_HOOK_ERROR");
expect(result.error.message).not.toContain("secret internal detail");
});

it("returns a generic error that hides the exception message on update", async () => {
const item = await repo.create({ type: "post", data: { title: "Original" } });

const result = await runtime.handleContentUpdate("post", item.id, {
data: { title: "Changed" },
});

expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("CONTENT_HOOK_ERROR");
expect(result.error.message).not.toContain("secret internal detail");
const kept = await repo.findById("post", item.id);
expect(kept?.data.title).toBe("Original");
});
});

describe("hook that does not throw", () => {
beforeEach(() =>
boot(async (event) => {
return { ...event.content, title: `${event.content.title as string}!` };
}),
);

it("still applies the hook's content changes", async () => {
const result = await runtime.handleContentCreate("post", { data: { title: "Hi" } });

expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.item.data.title).toBe("Hi!");
});
});
});
Loading
Loading