Skip to content

fix(core): permanent delete is missing trash guard and uses unrelated permission#912

Merged
ascorbic merged 3 commits intoemdash-cms:mainfrom
lsngmin:fix/content-permanent-delete
May 5, 2026
Merged

fix(core): permanent delete is missing trash guard and uses unrelated permission#912
ascorbic merged 3 commits intoemdash-cms:mainfrom
lsngmin:fix/content-permanent-delete

Conversation

@lsngmin
Copy link
Copy Markdown
Contributor

@lsngmin lsngmin commented May 4, 2026

What does this PR do?

DELETE /content/:collection/:id/permanent had two issues:

  1. No trash-state guard. The SQL was DELETE FROM ${table} WHERE id = ?, so live rows could be permanently removed without going through soft-delete first. The query now requires deleted_at IS NOT NULL.

  2. Wrong permission domain. The route gated on import:execute, an unrelated permission that happens to be ADMIN-only. Replaced with a new content:delete_permanent (also ADMIN-only — no audience change).

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:

Screenshots / test output

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 4, 2026

🦋 Changeset detected

Latest commit: bf384d4

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

This PR includes changesets to release 13 packages
Name Type
@emdash-cms/auth Patch
emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/cloudflare Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash 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

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 4, 2026

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

emdash

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

create-emdash

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

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

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: bf384d4

@ascorbic
Copy link
Copy Markdown
Collaborator

ascorbic commented May 5, 2026

/review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid bug fix. Both issues are real and the fix is minimal and correct:

  • The added AND deleted_at IS NOT NULL clause is standard ANSI SQL, dialect-safe, and properly enforces the "empty trash, not skip soft-delete" contract. The pre-fix SQL really would let a caller wipe live rows without going through trash — verified by the new integration test that fails without the guard.
  • Replacing import:execute with a dedicated content:delete_permanent is the right shape. Both gate at Role.ADMIN, so audience is unchanged. The permission lives in the right place in the ladder (next to content:delete_any) and Permission is keyof typeof Permissions, so type checking flows through cleanly. No other route was using import:execute for non-import work, so nothing else needs untangling.

Test coverage is appropriate:

  • Integration test exercises the three behavioral paths (live refusal, missing id, trashed happy path) and verifies the row actually survives in the live-refusal case rather than just trusting the error code.
  • Unit test covers the full role ladder (ADMIN allowed; EDITOR / AUTHOR / CONTRIBUTOR / SUBSCRIBER denied, handler not called).

One small nit inline. There's also a follow-up worth doing (out of scope here): with the new guard, permanentDelete returns false for both "row doesn't exist" and "row exists but is live", and the handler surfaces both as NOT_FOUND. The security invariant is intact (the row is preserved), but the second case is slightly misleading — the row IS found, just not eligible for permanent deletion. Worth a follow-up to distinguish NOT_FOUND vs NOT_TRASHED (probably 409) so the admin UI can render a clearer error if a stale tab tries to permanent-delete a restored item. The repository docstring in this PR already points at findByIdOrSlugIncludingTrashed as the discriminator.

Nothing blocking — happy to see this merge.

* the caller is responsible for distinguishing these cases (typically via
* a follow-up `findByIdOrSlugIncludingTrashed` to surface NOT_FOUND vs
* NOT_TRASHED). The `AND deleted_at IS NOT NULL` clause is the safety net
* that prevents permanent delete from bypassing the trash workflow.
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.

Nit: there are now two consecutive JSDoc blocks on permanentDelete. The original one-liner (Permanently delete content (cannot be undone)) was kept above the new detailed block. Worth collapsing into one — IDE hover and TSDoc tooling will only pick up the second block anyway, and the duplicate looks like a merge accident on a quick read.

Suggested change
* that prevents permanent delete from bypassing the trash workflow.
/**
* Permanently delete a soft-deleted content row.
*
* Returns `true` only when a soft-deleted (trashed) row was removed.
* Returns `false` when no row exists OR when the row exists but is live
* the caller is responsible for distinguishing these cases (typically via
* a follow-up `findByIdOrSlugIncludingTrashed` to surface NOT_FOUND vs
* NOT_TRASHED). The `AND deleted_at IS NOT NULL` clause is the safety net
* that prevents permanent delete from bypassing the trash workflow.
*/

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 5, 2026

Review posted successfully. Summary:

  • Endorsed both fixes: the AND deleted_at IS NOT NULL SQL guard and the content:delete_permanent permission swap. Both are correct, minimal, and audience-preserving (ADMIN-only on both sides).
  • Verified test coverage is appropriate — three integration paths and the full role ladder in unit tests.
  • Confirmed no other route relied on import:execute outside the import endpoints.
  • Posted one inline nit on the duplicate JSDoc block on permanentDelete with a suggestion to collapse them.
  • Flagged a non-blocking follow-up: the handler surfaces both "missing" and "live" cases as NOT_FOUND, which is misleading for the live case but doesn't compromise the security invariant.

github run

Copy link
Copy Markdown
Collaborator

@ascorbic ascorbic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! Good catch

@ascorbic ascorbic merged commit c8a3a2c into emdash-cms:main May 5, 2026
32 checks passed
@emdashbot emdashbot Bot mentioned this pull request May 5, 2026
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.

2 participants