Skip to content

fix(admin): echo _rev on editor save to prevent silent draft overwrite (#2121) - #2126

Merged
ascorbic merged 3 commits into
emdash-cms:mainfrom
swissky:fix/draft-overwrite-2121
Sep 2, 2026
Merged

fix(admin): echo _rev on editor save to prevent silent draft overwrite (#2121)#2126
ascorbic merged 3 commits into
emdash-cms:mainfrom
swissky:fix/draft-overwrite-2121

Conversation

@swissky

@swissky swissky commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

The content API returns an _rev token on reads and honours it on writes (409 Conflict on a stale token), but the admin never echoed it back — every editor save and autosave was a blind write. When the editor loaded an entry inside the stale-read window and the user saved, the save silently repointed draft_revision_id at a new revision built from stale state, orphaning the newer draft with no warning.

This makes the editor round-trip _rev so the server-side validateRev check actually engages:

  • fetchContent/createContent/updateContent lift the envelope-level _rev onto the returned item (the server returns it next to item, not inside it).
  • ContentItem/UpdateContentInput carry the optional _rev field.
  • handleSave/handleAutosave echo rawItem._rev, so a save based on a stale read is rejected with 409 and the editor shows a clear error instead of losing work.

Autosave stays consistent: it updates the existing draft revision in place (no updated_at bump on the content table), and the post-save cache invalidation refetches a fresh _rev.

Closes #2121

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.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Cursor + Kimi K3

Screenshots / test output

packages/admin/tests/lib/content-rev.test.ts — 3 passing: fetchContent surfaces _rev, updateContent sends it, blind writes omit it.

emdash-cms#2121)

The content API returns `_rev` on reads and honours it on writes (409 on a
stale token), but the admin never echoed it back — every editor save was a
blind write that could silently replace a newer draft revision.

- Lift the envelope-level `_rev` onto the item in fetch/create/updateContent
- Add `_rev` to ContentItem/UpdateContentInput so it round-trips
- Echo rawItem._rev in handleSave/handleAutosave so the server rejects a
  stale save with 409 instead of overwriting a newer draft
@changeset-bot

changeset-bot Bot commented Jul 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9e741af

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

This PR includes changesets to release 17 packages
Name Type
@emdash-cms/admin Patch
emdash Patch
@emdash-cms/cloudflare Patch
@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/auth Patch
@emdash-cms/blocks Patch
create-emdash Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
@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

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet area/admin size/M labels Jul 18, 2026
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 18, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jul 18, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-moderation

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

@emdash-cms/registry-verification

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 9e741af

@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 change for the right problem. The server already supports optimistic-concurrency _rev tokens, and the admin was simply dropping them for content reads/writes. The fix is additive and backwards-compatible: fetchContent/createContent/updateContent now lift the envelope _rev onto the returned item, and the editor's main handleSave/handleAutosave echo it back. A regression test verifies the API-level round-trip, and the changeset is correctly scoped.

However, the fix is incomplete within ContentEditPage. Two other editor-side write paths — author changes and SEO changes — still call updateMutation without _rev, so they remain blind writes that can silently overwrite a newer draft revision. They should echo the same token. I also noted that the existing router tests exercise save/autosave/SEO/author actions but never assert the request body, so an accidental drop of _rev in the router handlers would not be caught.

Changeset, type changes, and test coverage for the API layer look good.


Findings

  • [needs fixing] packages/admin/src/router.tsx:1185-1207

    handleAuthorChange and handleSeoChange both send source: "auxiliary" writes without echoing rawItem?._rev. These are still blind writes, so changing an author or SEO field from the editor can silently overwrite a newer draft revision made by another session — the same data-loss bug in #2121. Include the token in both changes objects and add it to the dependency arrays so the callbacks are recreated when the token refreshes.

    	const handleAuthorChange = React.useCallback(
    		(authorId: string | null) => {
    			updateMutation.mutate({
    				targetId: id,
    				targetLocale: rawItem?.locale ?? activeLocale,
    				source: "auxiliary",
    				changes: { authorId, _rev: rawItem?._rev },
    			});
    		},
    		[activeLocale, id, rawItem?.locale, rawItem?._rev, updateMutation.mutate],
    	);
    
    	const handleSeoChange = React.useCallback(
    		(seo: ContentSeoInput) => {
    			updateMutation.mutate({
    				targetId: id,
    				targetLocale: rawItem?.locale ?? activeLocale,
    				source: "auxiliary",
    				changes: { seo, _rev: rawItem?._rev },
    			});
    		},
    		[activeLocale, id, rawItem?.locale, rawItem?._rev, updateMutation.mutate],
    	);
    
  • [suggestion] packages/admin/tests/router.test.tsx:517

    The existing router tests mock ContentEditor and exercise onSave, onAutosave, and onSeoChange, but they only assert UI feedback state, not the outgoing request body. Consider capturing the serialized PUT bodies and asserting that _rev is present for editor saves, autosaves, and auxiliary writes. The globalThis.fetch wrapper already used in ContentNewPage tests can collect these bodies, which would give the router-level regression coverage needed to prevent _rev from being dropped again.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 18, 2026

@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 makes the right fix to the right problem: the admin content editor now echoes the server's _rev optimistic-concurrency token back on save/autosave, engaging the existing validateRev check and turning silent draft overwrites into a 409 conflict. The approach is idiomatic and backwards-compatible (no _rev = blind write).

I read the diff, the full changed client code, the server content handler/runtime in packages/core, and the existing router.test.tsx coverage. The implementation is clean overall: the envelope-level _rev is lifted onto ContentItem, the mutation types carry it, handleSave/handleAutosave pass rawItem._rev, and the React dependency arrays include it. The changeset is user-facing and correctly scoped to @emdash-cms/admin.

Two gaps keep this from a clean approval:

  1. Auxiliary editor writes don't echo _rev. handleAuthorChange and handleSeoChange still send blind writes through updateMutation. They read from the same potentially-stale rawItem and update the same content row, so they should echo _rev too for consistent stale-write protection.

  2. The save/autosave _rev wiring lacks an integration test. The new content-rev.test.ts covers the API client functions, but if the { ...payload, _rev: rawItem?._rev } lines in router.tsx were removed the API-level tests would still pass. router.test.tsx already exercises save/autosave in ContentEditPage and should assert that the outgoing PUT body carries _rev.

I did not run the test suite, linter, or typechecker; those claims are unverified.


Findings

  • [needs fixing] packages/admin/src/router.tsx:1191-1203

    handleAuthorChange and handleSeoChange both call updateMutation.mutate with changes read from the same potentially-stale rawItem, but they don't echo rawItem._rev. That leaves two editor write paths as blind writes; a stale editor can still silently overwrite a newer author or SEO change. Since the update endpoint validates _rev for any provided field, these auxiliary handlers should echo it too.

    const handleAuthorChange = React.useCallback(
    	(authorId: string | null) => {
    		updateMutation.mutate({
    			targetId: id,
    			targetLocale: rawItem?.locale ?? activeLocale,
    			source: "auxiliary",
    			changes: { authorId, _rev: rawItem?._rev },
    		});
    	},
    	[activeLocale, id, rawItem?.locale, rawItem?._rev, updateMutation.mutate],
    );
    
    const handleSeoChange = React.useCallback(
    	(seo: ContentSeoInput) => {
    		updateMutation.mutate({
    			targetId: id,
    			targetLocale: rawItem?.locale ?? activeLocale,
    			source: "auxiliary",
    			changes: { seo, _rev: rawItem?._rev },
    		});
    	},
    	[activeLocale, id, rawItem?.locale, rawItem?._rev, updateMutation.mutate],
    );
    
  • [needs fixing] packages/admin/src/router.tsx:1162-1182

    The new content-rev.test.ts verifies that updateContent sends _rev when given one, but it does not prove that handleSave/handleAutosave actually pass rawItem._rev through. If a future edit removes { ...payload, _rev: rawItem?._rev }, the API tests would still pass and the draft-overwrite bug would regress. The existing router.test.tsx already renders ContentEditPage and triggers save/autosave; add an assertion that the outgoing PUT /_emdash/api/content/posts/post_1 body includes the _rev token from the mocked content GET response.

    // In packages/admin/tests/router.test.tsx, inside the ContentEditPage describe block:
    it("echoes _rev from the content GET on editor save and autosave", async () => {
    	const { router, TestApp } = buildRouter();
    	mockFetch.on("GET", "/_emdash/api/content/posts/post_1", {
    		data: {
    			item: { /* existing post_1 fixture */ },
    			_rev: "djE6dDE=",
    		},
    	});
    	await router.navigate({
    		to: "/content/$collection/$id",
    		params: { collection: "posts", id: "post_1" },
    	});
    	const screen = await render(<TestApp />);
    	await waitFor(() => {
    		expect(screen.getByTestId("mock-title").element().textContent).toBe("Draft Title");
    	});
    
    	const puts: { body: unknown }[] = [];
    	const origFetch = globalThis.fetch;
    	globalThis.fetch = ((input, init) => {
    		const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
    		if (url.includes("/content/posts/post_1") && init?.method === "PUT") {
    			puts.push({ body: JSON.parse(init.body as string) });
    		}
    		return origFetch(input, init);
    	}) as typeof fetch;
    
    	await screen.getByRole("button", { name: "Save", exact: true }).click();
    	await screen.getByRole("button", { name: "Trigger Draft Sync" }).click();
    	globalThis.fetch = origFetch;
    
    	expect(puts[0]?.body).toMatchObject({ _rev: "djE6dDE=" });
    	expect(puts[1]?.body).toMatchObject({ _rev: "djE6dDE=" });
    });
    

@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 Jul 19, 2026
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

This PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity.

If you're still working on this, please push an update or leave a comment.

@github-actions github-actions Bot added stale and removed stale labels Aug 2, 2026
@ascorbic

Copy link
Copy Markdown
Collaborator

@swissky This is close. Could you give it one final pass so we can merge it:

  • Add a router-level regression test proving the GET _rev reaches save/autosave PUTs.
  • Pass _rev through the remaining updateContent paths: author, SEO, and publishedAt.
  • Refresh the cached _rev from the PUT response to avoid a transient false 409 before refetch completes.

Small clarification on the existing review: metadata-only writes cannot overwrite draft content, but they should still use consistent concurrency protection.

Once those are addressed, this should be ready to land. Thanks!

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

See above

@github-actions github-actions Bot added stale and removed stale labels Aug 27, 2026
@ascorbic

ascorbic commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

I've implemented those changes so we can get this in. Thanks!

@ascorbic
ascorbic enabled auto-merge (squash) September 2, 2026 12:27
@swissky

swissky commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

@ascorbic thank you🙏🏻

@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/awaiting-author Reviewed; waiting on the author to respond needs-rebase labels Sep 2, 2026
@ascorbic
ascorbic merged commit 7887577 into emdash-cms:main Sep 2, 2026
49 of 50 checks passed
@emdashbot emdashbot Bot mentioned this pull request Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/admin review/approved Approved; no new commits since size/M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Admin editor can silently overwrite a newer draft: PUTs omit _rev and editor loads can miss the latest draft revision (0.29.0)

2 participants