Skip to content

Fix audit-log hooks skipped for missing capabilities and pass item id to content:beforeSave - #2897

Open
ascorbic wants to merge 2 commits into
mainfrom
claude/admiring-wing-483524
Open

Fix audit-log hooks skipped for missing capabilities and pass item id to content:beforeSave#2897
ascorbic wants to merge 2 commits into
mainfrom
claude/admiring-wing-483524

Conversation

@ascorbic

@ascorbic ascorbic commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

The audit-log manifest declared only content:read, but its sandbox entry registers content:beforeSave (gated on content:write by HookPipeline.HOOK_REQUIRED_CAPABILITY) and media:afterUpload (gated on media:read). Every site bundling the plugin, including the blog template, logged both [hooks] Plugin "audit-log" declares … hook without … capability — skipping lines on boot, media uploads were never audited, and update entries had no before/after diff.

Fix: declare the capabilities the hooks need (option 1)

The manifest now declares content:read, content:write and media:read, with a comment explaining why a plugin that never writes content still needs content:write.

Why not a read-only pre-save hook variant in core (option 2):

  • The pipeline's requirement matches the mechanism. A content:beforeSave handler's return value replaces the draft, so registering that hook is write access, regardless of what the handler does today. Declaring content:write keeps the trust contract honest about what the plugin can do on the write path.
  • media:read is not an over-grant: media:afterUpload hands the plugin media metadata, which is exactly what media:read gates.
  • A read-only variant only tightens least privilege for sandboxed plugins. In trusted mode a handler can mutate event.content in place whatever flag it declares, so a genuine read-only guarantee would mean cloning the draft for every such hook on every save. The change would touch HookConfig/ResolvedHook, definePlugin, adaptSandboxEntry, the manifest hook-entry schema and plugin-cli translation, the registry lexicons, admin plugin views and docs, plus a minor emdash release, for one first-party plugin, and older hosts would still silently skip such a hook. If other plugin authors hit this, it is a good Discussion topic.

The published manifest schema (packages/plugin-cli/schemas/emdash-plugin.schema.json) needs no change: capability entries are free-form strings there, and the CLI validates them against core's current list, which includes both names. emdash-plugin validate passes on the updated manifest.

Also: content:beforeSave receives the item id on updates

The capability fix alone did not restore the diff. On updates the runtime passes content:beforeSave only the submitted field values (body.data), which never contain id (a reserved field slug), so the plugin's event.content.id lookup could never populate its before-state cache. This PR adds an optional id to ContentHookEvent, set for updates by both the trusted pipeline and the sandboxed path and resolved to the real ID when the caller updates by slug. The plugin reads event.id ?? event.content.id. The change is additive: on an older emdash the plugin behaves as it does today and records the update without the previous state. It is a separate commit with its own emdash changeset (minor) and docs updates so it can be reviewed or dropped on its own.

Tests

  • packages/core/tests/integration/plugins/audit-log-plugin.test.ts loads the real manifest and sandbox entry through adaptSandboxEntry and drives the runtime. Before the fix all three cases failed: the two hooks were not registered (skip warnings logged), the update entry had no changes.before, and no media entry was written.
  • packages/core/tests/integration/runtime/before-save-event-id.test.ts covers the new event field for trusted and sandboxed plugins on create (absent), update by id, and update by slug.

Verification

  • Booted templates/blog (astro dev) before and after. With the previous build both [hooks] … skipping lines printed on the first request; with the rebuilt core and plugin there is no [hooks] output while the runtime serves pages.
  • pnpm lint:json reports 0 diagnostics after a workspace build. pnpm --filter emdash typecheck and pnpm --filter @emdash-cms/plugin-audit-log typecheck pass. pnpm format has been run. Full core vitest run: 525 files passed, 6314 tests passed (2 files / 9 tests skipped, pre-existing).

Follow-up worth a Discussion, not included here: have emdash-plugin build warn when a declared hook lacks its required capability, so this class of mismatch fails at build time instead of at every boot.

Type of change

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

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main. — n/a, no admin UI strings changed
  • I have added and reviewed the user-facing changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/... — n/a, bug fix
  • I have included screenshots below if this PR changes the UI — n/a, no UI change

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Fable 5.1 (Claude Code)

Screenshots / test output

Not applicable (no UI change).

On an update the runtime hands content:beforeSave only the submitted
field values, which never include the item id, so a hook cannot look up
the stored item it is about to change. Set an optional `id` on the event
for updates, in both the trusted pipeline and the sandboxed path,
resolved to the real id when the caller updates by slug. Creates leave
it unset.
The manifest declared only content:read while the sandbox entry
registers content:beforeSave and media:afterUpload, which the hook
pipeline gates on content:write and media:read. Every boot logged a
skip warning for each, media uploads were never audited, and update
entries had no before/after diff.

Declare content:write and media:read. Registering a beforeSave hook is
write access whatever the handler does, so the declaration describes
the plugin's real power on the write path; media:read is exactly what
the media event exposes. Read the item id from `event.id` so the
before-state cache is populated on updates.
@changeset-bot

changeset-bot Bot commented Sep 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ee5a304

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

This PR includes changesets to release 18 packages
Name Type
@emdash-cms/plugin-audit-log Patch
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
create-emdash Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/release-service 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

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 3, 2026

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://claude-admiring-wing-483524.try.emdashcms.com, https://claude-admiring-wing-483524-emdash-playground.emdash-cms.workers.dev (commit ee5a304)

This URL reflects your latest Preview deployment

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://c0dc527c.try.emdashcms.com, https://c0dc527c-emdash-playground.emdash-cms.workers.dev ee5a304 2026-09-03T16:36:46.180Z Visit the dashboard ↗

@pkg-pr-new

pkg-pr-new Bot commented Sep 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

npm i https://pkg.pr.new/emdash@2897

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-moderation

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

@emdash-cms/registry-verification

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: ee5a304

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 3, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
docs ee5a304 Sep 03 2026, 04:35 PM

@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 PR is the right fix for the right problem: the audit-log manifest was under-declared relative to the hooks its sandbox entry actually registers, and the runtime didn’t give content:beforeSave handlers the existing item ID on updates, so the plugin never had enough information to record the before-state. The chosen approach—declare the capabilities the pipeline already requires and add an optional id to the update event—is additive, honest about the trust contract, and avoids a much larger core refactor.

I checked the diff and the full files, tracing how the event field moves through the trusted pipeline (HookPipeline.runContentBeforeSave), the manager delegator, and the sandboxed path (EmDashRuntime.runSandboxedBeforeSave), and confirmed both:

  • handleContentUpdate resolves slug→ID once and passes resolvedItem?.id.
  • That ID is only attached to the event on updates; the create path never sets it.

The new tests cover the regression: the audit-log test fails before the manifest and event-ID fixes, and the event-ID test covers trusted and sandboxed updates. The two changesets are user-facing and note the upgrade implication (marketplace capability approval) clearly.

Only one small coverage gap stood out.

});
});
});
});

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.

[suggestion] The sandboxed plugin block tests update-by-id but not update-by-slug, while the trusted block tests both. Since handleContentUpdate resolves the slug to the real item id before invoking the sandboxed hook, adding a slug case here would protect the two paths from diverging in future refactors.

it("passes the resolved item id when updating by slug", async () => {
	const item = await repo.create({
		type: "post",
		slug: "hello-world",
		data: { title: "Original" },
	});

	const result = await runtime.handleContentUpdate("post", "hello-world", {
		data: { title: "Changed" },
	});

	expect(result.success).toBe(true);
	expect(invokeHook).toHaveBeenCalledWith("content:beforeSave", {
		content: { title: "Changed" },
		collection: "post",
		isNew: false,
		id: item.id,
	});
});

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

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.

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.

1 participant