Product archive-only deletion with hardened authorization (Phase 4) - #74
Merged
Merged
Conversation
The highest blast radius in the schema, done last per the original
phasing directive. Product hard delete is replaced with the same
archive/soft-delete tombstone pattern proven by Assets in Phase 3:
archivedAt/archivedById/archivedByKind. Archiving does not touch,
flag, or cascade to anything beneath the product — assets, code
plans, releases, work items, and specs all keep existing exactly as
they are. They simply stop resolving through the one shared access
check (productAccessWhere), which now excludes archived products by
default and only opts into including them for detail-style reads
(viewing or restoring a specific product, or something beneath it).
An archived product also stops accepting new writes, since the same
check gates every MCP tool's assertProductAccess.
- lib/db/authz.ts: canDeleteProduct now actually wired to the delete
path (previously defined but unused) — org owner/admin or creator,
matching the unified rule used everywhere else.
- lib/db/mutations.ts: archiveProduct/restoreProduct, audit-logged.
deleteProduct kept for a possible future admin-only purge, no
longer on the normal UI/MCP path (MCP never had a delete_product
tool to begin with).
- lib/db/queries.ts: productAccessWhere(userId, { includeArchived })
— single source of truth, so every listing query (getCodePlans,
getWorkItems, getReleases, getAssetOptions, dashboard/analytics,
etc.) automatically excludes archived products' data with zero
per-query changes, while detail reads (getProduct, getCodePlan,
getWorkItem, getRelease, getAssetDetail/History/Record) opt back
in so they keep working for something already resolved as
accessible. getProducts/getProduct also gain the counts needed for
the archive confirmation's blast-radius disclosure (planCount,
releaseCount, workItemCount, specCount, alongside the existing
assetCount).
- MCP: archive_product/restore_product tools disclose those same
counts without claiming anything was touched.
- UI: the products list page's card menu swaps "Delete" for an
"Archive" confirmation showing the real counts and requiring the
product's name to be typed before the button enables — the
disclosure the spec calls for given this is the largest blast
radius in the schema. Archived products move into a collapsible
"Archived products" section with a Restore action; the product
detail page shows an archived banner (with Restore) and hides
Edit/Add Asset while archived.
- Also closes the access-control gap this phase's spec explicitly
named as a blocking dependency: updateAssetAction only called
requireUser() with no check that the asset was actually accessible
to the caller — now uses the same getAssetOptions guard already
used by updateAssetContentAction/setAssetOwnersAction.
Tests: 5 new/changed in mutations.test.ts (archiveProduct/
restoreProduct, deleteProduct rewritten for its new unconditional-
by-id contract) + 5 new in queries.test.ts (archived-visibility for
getProducts/getProduct/getCodePlans) — 297/297 passing. tsc --noEmit
and pnpm build clean of anything this touched. Migration hand-written
for Postgres (drizzle-kit generate needs an interactive TTY not
available here) and drizzle-kit generate for SQLite; both verified
against a real populated local dev DB, including a live end-to-end
pass through the real archive/restore mutations and page renders
against real seeded org data (owner/admin/editor authorization
checked against real membership rows, not just fixtures).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZM1Fxzj2sqge7EyGQ8LXb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
archivedAt/archivedById/archivedByKind) — the highest blast radius in the schema, done last per the original phasing plan.productAccessWhere), which now excludes archived products by default and opts back in only for detail-style reads (viewing/restoring a specific product or something beneath it).assertProductAccess.canDeleteProduct(previously defined inlib/db/authz.tsbut never wired to anything) is now the actual authorization gate — org owner/admin or creator, matching the unified rule used everywhere else.archive_product/restore_product, disclosing blast-radius counts (assets, plans, releases, work items, specs) without claiming anything was touched.updateAssetActiononly calledrequireUser()with no check that the asset was actually accessible to the caller — now uses the samegetAssetOptionsguard already used byupdateAssetContentAction/setAssetOwnersAction.Test plan
mutations.test.ts—archiveProduct/restoreProduct,deleteProductrewritten for its new unconditional-by-id contract; 5 inqueries.test.ts— archived-visibility forgetProducts/getProduct/getCodePlans) — 297/297 passingpnpm exec tsc --noEmit— clean of anything this touched (two pre-existing, unrelated errors remain:passwordHash,CodePlan.productName)pnpm build— succeedsdrizzle-kit generateneeds an interactive TTY not available here;drizzle-kit generateworked directly for SQLite) verified against a real, previously-populated local dev DBcanDeleteProductallows owner/admin and blocks the editor against real membership rows (not just fixtures); archived and restored a real product via the actual mutation path and confirmed the products list page, archived-section toggle, and the detail page's archived banner all render correctly against the live dev server🤖 Generated with Claude Code
https://claude.ai/code/session_016ZM1Fxzj2sqge7EyGQ8LXb