feat: enforce share-invite authorization and IPNS data-integrity in the API#599
Conversation
Discuss-phase decisions for Phase 71 (Share-Invite Security and IPNS Data-Integrity API): vault-backed root-ownership check, ipnsName-only validation, skip redundant SC#3 index, hard-guard same-seq CID equivocation (TEE contract verified), upgrade-merge widen-only re-claim. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 31fbc483b7cb
Verifies all 9 locked CONTEXT.md decisions against live code, confirms the D-05 TEE lease-renewer evidence is current, and maps each decision to a concrete validation strategy for the plan-checker. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 7d7d1e607cac
Entire-Checkpoint: b9583b4d30eb
Entire-Checkpoint: 943fbcf4c5b9
…only) Entire-Checkpoint: 5ed67cce1647
…d D-04 into cutover (greenfield) Entire-Checkpoint: 135a1d230a3f
Entire-Checkpoint: d59199e533e4
Entire-Checkpoint: 7fcb2e53230d
- shares/share_invites CREATE TABLE columns renamed in place (greenfield): read_descriptor_ref/write_descriptor_ref -> encrypted_read_key/encrypted_write_key, root_ipns_name -> share_root_ipns_name, share_invites.encrypted_key -> encrypted_read_key - Added inline CHK_share_invites_claim_count CHECK to the share_invites CREATE TABLE (D-04 folded) - Share/ShareInvite entities updated to encryptedReadKey/encryptedWriteKey/shareRootIpnsName with matching @check on ShareInvite - vaults.root_ipns_name left unchanged
D-05: reject a same-sequence republish carrying a different metadata CID with BadRequestException (400); a genuine same-CID idempotent TEE re-sign retry still succeeds with no sequence bump. Rewrote the stale "Pitfall 4" test into a rejects-different-CID case plus a same-CID-succeeds case, and documented the structural TEE lease-renewer guard (renewIpnsRecordEol never reaches this branch).
…ption D-06: when two brand-new publishRecord calls for the same ipnsName race past the findOne(null) check, the loser's INSERT now hits Postgres's unique-violation (23505) and gets translated into a clean, idempotent- retriable ConflictException (409) instead of an ambiguous 500. Mirrors the err.code / err.driverError.code idiom in shares.service.ts; a non-23505 save error is re-thrown unchanged.
- readDescriptorRef/writeDescriptorRef -> encryptedReadKey/encryptedWriteKey across all shares DTOs, services, controllers, and specs - share_invites.encryptedKey -> encryptedReadKey (invite read key) - rootIpnsName -> shareRootIpnsName module-wide in apps/api/src/shares (share-domain only; vault/ipns/folder-tree rootIpnsName untouched) - clearWriteDescriptor -> clearEncryptedWriteKey (update-grant DTO/service/controller) - raw SQL column literal root_ipns_name -> share_root_ipns_name in SharesService.revokeForItems - @ApiProperty descriptions/validator messages rewritten to encrypted-key wording - share-invites.controller.ts also updated (compile-blocking consumer of the renamed entity fields; not explicitly listed in plan files_modified but in scope directory) - descriptor term fully purged from apps/api/src/shares
Ran pnpm api:generate to regenerate the OpenAPI spec from the renamed apps/api DTOs and rebuild @cipherbox/api-client. The client now exposes encryptedReadKey/encryptedWriteKey/shareRootIpnsName/clearEncryptedWriteKey across shares/invites models, generated functions, and openapi.json. Foundation for the 71-02 compiler-guided consumer rename.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Entire-Checkpoint: 18a3ffd83bb0
- crates/api-client/src/shares.rs: read_descriptor_ref/write_descriptor_ref/ root_ipns_name renamed to encrypted_read_key/encrypted_write_key/ share_root_ipns_name on SentShareResponse, matching the regenerated openapi.json SentShareResponseDto field names - crates/core/src/node/types.rs: no share/grant descriptor symbols present (the only "descriptor" hits are the unrelated file-content NodeContent doc comment) -- verified, no change needed
- shares.module.ts registers IpnsRecord in TypeOrmModule.forFeature - ShareInviteService and SharesService inject IpnsRecord repo via @InjectRepository(IpnsRecord) for the upcoming D-01 ownership gate - both spec files register a getRepositoryToken(IpnsRecord) mock provider, defaulted to a truthy row so pre-existing tests keep resolving DI unchanged
- createInvite — root-ownership gate (D-01/SC#1): reject when ipnsRecordRepo.findOne resolves null, accept + persist when a row is found (asserts inviteRepo.save call count in both cases) - createInvite mechanics test (token, expiresAt, field copy) for D-09
createInvite now looks up ipns_records for (shareRootIpnsName, sharerId) before persisting and rejects with ForbiddenException (403) when the caller never registered the shared node. Documents the D-02 residual gap: only shareRootIpnsName ownership is server-verified, rootNodeId stays client-asserted.
createShare — throws ForbiddenException when ipnsRecordRepo.findOne resolves null for (shareRootIpnsName, sharerId); asserts shareRepo.save and the recipient lookup are never reached (fail-fast gate).
createShare now looks up ipns_records for (shareRootIpnsName, sharerId) at the very top of the method, before the recipient lookup, and rejects with ForbiddenException (403) when the caller never registered the shared node. Existing recipient/self/duplicate checks are unchanged and run after this gate.
…tation - crates/fuse/src/write_ops/grant_scope.rs: SentShareResponse field access (read_descriptor_ref/write_descriptor_ref/root_ipns_name) and test fixture updated to encrypted_read_key/encrypted_write_key/share_root_ipns_name - crates/fuse/src/write_ops/implementation/delete.rs + crates/fuse/src/write_ops/implementation/rename.rs: test-only seed_sent_share fixtures updated to match - crates/sdk/src/rotation/engine.rs: RotationDeps::update_grant param + all re_mint_grants_rooted_at locals/doc comments renamed read_descriptor_ref -> encrypted_read_key (share/grant re-mint path, T-69-12-02 HIGH-3) - Windows security_descriptor/SecurityDescriptor family under crates/fuse/src/platform/windows untouched (verified: 13 hits unchanged) - File-content "descriptor" doc comments (NodeContent/CID/IV, unrelated to share/grant) intentionally left as-is across content_ops.rs, events.rs, fs.rs, inode.rs, operations.rs, poll.rs, read_ops.rs
Release Preview
Cascade Details
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #599 +/- ##
==========================================
+ Coverage 66.61% 75.60% +8.99%
==========================================
Files 155 195 +40
Lines 15249 31838 +16589
Branches 1839 1853 +14
==========================================
+ Hits 10158 24072 +13914
- Misses 4841 7514 +2673
- Partials 250 252 +2
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
…tale write key Entire-Checkpoint: 2f4c7ebeaac0
Entire-Checkpoint: 2d77416f809a
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/api/src/shares/shares.controller.ts (1)
47-50: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick winAdd missing
@ApiResponsedecorators for new error statuses.
createSharecan now return 403 (ownership check per Phase 71 objectives) but only documents 201/401/404/409.updateGrantcan throwBadRequestException(400 — mutual exclusivity) andConflictException(409 — rootGeneration regression) in the service, but only documents 204/401/403/404.📝 Proposed fix
`@ApiResponse`({ status: 201, description: 'Share created', type: CreateShareResponseDto }) `@ApiResponse`({ status: 401, description: 'Unauthorized' }) + `@ApiResponse`({ status: 403, description: 'Caller does not own the share root' }) `@ApiResponse`({ status: 404, description: 'Recipient not found' }) `@ApiResponse`({ status: 409, description: 'Share already exists or self-share' })`@ApiResponse`({ status: 204, description: 'Grant updated' }) `@ApiResponse`({ status: 401, description: 'Unauthorized' }) `@ApiResponse`({ status: 403, description: 'Only the sharer can update' }) `@ApiResponse`({ status: 404, description: 'Share not found' }) + `@ApiResponse`({ status: 400, description: 'encryptedWriteKey and clearEncryptedWriteKey are mutually exclusive' }) + `@ApiResponse`({ status: 409, description: 'rootGeneration regression rejected' })As per coding guidelines, after modifying controllers in
apps/api, ensure the API client is regenerated withpnpm api:generate.Also applies to: 236-239
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/api/src/shares/shares.controller.ts` around lines 47 - 50, Add `@ApiResponse` decorators documenting 403 for createShare and 400/409 for updateGrant, alongside their existing response annotations; locate these methods by their names in the shares controller. After updating the controller, regenerate the API client with pnpm api:generate.Source: Coding guidelines
🧹 Nitpick comments (3)
apps/api/src/shares/share-invite.service.spec.ts (2)
311-408: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winCover stale-generation re-claims.
The suite tests equal and newer
rootGeneration, but not an older invite generation. Add a case asserting a stale re-claim neither saves nor replaces the existing generation/read key; otherwise an anti-rollback regression can pass this widen-only coverage.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/api/src/shares/share-invite.service.spec.ts` around lines 311 - 408, Extend the D-07 re-claim tests for service.claimInvite with a stale-generation case where the invite rootGeneration is lower than the existing share’s. Assert the result keeps the existing share ID, mockManager.save is not called, and both the existing rootGeneration and encryptedReadKey remain unchanged.Source: Path instructions
167-181: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAssert encrypted DTO serialization.
This test claims DTO fields are copied but does not verify
encryptedReadKey, optionalencryptedWriteKey, oritemNameEncrypted. A regression in encrypted-key persistence orBufferconversion would still pass.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/api/src/shares/share-invite.service.spec.ts` around lines 167 - 181, The createInvite test should also verify encrypted DTO fields are persisted and correctly serialized. In the test for service.createInvite, assert encryptedReadKey, optional encryptedWriteKey, and itemNameEncrypted against the DTO, accounting for expected Buffer conversion where applicable.Source: Path instructions
apps/api/src/migrations/1750000000000-ApiSchemaCutover.ts (1)
41-125: 🚀 Performance & Scalability | 🔵 TrivialConsider adding indexes on
share_root_ipns_nameforsharesandshare_invites.
revokeForItemsissuesWHERE sharer_id = :sharerId AND share_root_ipns_name IN (:...names)against both tables. The existingsharer_idindex narrows the scan, but theINfilter still scans all of a sharer's rows. A composite index on(sharer_id, share_root_ipns_name)for both tables would turn this into an index lookup, improving bulk revocation performance for users with many shares.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/api/src/migrations/1750000000000-ApiSchemaCutover.ts` around lines 41 - 125, Add composite indexes on ("sharer_id", "share_root_ipns_name") for both "shares" and "share_invites" in the migration, using clear unique index names and ensuring the migration’s rollback removes them appropriately.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
@.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/deferred-items.md:
- Line 5: Update the deferred-items entry to distinguish the command’s target
files from the files that actually contain diagnostics: state that the
type-check targets shares.service.ts and shares.service.spec.ts, while the
reported errors occur in the listed crypto resolution, ipns.service.ts, and
http-metrics.interceptor.spec.ts files. Preserve that these issues are
pre-existing and out of scope.
In @.planning/STATE.md:
- Around line 27-34: Synchronize the phase transition metadata in STATE.md:
update the Current focus and Status fields from Phase 71 to Phase 72, and revise
any stale stopped_at value to reflect Phase 72 execution, matching the Phase,
Plan, and last-activity fields already declaring Phase 72.
In `@apps/api/src/shares/share-invite.service.ts`:
- Around line 183-235: Reject write-capable claims missing a re-wrapped write
key before the atomic claim UPDATE, returning a 400 error so the invite is not
consumed. In the claim handling method, use invite.encryptedWriteKey and
dto.encryptedWriteKey to validate this condition before any database mutation;
retain the existing write-key assignment logic for valid claims. Add service
tests covering both a new write claim and a read-to-write upgrade with the write
ciphertext omitted.
In `@apps/api/src/shares/share-invites.controller.ts`:
- Line 60: Add Swagger documentation for the ownership-gate 403 response on both
controller methods: add an `@ApiResponse({ status: 403, ... })` decorator
alongside the existing response decorators for `createInvite` and `listInvites`,
using a clear description consistent with the runtime error.
---
Outside diff comments:
In `@apps/api/src/shares/shares.controller.ts`:
- Around line 47-50: Add `@ApiResponse` decorators documenting 403 for createShare
and 400/409 for updateGrant, alongside their existing response annotations;
locate these methods by their names in the shares controller. After updating the
controller, regenerate the API client with pnpm api:generate.
---
Nitpick comments:
In `@apps/api/src/migrations/1750000000000-ApiSchemaCutover.ts`:
- Around line 41-125: Add composite indexes on ("sharer_id",
"share_root_ipns_name") for both "shares" and "share_invites" in the migration,
using clear unique index names and ensuring the migration’s rollback removes
them appropriately.
In `@apps/api/src/shares/share-invite.service.spec.ts`:
- Around line 311-408: Extend the D-07 re-claim tests for service.claimInvite
with a stale-generation case where the invite rootGeneration is lower than the
existing share’s. Assert the result keeps the existing share ID,
mockManager.save is not called, and both the existing rootGeneration and
encryptedReadKey remain unchanged.
- Around line 167-181: The createInvite test should also verify encrypted DTO
fields are persisted and correctly serialized. In the test for
service.createInvite, assert encryptedReadKey, optional encryptedWriteKey, and
itemNameEncrypted against the DTO, accounting for expected Buffer conversion
where applicable.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 4d13e95a-8bc1-4495-9903-bc11225e125d
⛔ Files ignored due to path filters (13)
packages/api-client/openapi.jsonis excluded by!packages/api-client/**packages/api-client/src/generated/invites/invites.tsis excluded by!**/generated/**,!**/generated/**,!packages/api-client/**packages/api-client/src/generated/shares/shares.tsis excluded by!**/generated/**,!**/generated/**,!packages/api-client/**packages/api-client/src/models/claimInviteDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/createInviteDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/createShareDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/createShareResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/inviteDataResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/inviteResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/receivedShareResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/sentShareResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/shareInvitesControllerListInvitesParams.tsis excluded by!packages/api-client/**packages/api-client/src/models/updateGrantDto.tsis excluded by!packages/api-client/**
📒 Files selected for processing (96)
.planning/PROJECT.md.planning/ROADMAP.md.planning/STATE.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-01-PLAN.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-01-SUMMARY.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-02-PLAN.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-02-SUMMARY.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-03-PLAN.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-03-SUMMARY.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-04-PLAN.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-04-SUMMARY.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-05-PLAN.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-05-SUMMARY.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-06-PLAN.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-06-SUMMARY.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-07-PLAN.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-07-SUMMARY.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-08-PLAN.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-08-SUMMARY.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-09-PLAN.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-09-SUMMARY.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-CONTEXT.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-DISCUSSION-LOG.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-PATTERNS.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-RESEARCH.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-SECURITY.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-VALIDATION.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/71-VERIFICATION.md.planning/phases/71-share-invite-security-and-ipns-data-integrity-api/deferred-items.md.planning/todos/pending/2026-07-10-extract-assert-root-ownership-helper.md.planning/todos/pending/2026-07-10-reconcile-phase71-planning-docs-d01-greenfield.md.planning/todos/pending/2026-07-10-reject-write-invite-claimed-without-write-key.md.planning/todos/pending/2026-07-10-restore-tee-key-state-for-sdk-e2e-tee-republish.md.planning/todos/pending/2026-07-10-zeroize-file-keys-on-unwrap-error-path.mdapps/api/src/ipns/ipns.service.spec.tsapps/api/src/ipns/ipns.service.tsapps/api/src/migrations/1750000000000-ApiSchemaCutover.tsapps/api/src/shares/dto/claim-invite.dto.tsapps/api/src/shares/dto/create-invite.dto.tsapps/api/src/shares/dto/create-share.dto.tsapps/api/src/shares/dto/get-invites-for-item-query.dto.tsapps/api/src/shares/dto/invite-response.dto.tsapps/api/src/shares/dto/share-response.dto.tsapps/api/src/shares/dto/update-grant.dto.tsapps/api/src/shares/entities/share-invite.entity.tsapps/api/src/shares/entities/share.entity.tsapps/api/src/shares/invites.controller.spec.tsapps/api/src/shares/invites.controller.tsapps/api/src/shares/share-invite.service.spec.tsapps/api/src/shares/share-invite.service.tsapps/api/src/shares/share-invites.controller.spec.tsapps/api/src/shares/share-invites.controller.tsapps/api/src/shares/shares.controller.spec.tsapps/api/src/shares/shares.controller.tsapps/api/src/shares/shares.module.tsapps/api/src/shares/shares.service.spec.tsapps/api/src/shares/shares.service.tsapps/web/src/components/file-browser/ShareDialog.tsxapps/web/src/hooks/useAuth.tsapps/web/src/hooks/useMutationFailureUx.tsapps/web/src/hooks/useSharedNavigationActions.tsapps/web/src/lib/crypto/key-wrapping.tsapps/web/src/services/invite.service.tsapps/web/src/services/owner-reconcile.service.tsapps/web/src/services/rotation-driver.service.tsapps/web/src/services/share.service.tsapps/web/src/stores/share.store.tscrates/api-client/src/shares.rscrates/fuse/src/write_ops/grant_scope.rscrates/fuse/src/write_ops/implementation/delete.rscrates/fuse/src/write_ops/implementation/rename.rscrates/sdk/src/rotation/engine.rspackages/sdk-core/src/__tests__/rotation/grant-remint.test.tspackages/sdk-core/src/__tests__/rotation/scope.test.tspackages/sdk-core/src/__tests__/rotation/write-revocation.test.tspackages/sdk-core/src/__tests__/share/grant.test.tspackages/sdk-core/src/__tests__/share/navigate.test.tspackages/sdk-core/src/rotation/engine.tspackages/sdk-core/src/rotation/scope.tspackages/sdk-core/src/share/grant.tspackages/sdk-core/src/share/navigate.tspackages/sdk/src/__tests__/client-rotation.test.tspackages/sdk/src/__tests__/client-write-descriptor.test.tspackages/sdk/src/__tests__/download-shared-file.test.tspackages/sdk/src/__tests__/owner-reconcile.test.tspackages/sdk/src/__tests__/resolve-share-root.test.tspackages/sdk/src/__tests__/update-shared-single-file.test.tspackages/sdk/src/client.tspackages/sdk/src/share/owner-reconcile.tspackages/sdk/src/types.tstests/sdk-e2e/src/suites/invite-link.test.tstests/sdk-e2e/src/suites/ipns-publish-gate.test.tstests/sdk-e2e/src/suites/read-chain-navigation.test.tstests/sdk-e2e/src/suites/rotation-crash-safety.test.tstests/sdk-e2e/src/suites/share-operations.test.tstests/sdk-e2e/src/suites/write-chain-rotation.test.ts
…le invite Entire-Checkpoint: 0a4021a168f2
Entire-Checkpoint: aac122f828f7
…re_invites Entire-Checkpoint: 17cb630eb56c
… write-key todo Entire-Checkpoint: 0f77949445e0
…e specs Entire-Checkpoint: a782a363d374
…ublish Test 21 (D-06) fires two concurrent first-publishes of the same brand-new ipnsName with the SAME CID at sequence=1, and asserted exactly one 200 plus one 409. But same-CID / same-sequence is idempotent by design (#599): when the loser serializes after the winner commits, it re-reads the identical CID and returns an idempotent 200 -- so both calls can legitimately succeed, flaking the strict one-409 assertion (observed red on #600's SDK E2E leg). Accept both correct shapes: at least one winner (never zero) and any loser is a clean 409 (never a 5xx). The follow-up resolve still asserts exactly one row at sequence=1. Test-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: d752b84767ae
…fields Phase 71 (#599) renamed the share-plane DTO fields (readDescriptorRef -> encryptedReadKey, rootIpnsName -> shareRootIpnsName), but the D-16 shared-scope-exit-rotation.mts driver still POSTed /shares with the old names. Under the API's forbidNonWhitelisted ValidationPipe the old names are rejected as non-whitelisted and the required fields are absent, so Part A got a 400 (BadRequest) before any rotation ran. Update the POST body and the /shares/received read to the current field names. This surfaced only after the FUSE seq-1 equivocation fix let the flow advance past the per-file publish; pure test-driver drift, no product change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 658ce2583e2d
…ocation (#601) * fix(fuse): resolve before per-file first-publish to avoid seq-1 equivocation publish_file_node took its seq-1 first-publish tail purely on the caller's is_first_publish hint (the file inode CID was empty). A dropped UploadComplete (write_generation mismatch) or a duplicated/deferred FUSE-T release can leave that hint set AFTER the per-file record already exists at seq 1. Re-running the tail re-seals a fresh node envelope (created_at/ modified_at = now_ms changes each call -> a new envelope CID), and Phase 71's anti-equivocation gate (#599) correctly rejects a same-sequence republish with a different CID (HTTP 400) -- failing the D-16 shared scope-exit rotation desktop-e2e leg identically on macOS and Linux. Resolve the per-file IPNS name before the first-publish tail: take the seq 1 + TEE-enrollment path only when the record is genuinely absent (NotFound); when it already resolves the content still needs publishing, so fall through to the existing sequence-bumping CAS branch. A resolve error now fails the release closed rather than risking a seq-1 equivocation. The API gate is left intact (the correct anti-equivocation guarantee). The single shared publish_file_node covers fuser (macOS/Linux) and WinFsp, so one edit fixes all three platforms. Rust-only; no API surface change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 1a145a1db935 * test(desktop-e2e): update shared-scope-exit driver to #599 share DTO fields Phase 71 (#599) renamed the share-plane DTO fields (readDescriptorRef -> encryptedReadKey, rootIpnsName -> shareRootIpnsName), but the D-16 shared-scope-exit-rotation.mts driver still POSTed /shares with the old names. Under the API's forbidNonWhitelisted ValidationPipe the old names are rejected as non-whitelisted and the required fields are absent, so Part A got a 400 (BadRequest) before any rotation ran. Update the POST body and the /shares/received read to the current field names. This surfaced only after the FUSE seq-1 equivocation fix let the flow advance past the per-file publish; pure test-driver drift, no product change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 658ce2583e2d * perf(fuse): reuse pre-resolved sequence on the CAS re-publish path The seq-1 equivocation guard resolves the per-file IPNS name via resolve_ipns_for_replay; on the Found path the CAS branch then resolved the same name a second time inside publish_with_cas_retry -- two resolve round-trips on the exact path the fix targets (greptile P2 on #601). Thread an optional preresolved_seq through publish_with_cas_retry so the first CAS attempt reuses the already-resolved sequence when present. The Conflict retry still re-resolves (a conflict makes any pre-resolved value stale). The bin-publish caller passes None (behaviour unchanged). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 4c647947037c --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ublish Test 21 (D-06) fires two concurrent first-publishes of the same brand-new ipnsName with the SAME CID at sequence=1, and asserted exactly one 200 plus one 409. But same-CID / same-sequence is idempotent by design (#599): when the loser serializes after the winner commits, it re-reads the identical CID and returns an idempotent 200 -- so both calls can legitimately succeed, flaking the strict one-409 assertion (observed red on #600's SDK E2E leg). Accept both correct shapes: at least one winner (never zero) and any loser is a clean 409 (never a 5xx). The follow-up resolve still asserts exactly one row at sequence=1. Test-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: d752b84767ae
…store (#600) * test(web-e2e): align rotation-durability floor helpers with combined store Phase 70.1 (D-06, #598) collapsed the generation-high-water and seq-high-water IndexedDB stores into a single rotation-floor store keyed by nodeId holding a { generation?, seq?, wrappedKeyCheckpoint? } record. The rotation-durability e2e helpers still probed the retired store names, so readDurableFloors short-circuited and returned generation: undefined, failing the SC#4 setup assertion at line 216 on every main run since #598. Point readDurableFloors and writeDurableSeqFloor at the rotation-floor store; the write does a read-modify-write so it preserves the node's existing generation/wrappedKeyCheckpoint floors. Test-only -- the product write path (rotation-idb-store.ts) is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 545aa91edc15 * docs(web-e2e): clarify writeDurableSeqFloor seq is a direct overwrite The doc comment claimed the read-modify-write "matches production's max-preserving write", which is misleading: production idbPutFloors max-preserves seq, whereas this staging helper intentionally overwrites seq so a test can pin an exact sequence floor. Only the preservation of the other fields (generation / wrappedKeyCheckpoint) matches production. Comment-only; no behavior change. Addresses CodeRabbit review on #600. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: f73432632360 * test(sdk-e2e): accept idempotent two-200 outcome in D-06 concurrent publish Test 21 (D-06) fires two concurrent first-publishes of the same brand-new ipnsName with the SAME CID at sequence=1, and asserted exactly one 200 plus one 409. But same-CID / same-sequence is idempotent by design (#599): when the loser serializes after the winner commits, it re-reads the identical CID and returns an idempotent 200 -- so both calls can legitimately succeed, flaking the strict one-409 assertion (observed red on #600's SDK E2E leg). Accept both correct shapes: at least one winner (never zero) and any loser is a clean 409 (never a 5xx). The follow-up resolve still asserts exactly one row at sequence=1. Test-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: d752b84767ae * test: stabilize web-e2e batch-op and shared-mkdir timeouts Batch delete/move (4.10/4.11), version restore poll (6.6.2), the serial root-file delete chain (8.2), and recipient-side shared mkdirs each drive IPNS publish round-trips whose serial-calibrated waits were too tight, flaking locally and under parallel-worker CI load. Raise the explicit per-call timeouts (CI already floors waiters to 60s via ciFloor) and make 6.6.2 poll the editor for the restored content instead of reading once. Verified: full-workflow.spec.ts 53/53 green locally under the CI 60s-floor timeout regime. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 1fe70eb98011 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* docs(72): research SDK write-plane durability and correctness Maps all 6 locked success criteria plus 3 supporting todos against the current client.ts/bin/index.ts/registration.ts tree, including two material corrections to stale todo assumptions (SC#4's mirror was already reverted in 68.2-12; the write-body CAS-merge has no deletion-pruning and will resurrect an SC#1 delete under a concurrent write) and confirmation that moveInSharedFolder has zero regression coverage today. Entire-Checkpoint: 38d47c129162 * docs(72): create phase plan Entire-Checkpoint: 8ad8a54be3fd * docs(72): add phase patterns and validation docs Entire-Checkpoint: 721d45d3f73f * docs(72): retire moved phase-71 todos from pending The completed/ copies landed with the phase-72 plan commit; this removes the pending/ originals to finish the move (Phase 71 #599 addressed them). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 137d02be0acd * test: re-enable moveInSharedFolder reachable-branch regression gate Rewrite move-in-shared-folder.test.ts from 100% describe.skip'd (13 tests against the legacy dead branch, importing retired core types) to one live test that drives the reachable write-chain branch with real AES-GCM AAD-bound seal/unseal from cipherbox/core. Fixture uses distinct values for the moved child's ipnsName vs its WriteChildRef.childId so a UUID-vs-ipnsName confusion cannot silently pass. This is the SC#5 regression gate that Plan 07's dead-branch removal depends on. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 954f087042ea * docs(72-01): complete SDK write-plane durability plan 01 Entire-Checkpoint: bc8e958fff06 * test: update upload-batch.test.ts mocks to current SealedChildRef shape Mock addFilePointerToFolder builders emitted the retired type/fileMetaIpnsName/ipnsPrivateKeyEncrypted child-ref shape, drifted since the node/v3 SealedChildRef frozen field set (68.2-12). Rebuild mocks to name/ipnsName/generation/versionFloor/readKeySealed, matching the real function's newRef return key and async signature. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 00bb017ef123 * test: identify rotated write-chain seeds by provenance not offset capturedKeys[0]/capturedKeys[2] assumed a fixed child-first ordering of every 32-byte random draw during rotateWriteFromNode, but co-writer write-key re-wrap can legitimately mint extra 32-byte randoms and shift those offsets. Spy directly on generateEd25519Keypair (the one call that mints a node's new k51 identity) via a namespace import so the spy observes calls made inside sdk-core's bundled rotation engine, and read back its real per-call return values in guaranteed child-first order instead of trusting array position. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 995b2ef84db9 * docs(72-02): complete SDK write-plane durability plan 02 Entire-Checkpoint: 84775d2a32bf * test(72-03): add failing base-aware write-body CAS-merge tests Adds Test A/B/C for updateFolderMetadataAndPublish covering the SC#1 Critical Finding 2 landmine: the write-body CAS-merge is currently a naive union with no base snapshot, so a racing writer's stale remote write-body resurrects a childId a concurrent deleteItem just dropped. Test C is the required resurrection-guard regression test; B proves a genuinely concurrent add is still kept; A is a clean-publish sanity check. All three fail today (RED) since baseWriteChildren does not exist yet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 48169f844c68 * feat(72-03): make write-body CAS-merge base-aware to prevent resurrection The write-body merge in updateFolderMetadataAndPublish was a naive childId union that resurrected a WriteChildRef a concurrent deleteItem had just dropped, whenever a racing writer's stale remote snapshot still carried it (72-RESEARCH.md Critical Finding 2). Adds an optional baseWriteChildren param. When supplied, the CAS-409 merge treats a childId present in base but absent from local as an intentional delete this transaction already committed to and prunes it regardless of what the remote snapshot still holds, while still keeping a genuinely concurrent remote-only add. Falls back to the legacy naive union when baseWriteChildren is omitted (back-compat). Rewrites the now-falsified "write plane is add-only... deletes are preserved verbatim" comment to state the new prune contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 333bc62254d0 * test(72-03): add failing deleteItem write-chain trim tests Adds delete-item.test.ts proving deleteItem must resolve the removed child's UUID (WriteChildRef.childId) before it can drop the entry from writeChildren -- deleteItem's childId param is an ipnsName, a different value (72-RESEARCH.md Pitfall 1). Fixture uses distinct ipnsName vs UUID values so a naive filter would silently no-op. Also asserts baseWriteChildren is threaded to updateFolderMetadataAndPublish and that a UUID-resolve failure fails open (delete still succeeds). Two of three tests fail today (RED) since deleteItem still preserves the write-body verbatim. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 140a8d13e353 * feat(72-03): deleteItem drops the removed child's WriteChildRef by UUID Resolves removedItem.ipnsName to its PublishedNode.id (UUID) and filters the matching WriteChildRef out of the write-body in the SAME CAS publish that removes the read-plane SealedChildRef (SC#1). deleteItem's childId param is an ipnsName; WriteChildRef.childId is a UUID, so the resolve step is required before the filter can match anything. Threads the pre-trim write-body as baseWriteChildren into updateFolderMetadataAndPublish so the base-aware CAS-merge (previous commit) prunes the drop instead of letting a racing writer's stale remote snapshot resurrect it. Fails open (Pitfall 2): a UUID-resolve failure logs a warning and proceeds with the write-body unchanged -- never aborts the already-succeeded read-plane delete. Replaces the stale "preserved verbatim / owned by 68.1-02" comment with the new drop contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: e43379005dd2 * docs(72-03): complete SDK write-plane durability plan 03 Entire-Checkpoint: 1c4f9a82f3b9 * test(72-04): add failing fail-closed test for getWriteBodyParams transient miss RED: proves getWriteBodyParams must throw when a real writeKey is present and the IPNS resolve returns null (transient miss), instead of silently returning writeChildren: [] and letting the next publish seal an empty write-body that discards the entire write chain. The two existing fail-open paths (zero/absent writeKey, resolved-without-writeSealed) are asserted unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 93ab403944ce * feat(72-04): fail closed on transient IPNS resolve miss in getWriteBodyParams GREEN: getWriteBodyParams now throws when a real writeKey is present and resolvePublishedNode genuinely returns null, instead of returning writeChildren: [] -- a network blip must never erase an entire folder's write chain (SC#2). Splits the prior combined `!resolved || !writeSealed` condition: the structurally-never-write-capable !writeSealed sub-case stays fail-open, unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * test(72-04): add failing fail-closed test for bin/index.ts getWriteBodyParams twin RED: mirrors the client.ts RED case for the bin/index.ts twin -- proves addToBin's getWriteBodyParams call must throw when a real writeKey is present and the folder's IPNS resolve returns null (transient miss), BEFORE the durable bin write happens (no orphaned bin entry). The resolved-without-writeSealed fail-open path is asserted unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 35618bcefd0b * feat(72-04): mirror fail-closed transient-miss handling into bin/index.ts twin GREEN: applies the identical fail-closed split from client.ts to the bin/index.ts getWriteBodyParams twin -- throws on a genuine null resolve with a real writeKey present, fail-open unchanged for the resolved-without-writeSealed and zero-writeKey cases. Both copies remain byte-identical in their error message and branching (Plan 08 dedupe precondition). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * docs(72-04): complete getWriteBodyParams fail-closed plan Entire-Checkpoint: de590e0802bc * test(72-05): add failing restoreFromBin write-link re-homing tests RED for SC#3: binOps.restoreFromBin does not yet accept a source FolderState, so the moved child's WriteChildRef is never re-homed when restoring to a different parent than the original. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * feat(72-05): restoreFromBin re-homes the WriteChildRef to a different parent Closes SC#3: restoring a binned item to a parent DIFFERENT from its original now loads the original parent (client.ts, via requireFolder on BinEntry.originalParentIpnsName) and re-homes the write-chain link (bin/index.ts) using moveItem's 68.1-31 dest-before-source unseal/reseal/drop pattern, keyed by the node's own UUID and restoredItem.generation. Fails open (no throw, console.warn) when either folder is read-only or the original parent cannot be resolved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * test(72-05): add failing permanentDeleteFromBin write-link drop test RED for SC#1 symmetry (RESEARCH.md Open Question 1): permanentDeleteFromBin does not yet drop the lingering WriteChildRef addToBin retains in the original parent's write-body, so a permanently-deleted item that was never restored leaks an orphaned write-chain entry forever. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * feat(72-05): permanentDeleteFromBin drops the lingering original-parent WriteChildRef Closes SC#1 symmetry (RESEARCH.md Open Question 1): addToBin retains the removed child's WriteChildRef in the original parent's write-body so a later restoreFromBin can re-home it; permanent removal is now the symmetric release point that drops it, by node UUID (BinEntry.nodeRef.id) rather than a fresh resolve. Fails open (no throw) on an unresolvable original parent or write-body error -- CID cleanup and bin-entry removal always complete. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * test(72-05): add sdk-e2e restore-to-different-parent editable-and-savable proof Mirrors move-restore-content.spec.ts test 2b for the RESTORE direction: upload into folder A, deleteToBin, restore into a DIFFERENT folder B, then edit-and-save the restored file in B (an owned in-place write via replaceFile). Before SC#3's re-homing this save fails closed with "File ... is not write-capable (no WriteChildRef)". Restore-to-different-parent is not a shipped web UI flow, so this lives in sdk-e2e rather than web-e2e. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * docs(72-05): add plan 05 summary Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * docs(72-05): complete restoreFromBin re-homing and permanent-delete symmetry plan * test(72-06): add failing listingCache invalidation test for SC#4 Drives maybeRepublishFolderForFileMigration directly to prove a file-only publish must invalidate the parent listingCache when the file's size/cid actually changed, and preserve it on a no-op edit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * feat(72-06): invalidate listingCache on real file-only publish SC#4 maybeRepublishFolderForFileMigration now deletes the parent's listingCache entry when replaceFile/restoreFileVersion/deleteFileVersion change the file's live size/cid, mirroring the shipped updateSharedFile 68.2-02 one-liner. The gate is computed by each caller (which already holds the prior and new content descriptors) so a genuine no-op edit does not needlessly bust the cache. No fields added to SealedChildRef. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * test(72-06): add failing zeroize-on-second-unwrap-throw test updateSharedSingleFile unwraps both fileReadKey and fileWriteKey BEFORE the try/finally that owns their zeroing, so a throw on the second unwrapKey call leaves the already-unwrapped first key un-zeroed. Proves the gap before the fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * feat(72-06): zero file keys when the second unwrap throws Moves both unwrapKey calls in updateSharedSingleFile inside the try/finally that owns fileReadKey/fileWriteKey zeroing (null-init before the try), so a throw on the second unwrap still zeroes the already-unwrapped first key instead of leaving it live until GC. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * docs(72-06): complete listingCache invalidation and zeroize plan Entire-Checkpoint: c960b5ea342d * fix: remove unreachable moveInSharedFolder legacy share-keys branch Deletes the dead legacy branch in CipherBoxClient.moveInSharedFolder that assigned an Ed25519 ipnsPrivateKey as an AES destWriteKey -- a latent wrong-key bug that could never be reached because the sole producer, fetchShareKeys, hard-returns an empty array. Also removes the now-unused getShareKeysFn callback parameter and its stale backward-compat doc comment. The reachable write-chain branch (one-hop UUID walk to the destination WriteChildRef) is retained unchanged. Updates the Plan 01 regression test to drop the removed callback arg and its now-outdated two-branch doc comment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * fix: drop removed getShareKeysFn arg from moveInSharedFolder callers Both useSharedWriteOps.ts call sites passed getShareKeysFn, which the SDK's moveInSharedFolder no longer accepts after the dead legacy branch removal. fetchShareKeys itself stays in place (still used by resolveFileIpnsKey). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * docs(72-07): complete remove-legacy-moveinsharedfolder-branch plan Entire-Checkpoint: 16f2fd466370 * refactor: extract hasRealWriteKey predicate in SDK client Consolidates the ~6 inline "wk.length === 32 && !wk.every(b => b===0)" spellings and 2 duplicated local closures into one module-level hasRealWriteKey predicate in packages/sdk/src/client.ts. Pure read, never mutates or zeroes the inspected key (D-09 borrow contract). Behavior-preserving -- full SDK unit suite stays green (389 passing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * refactor: extract walkChildWriteKey write-chain hop-walk primitive Consolidates the 7 divergent inline unsealChildWriteKey call sites in packages/sdk/src/client.ts (dfsFindFolder, moveItem, resolveFileWriteChainKeys, resolveShareEncryptedWriteKey, moveInSharedFolder, enumerateSharedSubtree, resolveSharedSubfolderWriteKey) into one walkChildWriteKey primitive with an explicit mode: 'require' | 'skip' | 'nullable' string-literal parameter (never a TS enum), matching each site's original missing-WriteChildRef fail-open/fail-closed posture exactly. The primitive never swallows an AEAD unseal failure in any mode -- verified against every original site plus resolve-shared-subfolder-write-key.test.ts's explicit throw assertions. updateSharedFile's inline walk (site 5) is left as a documented exception: its fallback-to-legacy-key-lookup shape fits none of the 3 modes, and its getFileIpnsKeyFn fallback is confirmed live via apps/web's useSharedWriteOps.ts resolveFileIpnsKey. D-09 null-before-return / fill-in-finally zeroization preserved inside the primitive. Behavior-preserving -- full SDK unit suite stays green (389 passing), build succeeds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * docs(72-08): add SUMMARY for write-chain hop-walk consolidation plan Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * docs(72-08): append self-check results to SUMMARY Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * docs(72-08): complete write-chain hop-walk consolidation plan Entire-Checkpoint: 8af0f065dd08 * refactor(sdk-core): extract wrapIpnsKeyForTee shared TEE-wrap helper Deduplicate the triplicated fail-closed TEE enrollment wrap sequence across file/index.ts, vault/index.ts, and folder/registration.ts into a single packages/sdk-core/src/tee/wrap.ts helper, re-pointing all three call sites. Preserves the borrow-only D-09 contract — the helper never zeroes the caller-owned ipnsPrivateKey buffer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * docs: add 72-09 plan summary for TEE-wrap dedup Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * docs(72-09): complete TEE-wrap dedup plan Entire-Checkpoint: 8b227802d7c2 * refactor(sdk): extract shared version-op core for file version mutations Extract runFileVersionOp as the shared body of replaceFile, restoreFileVersion, and deleteFileVersion — requireFolder, resolveFileWriteChainKeys, the 14-field updateFileMetadata publish, the maybeRepublishFolderForFileMigration piggyback, and the identical 3-key zeroing finally. Only createVersion and deletedCid vary across callers. Removes the dead void versionIndex slots by underscore-prefixing the unused parameter instead. D-09 zeroing contract preserved exactly: fileReadKey/fileWriteKey are terminal-owner-zeroed in the extracted core; fileIpnsPrivateKey is left to updateFileMetadata (T-47-01). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * refactor(sdk): unify getWriteBodyParams and adoptPublishedFolderState Extract getWriteBodyParams, adoptPublishedFolderState, and hasRealWriteKey into a shared packages/sdk/src/write-body-params.ts module, and re-point both CipherBoxClient (client.ts, a thin delegating private method) and the bin operations module (bin/index.ts, a direct import) at the single implementation. Plan 04 already made the two getWriteBodyParams copies byte-identical including the SC#2 fail-closed change, so this collapses the drift surface without altering behavior. Also reconciles the resolve path: client.ts previously went through the private resolvePublishedNode helper while bin/index.ts inlined resolveIpnsRecord+fetchFromIpfs+JSON.parse directly; the shared module standardizes on the inline form both already proved behaviorally identical for this call site. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * docs(72-10): complete version-op core dedup plan Entire-Checkpoint: f12a4bd99488 * test: migrate api-client test mocks to vitest v3 Mock generics instance.test.ts declared createSpy with the bare ReturnType<typeof vi.spyOn>, which vitest v3 resolves to an unconstrained MockInstance shape that no longer accepts the axios.create spy assignment. Type it explicitly as MockInstance<typeof axios.create>. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test: migrate sdk-core test mocks to vitest v3 Mock generics cas.test.ts and share/grant.test.ts used the vitest v2 Mock typing patterns: untyped vi.fn() spread through a Partial<> override object (losing mockResolvedValue/mockResolvedValueOnce on cas.test.ts's decodeRemote/encodeAndUpload/merge fixtures) and the old two-type-arg vi.fn<[Args], Return>() tuple form (grant.test.ts's insertShareFn and getInviteDataFn). Migrated to the vitest v3 single function-type generic (vi.fn<(args) => Return>()) and explicit Mock<...> typing on the makeParams override shape in cas.test.ts, with no change to any assertion or test behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test: migrate sdk client and folder tests off retired core types Update fixtures/mocks to the node/v3 type system: SealedChildRef (name/ipnsName/generation/versionFloor/readKeySealed), Node, NodeContent, and the required FolderState.writeKey field. Fix addFilePointerToFolder mocks to the current async { updatedChildren, newRef } shape. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 79177a9b648f * test: migrate sdk move share and integration tests off retired core types Fix moveItem mocks to updatedSource/updatedDest/movedRef, shared-write result shape to include publishedParent, VaultInit rootReadKey/rootWriteKey split, and resolve child kind/id via resolveChildIdentity and ipnsName instead of retired SealedChildRef fields. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 418bf9a35b15 * build: typecheck all test files in the CI typecheck gate CI's `pnpm typecheck` ran each package's build (`tsconfig.build.json`, which excludes tests), so test files were never typechecked and had accumulated pre-existing vitest-v3 Mock and Phase-62 retired-type drift. Add a test-inclusive `typecheck` script (`tsc --noEmit`, base tsconfig includes tests) to each package and run them after the builds in the root `typecheck` script (build-first so cross-package types resolve from dist). The test-file drift itself was fixed in the preceding commits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 4fa61c10e8dc * test: remove obsolete moveItem re-encryption unit block moveItem was redesigned in Phase 68.1 to a zero-re-encryption SealedChildRef link rewrite (READ-04) plus a read/write-key re-seal under the destination parent -- sdk-core's moveItem never calls resolveFileMetadata/updateFileMetadata at all. The skipped client-move-reencrypt.test.ts asserted a re-encryption premise that no longer exists in production code. Current moveItem behavior (re-seal, resolveIpnsRecord-driven child resolve, dest-before-source publish ordering) is already covered by the live moveItem describe block in client-extended.test.ts and client-rotation.test.ts, so the dead block is deleted rather than replaced. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 579dbf85d3fd * test: migrate deleteItem subtree-unenroll traversal to the node/v3 read-chain Phase 68.1-02 rewrote the fire-and-forget IPNS-unenroll collector (collectRemovedItemIpnsNames/collectDescendantIpnsNames) on top of the real PublishedNode read-chain (resolveIpnsRecord + fetchFromIpfs + unsealChildReadKey/unsealNode); the sdkCore.loadFolderMetadata seam this suite mocked no longer exists. Rebuilds Tests A-C on real @cipherbox/core seal/unseal fixtures (mirrors delete-item.test.ts / client-write-plane-recovery.test.ts) and un-skips them. Test D (cyclic A->B->A folder graph) is kept skipped with an accurate comment instead of the stale TODO(phase 65): collectDescendantIpnsNames has no visited-set cycle guard, unlike dfsFindFolder (ensureFolderLoaded's navigation DFS), which does. This is a real, currently-unaddressed gap -- reported as a finding rather than fixed, since closing it means editing client.ts production code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 6ebafcbd4e1f * test: migrate ensureFolderLoaded coverage to the write-chain-gated DFS ensureFolderLoaded's self-bootstrap walk (dfsFindFolder/ensureRootFolderState) no longer resolves through sdkCore.loadFolderMetadata -- it resolves each hop's PublishedNode directly and requires a real WriteChildRef (not just a SealedChildRef) to descend into and register a folder (68.1-02/68.1-23). Rebuilds every case (cached short-circuit, no-root-material null, root bootstrap, multi-hop descent, unreachable target, unresolvable sibling) on real @cipherbox/core seal fixtures instead of the retired mock seam. The old "skips a sibling whose key unwrap fails and still reaches the target" case described a resilience behavior the current fail-closed design does not provide: dfsFindFolder has no catch around unsealChildReadKey, so a corrupt sibling's AEAD failure now aborts the ENTIRE walk (T-68.1-01-02) rather than being skipped. Replaced with a test that locks in the actual current behavior instead of a stale claim. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: ac8a56b5cc5d * test: migrate file-version-op coverage to NodeContent and the write-chain replaceFile/restoreFileVersion/deleteFileVersion (68.1-09) now share the runFileVersionOp core: requireFolder -> resolveFileWriteChainKeys (resolves the file's own read/write keys from the parent's write-chain, requiring a real WriteChildRef) -> sdkCore.updateFileMetadata -> a folder republish ONLY when migratedIpnsPrivateKeyEncrypted is supplied (maybeRepublishFolderForFileMigration). The retired suite fixtured a pre-68.1-09 legacy FileMetadata shape and asserted a folder publish on every content edit, which is no longer how the folder-sequence-preserving design works. Rebuilds every case on NodeContent/UpdateFileContentParams with a real write-capable parent FolderState + WriteChildRef fixture (mirrors delete-item.test.ts / client-write-descriptor.test.ts): a plain content edit now asserts NO folder publish and an unchanged folder sequence, while the migration-key case asserts the conditional folder publish and the resulting sequence bump. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: a90e8339705c * test: enable downloadFromIpns and shareFolder unit coverage downloadFromIpns and shareFolder("throws when folder not loaded") both already work against current production code -- they only needed un-skipping and, for downloadFromIpns, migrating off the retired downloadAndDecrypt/legacy-FileMetadata premise onto the real current path (resolvePublishedNode -> unsealChildReadKey -> sdkCore.resolveFileMetadata -> sdkCore.downloadFileContent, added to the sdk-core mock factory since the full-replacement @cipherbox/crypto mock in this file removes the real decryptAesGcm/decryptAesCtr downloadFileContent needs). Locks in that the method emits no 'file:downloaded' event (unlike the sibling downloadFile()) and zeroes the resolved NodeContent.fileKey after use (D-09). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: abe6a5e929fe * docs: clarify integration.test.ts as a permanent live-stack gate Replaces the stale "TODO(phase 65): re-enable when write-chain stubs are implemented" comment -- these specs already exercise the CURRENT CipherBoxClient API against a real running API + docker stack (no sdk-core/crypto/core mocks at all), they were never blocked on write-chain stubs. This is a deliberate, permanent infra gate (not legacy, not phase-gated): documents what the suite is, why it stays describe.skip'd for the regular unit-test run, and how to run it against a live stack. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 3cd72c57fe1c * fix: guard collectDescendantIpnsNames against cyclic folder graphs The unenroll/unpin subtree walk recursed unconditionally into every folder child with no visited-set, so a cyclic or malicious folder graph (an ancestor listed as its own descendant) would recurse unbounded — stack overflow / hang. Thread a visited Set<string> exactly as dfsFindFolder does: mark each hop before any await, contribute an already-seen ipnsName once without re-walking, and seed the set with the entry node at both callers. Surfaced by Phase 72 test hardening (collect-subtree-ipns-names Test D). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 157b49baf87a * test: enable cycle-guard coverage for collectDescendantIpnsNames The production gap this suite previously only documented is now fixed: collectDescendantIpnsNames threads a visited: Set<string> cycle guard (mirrors dfsFindFolder), seeded by both entry collectors (collectRemovedItemIpnsNames / collectBinEntryIpnsNames). Replaces the placeholder it.skip Test D with a real, un-skipped test on the same real-crypto fixtures + deleteItem seam as Tests A-C: a cyclic folder graph A -> B -> A (B lists A back) now TERMINATES (no hang / no RangeError) and each node's subtree is expanded exactly once. Asserts B (whose only inbound path is A's forward edge) is collected exactly once -- the precise cycle-break witness -- and the full name list is finite and small ([FOLDER_A removed-item, FOLDER_B, FOLDER_A back-edge-leaf]), covering only the two distinct cycle nodes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 6670f8f4aab0 * docs(72): retire addressed source todos and add phase verification Move the 9 Phase 72 source todos (delete-drop, getWriteBodyParams fail-closed, listingCache/mirror, moveInSharedFolder branch, restore re-homing, helper dedup, write-chain-e2e seed, upload-batch mock, zeroize) to completed now that all 6 Success Criteria are verified, and add 72-VERIFICATION.md (status: passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: a160f8b944ee * remove unnecessary todo Entire-Checkpoint: 2395495c950e * test(sdk): add failing regression test for moveItem write-chain resurrection moveItem's SOURCE-folder publish omits baseWriteChildren, so a CAS-409 retry against a racing writer's stale write-body snapshot resurrects the just-dropped WriteChildRef via the legacy naive-union merge fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * fix(sdk): thread baseWriteChildren into moveItem SOURCE publish Fixes a HIGH-severity write-chain-resurrection gap: moveItem dropped the moved child's WriteChildRef from the SOURCE folder's write-body but published without baseWriteChildren, so a CAS-409 retry fell back to the legacy naive-union merge and could resurrect the dropped write-link if a racing writer's stale remote snapshot still carried it. deleteItem and restoreFromBin/permanentDeleteFromBin already threaded this through; moveItem was the last write-chain-mutating caller left exposed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 859224062bf0 * docs: remove more irrlevant todos Entire-Checkpoint: 32fd6015b8ec * fix(sdk): zero transient IPNS private key in getWriteBodyParams unsealNode materializes the folder's Ed25519 signing seed as a byproduct of reading writeChildren; getWriteBodyParams is the terminal owner of that transient copy but never zeroed it before this fix (D-09). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 8cb1a8cbd0cf * fix(sdk): zero transient source key in moveInSharedFolder The one-hop write-chain resolution unseals the SOURCE folder's own write-body to read writeChildren, which also materializes its ipnsPrivateKey as a byproduct. This copy is never used for signing (shareOps re-unseals its own copy from srcCtx) so this method is the terminal owner and must zero it (D-09), matching the existing dest-key-zeroing pattern in the same finally block. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 8065e473a632 * fix(sdk): drop lingering WriteChildRef in emptyBin and purgeExpired emptyBin and purgeExpiredEntries had no equivalent of permanentDeleteFromBin's SC#1 symmetry cleanup (dropping the removed item's lingering original-parent WriteChildRef), leaving those two bin-clearing paths exposed to the same unbounded write-body growth and resurrection risk. Extracted the cleanup into a shared dropLingeringWriteChildRef helper (fail-open, matches deleteItem's hygiene posture) and invoke it from all three bin-clearing paths. The extracted helper also fixes a stale write-body mirror: the no-folderTree branch updated originalParent.children/sequenceNumber but never originalParent.metadata.writeBody.writeChildren, so a subsequent getWriteBodyParams call (which prefers metadata.writeBody) would read the pre-trim writeChildren and silently reintroduce the dropped ref on the next publish. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * fix(sdk-core): honor concurrent remote deletion in write-body merge The base-aware write-body merge only protected LOCAL's own intentional deletes from resurrection by a stale remote snapshot (Test C). It did not protect the mirror case: a childId present in base and untouched in local, but concurrently deleted by the racing remote writer, was always carried forward because the merge kept every entry present in local regardless of what remote did to it -- silently reverting the remote writer's deletion on the next publish. Rewrote the merge as a symmetric 3-way prune: a childId present in base is now kept only if it is still present on BOTH sides; a childId absent from base (a genuinely concurrent add from either side) is always kept. Added Test D as a regression test; Tests A/B/C continue to pass unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * test(sdk-e2e): zero captured rotation keypair seeds in finally The test's own captured Ed25519 seeds (spied off generateEd25519Keypair for identity assertions) were zeroed after deriveIpnsName, but a throw from deriveIpnsName would have skipped the zeroing. Wrap in try/finally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * docs(72): capture deferred ship-review findings as todos Pre-existing / style / out-of-diff findings from the Phase 72 security + CodeRabbit review, deferred with the phase kept scoped: createSubfolder zeroize-on-error-path, wrapIpnsKeyForTee bytes-in/bytes-out refactor, and shared-write base-aware merge parity. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 594a34d3dbc8 * fix(sdk): restoreFromBin write-plane rehoming fails closed and cleanup is best-effort restoreFromBin re-homing previously let a wrong-key or tampered AEAD unseal/reseal failure fall through the same catch as a legitimate source-resolve miss, silently downgrading to a read-plane-only restore instead of failing closed. Scope the fail-open catch to only the getWriteBodyParams resolve step now, mirroring walkChildWriteKey (72-08): a resolve miss stays fail-open, an AEAD failure propagates. Also wrap the source-side cleanup publish (dropping the stale WriteChildRef from the original parent) in try/catch. The target publish already durably restores the item, so a cleanup failure must be best-effort and must not skip the bin-entry removal that follows. Adds tests asserting an AEAD failure during rehoming throws and a source-side cleanup publish failure never blocks the restore. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * test(sdk): tighten SC#2 fail-closed test isolation and mock-call assertions The SC#2 resolve-miss test returned null for every resolveIpnsRecord call, so it could pass even if the failure came from a reconcile pre-check rather than getWriteBodyParams itself. Return a matching record for the first call and null only for the second so the test precisely targets the intended fail-closed path. Also replace vacuous optional-chaining mock-call assertions (`mock.calls[0]?.[0]`, which passes trivially when the mock was never called) with an explicit toHaveBeenCalledTimes(1) check followed by a non-optional index. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL * docs(72): extract phase learnings Entire-Checkpoint: a2428178680f --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Phase 71 — Share-Invite Security and IPNS Data-Integrity (API)
Enforces share-invite authorization and cleans up IPNS/share data-integrity edges, plus a full share-plane rename purging "descriptor" vocabulary across the API, api-client, TS consumers, and Rust crates.
What shipped (9 plans, 4 waves)
read_descriptor_ref→encrypted_read_key,write_descriptor_ref→encrypted_write_key,root_ipns_name→share_root_ipns_name; TSreadDescriptorRef→encryptedReadKey, etc.; Rust serde fields aligned to the regenerated OpenAPI. "descriptor" fully purged from the share domain (api / api-client / sdk-core / sdk / web / sdk-e2e / crates).createInviteandcreateShareverify the caller registered the shared node inipns_records(creator marker) before persisting;ForbiddenException(403) on miss.rootNodeIdstays client-asserted (D-02 residual, bounded by the crypto ceiling).claim_countCHECK — folded into the greenfield cutover migration; root-uniqueness index intentionally dropped (D-03, already covered by vault uniqueness).upsertIpnsRecord; the D-06 real race is proven against live Postgres (sdk-e2e Test 21: exactly one 200 + one 409, never 500).revokeForItemsis now a singleDELETEquery.ShareInviteServicegetInvitesForItem/revokeInvitecoverage and hardened controller fixtures.Fix found during ship (SDK E2E gate)
The SDK E2E gate caught a real regression from the new D-05 guard: during a concurrent-add-mid-rotation, the rotation engine's republish embeds
seq === dbSeqafter a concurrent writer advanced the row, and the D-05 equivocation guard rejected it with a 400 instead of the 409 the engine merges. Fixed by classifying a forward-CAS attempt that lost the race (expectedSequenceNumber === embeddedSeq - 1) as a 409, not equivocation — equivocation protection is preserved (the write still never lands). Added a unit test;rotation-crash-safetyand the ipns unit suite are green.Quality gates
pnpm typecheck: green · apps/api Jest: 895 passing ·cargo check --all-targets: green · sdk-core 363 + sdk 362: greentee-republishand are environmental (a DB reset for the greenfield cutover wipedtee_key_state; the file is unchanged and phase 71 touches no TEE code) — restore recipe captured as a todo.Deferred (todos on this branch, out of scope for this phase)
assertRootOwnershiphelper extraction (dedup the D-01 gate)unwraperror path inupdateSharedSingleFile(pre-existing crypto hygiene)tee_key_statefor the sdk-e2e tee-republish suite🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Tests