Skip to content

feat: enforce share-invite authorization and IPNS data-integrity in the API#599

Merged
FSM1 merged 74 commits into
mainfrom
feat/share-invite-security-and-ipns-data-integrity-api
Jul 10, 2026
Merged

feat: enforce share-invite authorization and IPNS data-integrity in the API#599
FSM1 merged 74 commits into
mainfrom
feat/share-invite-security-and-ipns-data-integrity-api

Conversation

@FSM1

@FSM1 FSM1 commented Jul 9, 2026

Copy link
Copy Markdown
Owner

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)

  • D-10 share-plane renameread_descriptor_refencrypted_read_key, write_descriptor_refencrypted_write_key, root_ipns_nameshare_root_ipns_name; TS readDescriptorRefencryptedReadKey, 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).
  • D-01 / SC#1 child-ownership gatecreateInvite and createShare verify the caller registered the shared node in ipns_records (creator marker) before persisting; ForbiddenException (403) on miss. rootNodeId stays client-asserted (D-02 residual, bounded by the crypto ceiling).
  • D-07 / SC#2 widen-only claim — a re-claim over an existing share applies the later invite's grant only if it widens authority (read→write or higher generation); never downgrades write→read.
  • D-04 / SC#3 claim_count CHECK — folded into the greenfield cutover migration; root-uniqueness index intentionally dropped (D-03, already covered by vault uniqueness).
  • D-05 same-seq CID equivocation guard and D-06 first-publish INSERT-race → clean 409 in upsertIpnsRecord; the D-06 real race is proven against live Postgres (sdk-e2e Test 21: exactly one 200 + one 409, never 500).
  • SC#5 bulk-revokerevokeForItems is now a single DELETE query.
  • SC#6 unit coverage — restored ShareInviteService getInvitesForItem / revokeInvite coverage 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 === dbSeq after 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-safety and the ipns unit suite are green.

Quality gates

  • Verify: passed (16/16 truths, 0 gaps)
  • Secure: SECURED (12/12 threats closed; T-71-02 accepted documented residual)
  • Validate (Nyquist): compliant, 0 gaps
  • pnpm typecheck: green · apps/api Jest: 895 passing · cargo check --all-targets: green · sdk-core 363 + sdk 362: green
  • SDK E2E: 103/105 green. The 2 remaining failures are tee-republish and are environmental (a DB reset for the greenfield cutover wiped tee_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)

  • assertRootOwnership helper extraction (dedup the D-01 gate)
  • Zero file keys on the unwrap error path in updateSharedSingleFile (pre-existing crypto hygiene)
  • Reject a write-capable invite claimed without a re-wrapped write key (behavior decision)
  • Reconcile stale D-01 / greenfield language in the planning docs
  • Restore tee_key_state for the sdk-e2e tee-republish suite

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Share and invite APIs now use clearer encrypted-key fields and shared-root identifiers.
    • Added ownership checks to prevent unauthorized share and invite creation.
    • Added safer invite re-claim behavior that only increases permissions.
    • Added protection against conflicting IPNS updates and first-publish conflicts.
  • Bug Fixes

    • Bulk share revocation is more efficient and reports accurate results.
    • Improved handling of concurrent publishing and same-content retries.
  • Tests

    • Expanded unit and end-to-end coverage for authorization, invite lifecycle, key handling, and publishing races.

FSM1 and others added 30 commits July 9, 2026 13:35
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
…d D-04 into cutover (greenfield)

Entire-Checkpoint: 135a1d230a3f
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
@github-actions github-actions Bot added release:cipherbox-sdk:feat Minor version bump (new feature) for cipherbox-sdk release:web:feat Minor version bump (new feature) for web release:sdk-core:feat Minor version bump (new feature) for sdk-core release:sdk:feat Minor version bump (new feature) for sdk release:desktop:fix Patch version bump (bug fix) for desktop release:tee-worker:fix Patch version bump (bug fix) for tee-worker labels Jul 9, 2026
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Release Preview

Package Bump Label Source
api minor release:api:feat Direct (feat commit)
cipherbox-fuse minor release:cipherbox-fuse:feat Direct (feat commit)
cipherbox-sdk minor release:cipherbox-sdk:feat Direct (feat commit)
desktop minor release:desktop:fix Cascade (api minor)
sdk minor release:sdk:feat Direct (feat commit)
sdk-core minor release:sdk-core:feat Direct (feat commit)
tee-worker patch release:tee-worker:fix Cascade (sdk-core minor)
web minor release:web:feat Direct (feat commit)

Cascade Details

  • api minor -> desktop patch (direct dependency)
  • sdk-core minor -> tee-worker patch (direct dependency)

@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.43697% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.60%. Comparing base (d5486e5) to head (abe0a9a).

Files with missing lines Patch % Lines
packages/sdk-core/src/share/grant.ts 81.25% 3 Missing ⚠️
packages/sdk/src/client.ts 83.33% 2 Missing ⚠️
apps/api/src/shares/share-invite.service.ts 95.65% 0 Missing and 1 partial ⚠️
apps/api/src/shares/shares.service.ts 93.33% 0 Missing and 1 partial ⚠️
crates/fuse/src/write_ops/grant_scope.rs 87.50% 1 Missing ⚠️
crates/sdk/src/rotation/engine.rs 88.88% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            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     
Flag Coverage Δ
api 80.29% <92.30%> (+0.23%) ⬆️
api-client 80.29% <92.30%> (+0.23%) ⬆️
core 80.29% <92.30%> (+0.23%) ⬆️
crypto 80.29% <92.30%> (+0.23%) ⬆️
desktop 21.41% <ø> (-10.03%) ⬇️
rust 78.53% <92.85%> (?)
sdk 80.29% <92.30%> (+0.23%) ⬆️
sdk-core 80.29% <92.30%> (+0.23%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
apps/api/src/ipns/ipns.service.ts 87.19% <100.00%> (+0.55%) ⬆️
apps/api/src/shares/invites.controller.ts 82.75% <100.00%> (ø)
apps/api/src/shares/share-invites.controller.ts 74.07% <ø> (ø)
apps/api/src/shares/shares.controller.ts 71.18% <100.00%> (ø)
crates/api-client/src/shares.rs 77.14% <100.00%> (ø)
crates/fuse/src/write_ops/implementation/delete.rs 89.62% <100.00%> (ø)
crates/fuse/src/write_ops/implementation/rename.rs 69.03% <100.00%> (ø)
packages/sdk-core/src/rotation/engine.ts 79.98% <100.00%> (ø)
packages/sdk-core/src/rotation/scope.ts 100.00% <100.00%> (ø)
packages/sdk-core/src/share/navigate.ts 94.02% <100.00%> (ø)
... and 7 more

... and 92 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown

Greptile Summary

Phase 71 enforces share-invite authorization (D-01/SC#1 ownership gate, D-07/SC#2 widen-only re-claim), hardens IPNS data-integrity (D-05 equivocation guard, D-06 first-publish INSERT-race → 409), optimizes bulk revocation to a single DELETE query (SC#5), and executes a full rename purging "descriptor" vocabulary across the share domain.

  • D-01/SC#1: createShare and createInvite now read ipns_records to verify the caller registered the node before persisting the share/invite; a 403 is returned on miss. Correctly treated as defense-in-depth (non-authoritative) since the true boundary is cryptographic.
  • D-07/SC#2: claimInvite re-claim path applies a later invite's grant only if it widens authority (read→write or higher generation); a pre-transaction guard rejects write-capable claim attempts that omit encryptedWriteKey before the invite is consumed.
  • D-05/D-06 + IPNS forward-CAS race fix: upsertIpnsRecord distinguishes a genuine same-seq equivocation (400) from a forward-CAS attempt that lost a concurrent race (expectedSequenceNumber === embeddedSeq - 1 → fall through to 409 without triggering the equivocation guard).

Confidence Score: 5/5

Safe to merge; all new authorization gates and IPNS integrity checks are well-tested and the rename is consistent across all layers.

The D-01 ownership check, D-07 widen-only re-claim, D-05 equivocation guard, and D-06 first-publish race translation are all implemented correctly and covered by new unit and e2e tests. The isForwardCasRace detection (expectedSequenceNumber === embeddedSeq - 1) is logically sound: it preserves the equivocation-protection invariant while allowing the CAS UPDATE to produce the correct 409 for a lost rotation race. The bulk DELETE refactor in revokeForItems and the CHK_share_invites_claim_count constraint are straightforward. No regressions were found in the changed paths.

No files require special attention. share-invite.service.ts carries the highest complexity (re-claim branching), but the widen-only logic and the write-key presence gate are both covered by unit tests for all combinations.

Important Files Changed

Filename Overview
apps/api/src/shares/share-invite.service.ts Core of this PR: adds D-01 ownership gate, D-07 widen-only re-claim logic, write-key presence guard before transaction, and full rename; the generation-bump-via-read-only-invite stale write key edge case (previous thread) is a known open concern
apps/api/src/shares/shares.service.ts Adds D-01 ownership gate before createShare, replaces manager.find+remove with single QueryBuilder DELETE for revokeForItems (SC#5), full descriptor→encrypted-key rename; logic is clean and well-tested
apps/api/src/ipns/ipns.service.ts Adds D-05 equivocation guard (same-seq/different-CID → 400), introduces isForwardCasRace detection to avoid misclassifying a lost CAS race as equivocation, and adds D-06 first-publish INSERT-race 409 translation; logic is sound and covered by new unit tests
apps/api/src/migrations/1750000000000-ApiSchemaCutover.ts Renames columns (read_descriptor_ref→encrypted_read_key, write_descriptor_ref→encrypted_write_key, root_ipns_name→share_root_ipns_name), adds composite indexes for (sharer_id, share_root_ipns_name) on both shares and share_invites, and adds CHK_share_invites_claim_count DB-level CHECK constraint
apps/api/src/shares/shares.module.ts Adds IpnsRecord to TypeOrmModule.forFeature so both SharesService and ShareInviteService can inject the IpnsRecord repository for the D-01 ownership gate
apps/api/src/shares/entities/share.entity.ts Column rename (readDescriptorRef→encryptedReadKey, writeDescriptorRef→encryptedWriteKey, rootIpnsName→shareRootIpnsName) and adds IDX_shares_sharer_root composite index on entity level
apps/api/src/shares/entities/share-invite.entity.ts Column renames plus adds @check constraint on claim_count and @Index for (sharerId, shareRootIpnsName); consistent with migration SQL
apps/api/src/shares/share-invite.service.spec.ts Restores getInvitesForItem/revokeInvite coverage, adds generation-bump unit tests including a test for the Greptile P1 stale-write-key fix (write invite + write-capable existing + higher gen), and a no-downgrade backstop test
apps/api/src/ipns/ipns.service.spec.ts Adds three new D-05 unit tests: equivocation rejection (same-seq/different-CID), forward-CAS-race 409 passthrough, and idempotent same-CID re-sign; covers the isForwardCasRace distinction introduced in the service

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant C as Client
    participant SI as ShareInvitesController
    participant SIS as ShareInviteService
    participant IPNS as IpnsRecordRepo
    participant DB as Postgres

    Note over C,DB: createInvite (D-01/SC#1 ownership gate)
    C->>SI: "POST /invites {shareRootIpnsName, encryptedReadKey, ...}"
    SI->>SIS: createInvite(sharerId, dto)
    SIS->>IPNS: "findOne({ipnsName, userId}) — D-01 gate"
    alt not owner
        IPNS-->>SIS: null
        SIS-->>C: 403 ForbiddenException
    else owner confirmed
        IPNS-->>SIS: IpnsRecord
        SIS->>DB: INSERT share_invites
        SIS-->>C: "invite {token, shareRootIpnsName, ...}"
    end

    Note over C,DB: claimInvite (atomic widen-only re-claim D-07/SC#2)
    C->>SI: "POST /invites/:token/claim {encryptedReadKey, [encryptedWriteKey]}"
    SI->>SIS: claimInvite(token, claimerId, dto)
    SIS->>DB: findOne(invite) — pre-tx expiry/status check
    alt write-capable invite + no dto.encryptedWriteKey
        SIS-->>C: 400 BadRequestException
    end
    SIS->>DB: BEGIN TRANSACTION
    SIS->>DB: "UPDATE share_invites SET status=claimed WHERE ... AND claim_count < max_claims"
    alt "affected=0"
        SIS-->>C: 409 ConflictException
    end
    SIS->>DB: "findOne(Share, {sharerId, recipientId, rootNodeId})"
    alt existingShare found
        Note over SIS: D-07: widen-only (isWriteUpgrade OR isGenerationBump)
        SIS->>DB: UPDATE share (encryptedReadKey, [encryptedWriteKey, rootGeneration])
    else no existing share
        SIS->>DB: INSERT share (write authority from invite, not claimer input)
    end
    SIS->>DB: COMMIT
    SIS-->>C: "{shareId}"

    Note over C,DB: upsertIpnsRecord (D-05 equivocation + D-06 first-publish race)
    C->>IPNS: "publishRecord({ipnsName, embeddedSeq, expectedSeq, CID})"
    alt "embeddedSeq==dbSeq AND isForwardCasRace (expectedSeq==embeddedSeq-1)"
        Note over IPNS: Skip D-05 guard — lost CAS race, fall through to 409
    else "embeddedSeq==dbSeq AND same CID"
        Note over IPNS: Idempotent re-sign (isIdempotentRepublish=true)
    else "embeddedSeq==dbSeq AND different CID"
        IPNS-->>C: 400 BadRequestException (equivocation)
    end
    IPNS->>DB: "CAS UPDATE WHERE seq=expected AND gen<=incoming"
    alt "affected=0"
        IPNS-->>C: 409 ConflictException
    else "affected=1"
        IPNS-->>C: updated IpnsRecord
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant C as Client
    participant SI as ShareInvitesController
    participant SIS as ShareInviteService
    participant IPNS as IpnsRecordRepo
    participant DB as Postgres

    Note over C,DB: createInvite (D-01/SC#1 ownership gate)
    C->>SI: "POST /invites {shareRootIpnsName, encryptedReadKey, ...}"
    SI->>SIS: createInvite(sharerId, dto)
    SIS->>IPNS: "findOne({ipnsName, userId}) — D-01 gate"
    alt not owner
        IPNS-->>SIS: null
        SIS-->>C: 403 ForbiddenException
    else owner confirmed
        IPNS-->>SIS: IpnsRecord
        SIS->>DB: INSERT share_invites
        SIS-->>C: "invite {token, shareRootIpnsName, ...}"
    end

    Note over C,DB: claimInvite (atomic widen-only re-claim D-07/SC#2)
    C->>SI: "POST /invites/:token/claim {encryptedReadKey, [encryptedWriteKey]}"
    SI->>SIS: claimInvite(token, claimerId, dto)
    SIS->>DB: findOne(invite) — pre-tx expiry/status check
    alt write-capable invite + no dto.encryptedWriteKey
        SIS-->>C: 400 BadRequestException
    end
    SIS->>DB: BEGIN TRANSACTION
    SIS->>DB: "UPDATE share_invites SET status=claimed WHERE ... AND claim_count < max_claims"
    alt "affected=0"
        SIS-->>C: 409 ConflictException
    end
    SIS->>DB: "findOne(Share, {sharerId, recipientId, rootNodeId})"
    alt existingShare found
        Note over SIS: D-07: widen-only (isWriteUpgrade OR isGenerationBump)
        SIS->>DB: UPDATE share (encryptedReadKey, [encryptedWriteKey, rootGeneration])
    else no existing share
        SIS->>DB: INSERT share (write authority from invite, not claimer input)
    end
    SIS->>DB: COMMIT
    SIS-->>C: "{shareId}"

    Note over C,DB: upsertIpnsRecord (D-05 equivocation + D-06 first-publish race)
    C->>IPNS: "publishRecord({ipnsName, embeddedSeq, expectedSeq, CID})"
    alt "embeddedSeq==dbSeq AND isForwardCasRace (expectedSeq==embeddedSeq-1)"
        Note over IPNS: Skip D-05 guard — lost CAS race, fall through to 409
    else "embeddedSeq==dbSeq AND same CID"
        Note over IPNS: Idempotent re-sign (isIdempotentRepublish=true)
    else "embeddedSeq==dbSeq AND different CID"
        IPNS-->>C: 400 BadRequestException (equivocation)
    end
    IPNS->>DB: "CAS UPDATE WHERE seq=expected AND gen<=incoming"
    alt "affected=0"
        IPNS-->>C: 409 ConflictException
    else "affected=1"
        IPNS-->>C: updated IpnsRecord
    end
Loading

Reviews (4): Last reviewed commit: "fix(test): resolve pre-existing tsc --no..." | Re-trigger Greptile

Comment thread apps/api/src/shares/share-invite.service.ts
FSM1 added 2 commits July 10, 2026 01:42
…tale write key

Entire-Checkpoint: 2f4c7ebeaac0
Entire-Checkpoint: 2d77416f809a

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 win

Add missing @ApiResponse decorators for new error statuses.

createShare can now return 403 (ownership check per Phase 71 objectives) but only documents 201/401/404/409. updateGrant can throw BadRequestException (400 — mutual exclusivity) and ConflictException (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 with pnpm 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 win

Cover 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 win

Assert encrypted DTO serialization.

This test claims DTO fields are copied but does not verify encryptedReadKey, optional encryptedWriteKey, or itemNameEncrypted. A regression in encrypted-key persistence or Buffer conversion 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 | 🔵 Trivial

Consider adding indexes on share_root_ipns_name for shares and share_invites.

revokeForItems issues WHERE sharer_id = :sharerId AND share_root_ipns_name IN (:...names) against both tables. The existing sharer_id index narrows the scan, but the IN filter 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

📥 Commits

Reviewing files that changed from the base of the PR and between d5486e5 and 07b144f.

⛔ Files ignored due to path filters (13)
  • packages/api-client/openapi.json is excluded by !packages/api-client/**
  • packages/api-client/src/generated/invites/invites.ts is excluded by !**/generated/**, !**/generated/**, !packages/api-client/**
  • packages/api-client/src/generated/shares/shares.ts is excluded by !**/generated/**, !**/generated/**, !packages/api-client/**
  • packages/api-client/src/models/claimInviteDto.ts is excluded by !packages/api-client/**
  • packages/api-client/src/models/createInviteDto.ts is excluded by !packages/api-client/**
  • packages/api-client/src/models/createShareDto.ts is excluded by !packages/api-client/**
  • packages/api-client/src/models/createShareResponseDto.ts is excluded by !packages/api-client/**
  • packages/api-client/src/models/inviteDataResponseDto.ts is excluded by !packages/api-client/**
  • packages/api-client/src/models/inviteResponseDto.ts is excluded by !packages/api-client/**
  • packages/api-client/src/models/receivedShareResponseDto.ts is excluded by !packages/api-client/**
  • packages/api-client/src/models/sentShareResponseDto.ts is excluded by !packages/api-client/**
  • packages/api-client/src/models/shareInvitesControllerListInvitesParams.ts is excluded by !packages/api-client/**
  • packages/api-client/src/models/updateGrantDto.ts is 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.md
  • apps/api/src/ipns/ipns.service.spec.ts
  • apps/api/src/ipns/ipns.service.ts
  • apps/api/src/migrations/1750000000000-ApiSchemaCutover.ts
  • apps/api/src/shares/dto/claim-invite.dto.ts
  • apps/api/src/shares/dto/create-invite.dto.ts
  • apps/api/src/shares/dto/create-share.dto.ts
  • apps/api/src/shares/dto/get-invites-for-item-query.dto.ts
  • apps/api/src/shares/dto/invite-response.dto.ts
  • apps/api/src/shares/dto/share-response.dto.ts
  • apps/api/src/shares/dto/update-grant.dto.ts
  • apps/api/src/shares/entities/share-invite.entity.ts
  • apps/api/src/shares/entities/share.entity.ts
  • apps/api/src/shares/invites.controller.spec.ts
  • apps/api/src/shares/invites.controller.ts
  • apps/api/src/shares/share-invite.service.spec.ts
  • apps/api/src/shares/share-invite.service.ts
  • apps/api/src/shares/share-invites.controller.spec.ts
  • apps/api/src/shares/share-invites.controller.ts
  • apps/api/src/shares/shares.controller.spec.ts
  • apps/api/src/shares/shares.controller.ts
  • apps/api/src/shares/shares.module.ts
  • apps/api/src/shares/shares.service.spec.ts
  • apps/api/src/shares/shares.service.ts
  • apps/web/src/components/file-browser/ShareDialog.tsx
  • apps/web/src/hooks/useAuth.ts
  • apps/web/src/hooks/useMutationFailureUx.ts
  • apps/web/src/hooks/useSharedNavigationActions.ts
  • apps/web/src/lib/crypto/key-wrapping.ts
  • apps/web/src/services/invite.service.ts
  • apps/web/src/services/owner-reconcile.service.ts
  • apps/web/src/services/rotation-driver.service.ts
  • apps/web/src/services/share.service.ts
  • apps/web/src/stores/share.store.ts
  • crates/api-client/src/shares.rs
  • crates/fuse/src/write_ops/grant_scope.rs
  • crates/fuse/src/write_ops/implementation/delete.rs
  • crates/fuse/src/write_ops/implementation/rename.rs
  • crates/sdk/src/rotation/engine.rs
  • packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts
  • packages/sdk-core/src/__tests__/rotation/scope.test.ts
  • packages/sdk-core/src/__tests__/rotation/write-revocation.test.ts
  • packages/sdk-core/src/__tests__/share/grant.test.ts
  • packages/sdk-core/src/__tests__/share/navigate.test.ts
  • packages/sdk-core/src/rotation/engine.ts
  • packages/sdk-core/src/rotation/scope.ts
  • packages/sdk-core/src/share/grant.ts
  • packages/sdk-core/src/share/navigate.ts
  • packages/sdk/src/__tests__/client-rotation.test.ts
  • packages/sdk/src/__tests__/client-write-descriptor.test.ts
  • packages/sdk/src/__tests__/download-shared-file.test.ts
  • packages/sdk/src/__tests__/owner-reconcile.test.ts
  • packages/sdk/src/__tests__/resolve-share-root.test.ts
  • packages/sdk/src/__tests__/update-shared-single-file.test.ts
  • packages/sdk/src/client.ts
  • packages/sdk/src/share/owner-reconcile.ts
  • packages/sdk/src/types.ts
  • tests/sdk-e2e/src/suites/invite-link.test.ts
  • tests/sdk-e2e/src/suites/ipns-publish-gate.test.ts
  • tests/sdk-e2e/src/suites/read-chain-navigation.test.ts
  • tests/sdk-e2e/src/suites/rotation-crash-safety.test.ts
  • tests/sdk-e2e/src/suites/share-operations.test.ts
  • tests/sdk-e2e/src/suites/write-chain-rotation.test.ts

Comment thread .planning/STATE.md Outdated
Comment thread apps/api/src/shares/share-invite.service.ts
Comment thread apps/api/src/shares/share-invites.controller.ts
Comment thread apps/api/src/shares/share-invite.service.ts
@FSM1 FSM1 enabled auto-merge (squash) July 10, 2026 00:12
@FSM1 FSM1 merged commit 703bc00 into main Jul 10, 2026
30 checks passed
FSM1 added a commit that referenced this pull request Jul 10, 2026
…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
FSM1 added a commit that referenced this pull request Jul 10, 2026
…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
FSM1 added a commit that referenced this pull request Jul 10, 2026
…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>
FSM1 added a commit that referenced this pull request Jul 10, 2026
…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
FSM1 added a commit that referenced this pull request Jul 10, 2026
…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>
FSM1 added a commit that referenced this pull request Jul 10, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:api:feat Minor version bump (new feature) for api release:cipherbox-fuse:feat Minor version bump (new feature) for cipherbox-fuse release:cipherbox-sdk:feat Minor version bump (new feature) for cipherbox-sdk release:desktop:fix Patch version bump (bug fix) for desktop release:sdk:feat Minor version bump (new feature) for sdk release:sdk-core:feat Minor version bump (new feature) for sdk-core release:tee-worker:fix Patch version bump (bug fix) for tee-worker release:web:feat Minor version bump (new feature) for web

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant