Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/attribution-2881.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"emdash": patch
"@emdash-cms/plugin-audit-log": patch
---

Fixes content attribution for authenticated REST, visual editing, and MCP saves.

- Revisions record the acting user without changing the entry owner. MCP updates preserve the existing owner, and actorless internal writes leave revision attribution unset instead of inferring it from ownership.
- `content:beforeSave` and `content:afterSave` receive an actor snapshot with the authenticated user's `id` and `role`. The snapshot is isolated between hooks so one plugin cannot change the attribution seen by another.
- The audit-log plugin stores the actor ID as `userId` on content create and update entries.
8 changes: 5 additions & 3 deletions docs/src/content/docs/plugins/creating-plugins/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ Do not put HTML in `reason`. The admin renders the value as text.

From the host process, throw `ContentSaveRejectedError` (exported from `emdash`) instead. The API returns `SAVE_REJECTED` with your message. Any other exception from either execution mode fails the save with a generic `CONTENT_HOOK_ERROR` response.

**Event:** `{ content, collection, isNew, id }` — **Returns:** modified content, a sandbox hook error result, or `void`. On an update, `id` is the ID of the existing item and `content` holds only the submitted field values; load the stored item with `ctx.content.get(event.collection, event.id)`.
**Event:** `{ content, collection, isNew, id, actor }` — **Returns:** modified content, a sandbox hook error result, or `void`. On an update, `id` is the ID of the existing item and `content` holds only the submitted field values; load the stored item with `ctx.content.get(event.collection, event.id)`. Authenticated REST, visual editing, and MCP saves include `actor.id` and the numeric `actor.role`. Internal writes without an authenticated user omit `actor`.

### `content:afterSave`

Expand All @@ -196,7 +196,9 @@ Runs after content is successfully saved. Use for side effects like notification
```typescript
"content:afterSave": async (event, ctx) => {
const contentId = String(event.content.id);
ctx.log.info(`${event.isNew ? "Created" : "Updated"} ${event.collection}/${contentId}`);
ctx.log.info(`${event.isNew ? "Created" : "Updated"} ${event.collection}/${contentId}`, {
actorId: event.actor?.id,
});

if (ctx.http) {
await ctx.http.fetch("https://api.example.com/webhook", {
Expand All @@ -207,7 +209,7 @@ Runs after content is successfully saved. Use for side effects like notification
},
```

**Event:** `{ content, collection, isNew }` — **Returns:** `Promise<void>`
**Event:** `{ content, collection, isNew, actor }` — **Returns:** `Promise<void>`. Authenticated saves include the same optional `actor` snapshot as `content:beforeSave`.

### `content:beforeDelete`

Expand Down
10 changes: 8 additions & 2 deletions docs/src/content/docs/reference/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,21 @@ export default definePlugin({
#### Event

```ts
interface ActorInfo {
readonly id: string;
readonly role: number;
}

interface ContentHookEvent {
content: Record<string, unknown>; // Content data
collection: string; // Collection slug
isNew: boolean; // True for creates, false for updates
id?: string; // ID of the existing item on updates; absent on creates
actor?: ActorInfo; // Authenticated user that initiated the save
}
```

On an update, `content` holds only the submitted field values. Load the stored item with `ctx.content.get(event.collection, event.id)` when the hook needs to compare against it.
On an update, `content` holds only the submitted field values. Load the stored item with `ctx.content.get(event.collection, event.id)` when the hook needs to compare against it. Authenticated REST, visual editing, and MCP saves include `actor`. Internal writes without an authenticated user omit it.

#### Return value

Expand Down Expand Up @@ -130,7 +136,7 @@ hooks: {

#### Event

`content:afterSave` receives `content`, `collection`, and `isNew`. `content` is the complete saved entry, with its database ID in `content.id` and collection fields under `content.data`. The separate optional `id` used by `content:beforeSave` updates is absent after the save.
`content:afterSave` receives `content`, `collection`, `isNew`, and the optional authenticated `actor`. `content` is the complete saved entry, with its database ID in `content.id` and collection fields under `content.data`. The separate optional `id` used by `content:beforeSave` updates is absent after the save.

#### Return value

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,14 @@ export const PUT: APIRoute = async ({ params, request, locals, cache }) => {
? body
: { ...body, authorId: undefined };

const actor = user ? { id: user.id, role: user.role } : undefined;

// Pass _rev through for optimistic concurrency validation
const result = await emdash.handleContentUpdate(collection, resolvedId, {
...writeBody,
locale,
_rev: body._rev,
actor,
});

if (!result.success) return unwrapResult(result);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,15 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
);
}

const actor = user ? { id: user.id, role: user.role } : undefined;

// Auto-set authorId to current user when creating content
const result = await emdash.handleContentCreate(collection, {
...body,
authorId: user?.id,
locale: body.locale,
translationOf: body.translationOf,
actor,
});

if (!result.success) return unwrapResult(result);
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/astro/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ export interface EmDashHandlers {
taxonomies?: Record<string, string[]>;
createdAt?: string | null;
publishedAt?: string | null;
actor?: { id: string; role: number };
},
) => Promise<HandlerResponse>;

Expand All @@ -321,6 +322,7 @@ export interface EmDashHandlers {
taxonomies?: Record<string, string[]>;
publishedAt?: string | null;
_rev?: string;
actor?: { id: string; role: number };
},
) => Promise<HandlerResponse>;

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/database/repositories/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -973,7 +973,7 @@ export class ContentRepository {
collection: type,
entryId: id,
data: mergedData,
...(input.authorId ? { authorId: input.authorId } : {}),
...(input.revisionAuthorId ? { authorId: input.revisionAuthorId } : {}),
});

let staged: boolean;
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/database/repositories/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ export interface UpdateContentInput {
slug?: string | null;
publishedAt?: string | null;
scheduledAt?: string | null;
/** Entry owner (`ec_{collection}.author_id`). */
authorId?: string | null;
/** Revision author, separate from entry ownership. */
revisionAuthorId?: string | null;
primaryBylineId?: string | null;
}

Expand Down
48 changes: 39 additions & 9 deletions packages/core/src/emdash-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import type {
SandboxRunnerFactory,
} from "./plugins/sandbox/types.js";
import type {
ActorInfo,
ContentHookEvent,
ResolvedPlugin,
MediaItem,
Expand Down Expand Up @@ -233,7 +234,7 @@ import { publishDueContent, type PublishedRef } from "./scheduled-publish.js";
import { FTSManager } from "./search/fts-manager.js";
import { invalidateSiteSettingsCache } from "./settings/index.js";

const DRAFT_ONLY_UPDATE_KEYS = new Set(["data", "slug", "locale", "skipRevision"]);
const DRAFT_ONLY_UPDATE_KEYS = new Set(["data", "slug", "locale", "skipRevision", "actor"]);
const MAX_DRAFT_STAGE_ATTEMPTS = 32;

/**
Expand Down Expand Up @@ -2798,21 +2799,36 @@ export class EmDashRuntime {
locale?: string;
translationOf?: string;
taxonomies?: Record<string, string[]>;
actor?: ActorInfo;
},
) {
const actor = body.actor ? { ...body.actor } : undefined;

// Run beforeSave hooks (trusted plugins)
let processedData = body.data;
if (this.hooks.hasHooks("content:beforeSave")) {
try {
const hookResult = await this.hooks.runContentBeforeSave(body.data, collection, true);
const hookResult = await this.hooks.runContentBeforeSave(
body.data,
collection,
true,
undefined,
actor,
);
processedData = hookResult.content;
} catch (error) {
return beforeSaveFailure(error);
}
}

// Run beforeSave hooks (sandboxed plugins)
const sandboxResult = await this.runSandboxedBeforeSave(processedData, collection, true);
const sandboxResult = await this.runSandboxedBeforeSave(
processedData,
collection,
true,
undefined,
actor,
);
if (!sandboxResult.success) return sandboxResult;
processedData = sandboxResult.data;

Expand Down Expand Up @@ -2846,7 +2862,7 @@ export class EmDashRuntime {

// Run afterSave hooks (fire-and-forget)
if (result.success && result.data) {
this.runAfterSaveHooks(contentItemToRecord(result.data.item), collection, true);
this.runAfterSaveHooks(contentItemToRecord(result.data.item), collection, true, actor);
}

return result;
Expand Down Expand Up @@ -2874,8 +2890,15 @@ export class EmDashRuntime {
/** Replace the previous autosave revision after staging this save. */
skipRevision?: boolean;
_rev?: string;
/**
* Acting user for this save. Used for revision attribution and
* passed to content hooks; never changes entry ownership.
*/
actor?: ActorInfo;
},
) {
const actor = body.actor ? { ...body.actor } : undefined;

// Resolve slug → ID if needed (before any lookups)
const repo = new ContentRepository(this.db);
const resolvedItem = await repo.findByIdOrSlug(collection, id, body.locale);
Expand All @@ -2899,7 +2922,7 @@ export class EmDashRuntime {
};
}
}
const { _rev: _discardedRev, ...bodyWithoutRev } = body;
const { _rev: _discardedRev, actor: _discardedActor, ...bodyWithoutRev } = body;

// Run beforeSave hooks if data is provided
let processedData = bodyWithoutRev.data;
Expand All @@ -2911,6 +2934,7 @@ export class EmDashRuntime {
collection,
false,
resolvedItem?.id,
actor,
);
processedData = hookResult.content;
} catch (error) {
Expand All @@ -2924,6 +2948,7 @@ export class EmDashRuntime {
collection,
false,
resolvedItem?.id,
actor,
);
if (!sandboxResult.success) return sandboxResult;
processedData = sandboxResult.data;
Expand Down Expand Up @@ -2975,7 +3000,7 @@ export class EmDashRuntime {
collection,
entryId: resolvedId,
data: mergedData,
authorId: bodyWithoutRev.authorId ?? undefined,
authorId: actor?.id,
});

let staged: boolean;
Expand Down Expand Up @@ -3113,7 +3138,7 @@ export class EmDashRuntime {

// Run afterSave hooks (fire-and-forget)
if (hydrated.success && hydrated.data) {
this.runAfterSaveHooks(contentItemToRecord(hydrated.data.item), collection, false);
this.runAfterSaveHooks(contentItemToRecord(hydrated.data.item), collection, false, actor);
}

if (hydrated.success) {
Expand Down Expand Up @@ -3984,6 +4009,7 @@ export class EmDashRuntime {
collection: string,
isNew: boolean,
contentId?: string,
actor?: ActorInfo,
) {
let result = content;

Expand All @@ -3994,6 +4020,7 @@ export class EmDashRuntime {
try {
const event: ContentHookEvent = { content: result, collection, isNew };
if (contentId !== undefined) event.id = contentId;
if (actor !== undefined) event.actor = { ...actor };
const hookResult = await plugin.invokeHook("content:beforeSave", event);
const inspection = inspectSandboxHookResult(hookResult);
if (inspection.kind === "error") {
Expand Down Expand Up @@ -4064,12 +4091,13 @@ export class EmDashRuntime {
content: Record<string, unknown>,
collection: string,
isNew: boolean,
actor?: ActorInfo,
): void {
after(async () => {
// Trusted plugins
if (this.hooks.hasHooks("content:afterSave")) {
try {
await this.hooks.runContentAfterSave(content, collection, isNew);
await this.hooks.runContentAfterSave(content, collection, isNew, actor);
} catch (err) {
console.error("EmDash afterSave hook error:", err);
}
Expand All @@ -4084,7 +4112,9 @@ export class EmDashRuntime {
tasks.push(
(async () => {
try {
await plugin.invokeHook("content:afterSave", { content, collection, isNew });
const event: ContentHookEvent = { content, collection, isNew };
if (actor !== undefined) event.actor = { ...actor };
await plugin.invokeHook("content:afterSave", event);
} catch (err) {
console.error(`EmDash: Sandboxed plugin ${id} afterSave error:`, err);
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ export type {
HookName,
ResolvedHook,
ResolvedPluginHooks,
ActorInfo,
ContentHookEvent,
ContentDeleteEvent,
ContentPublishStateChangeEvent,
Expand Down
14 changes: 9 additions & 5 deletions packages/core/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -975,7 +975,8 @@ export function createMcpServer(
async (args, extra) => {
requireScope(extra, "content:write");
requireRole(extra, Role.CONTRIBUTOR);
const { emdash, userId } = getExtra(extra);
const { emdash, userId, userRole } = getExtra(extra);
const actor = { id: userId, role: userRole };

// Creating a translation requires edit permission on the source item
if (args.translationOf) {
Expand All @@ -993,7 +994,7 @@ export function createMcpServer(

// Publishing requires publish permission — create as draft then publish
if (args.status === "published") {
const user = { id: userId, role: getExtra(extra).userRole };
const user = { id: userId, role: userRole };
if (!hasPermission(user, "content:publish_own")) {
throw new EmDashAuthError(
"Insufficient permissions: publishing requires content:publish_own",
Expand All @@ -1008,6 +1009,7 @@ export function createMcpServer(
translationOf: args.translationOf,
bylines: args.bylines,
taxonomies: args.taxonomies,
actor,
});
if (!result.success) return unwrap(result);
const itemId = extractContentId(result.data);
Expand All @@ -1026,6 +1028,7 @@ export function createMcpServer(
translationOf: args.translationOf,
bylines: args.bylines,
taxonomies: args.taxonomies,
actor,
}),
);
},
Expand Down Expand Up @@ -1103,6 +1106,7 @@ export function createMcpServer(
requireScope(extra, "content:write");
requireRole(extra, Role.AUTHOR);
const { emdash, userId, userRole } = getExtra(extra);
const actor = { id: userId, role: userRole };

// Fetch item to check ownership
const existing = await emdash.handleContentGet(args.collection, args.id, args.locale);
Expand Down Expand Up @@ -1146,7 +1150,7 @@ export function createMcpServer(
const updateResult = await emdash.handleContentUpdate(args.collection, resolvedId, {
data,
slug: args.slug,
authorId: userId,
actor,
locale: args.locale,
seo: args.seo,
bylines: args.bylines,
Expand Down Expand Up @@ -1176,7 +1180,7 @@ export function createMcpServer(
const updateResult = await emdash.handleContentUpdate(args.collection, resolvedId, {
data,
slug: args.slug,
authorId: userId,
actor,
locale: args.locale,
seo: args.seo,
bylines: args.bylines,
Expand All @@ -1196,7 +1200,7 @@ export function createMcpServer(
await emdash.handleContentUpdate(args.collection, resolvedId, {
data,
slug: args.slug,
authorId: userId,
actor,
locale: args.locale,
seo: args.seo,
bylines: args.bylines,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/plugin-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import type { ZodType } from "zod";

import type { SandboxHookErrorEnvelope } from "./plugins/sandbox/hook-result.js";
import type {
ActorInfo,
CommentAfterCreateEvent,
CommentAfterCreateHandler,
CommentAfterModerateEvent,
Expand Down Expand Up @@ -272,6 +273,7 @@ export type { SandboxHookErrorEnvelope };
* portable `.d.mts`.
*/
export type {
ActorInfo,
CommentAfterCreateEvent,
CommentAfterModerateEvent,
CommentBeforeCreateEvent,
Expand Down
Loading
Loading