Skip to content

fix(web): reconcile SDK folderTree sequence to stop deleted-file resurrection#489

Merged
FSM1 merged 1 commit into
mainfrom
fix/folder-tree-desync-resurrects-deleted-file
Jun 14, 2026
Merged

fix(web): reconcile SDK folderTree sequence to stop deleted-file resurrection#489
FSM1 merged 1 commit into
mainfrom
fix/folder-tree-desync-resurrects-deleted-file

Conversation

@FSM1

@FSM1 FSM1 commented Jun 14, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes the deterministic web-e2e failure in recycle-bin TC08: permanent delete of versioned file reclaims all quota (reproduced on both runs of 1abceb4b8). After a file is replaced and then soft-deleted, the item is resurrected in the file list (and also moved to the bin), so waitForItemToDisappear times out.

Root cause

A two-store desync exposed by the new 409-merge (#488):

  • The web app holds folder state in two places: the Zustand useFolderStore and the SDK client's internal folderTree.
  • ensureFolderRegistered treats the folderTree as authoritative and no-ops once a folder is registered.
  • A file replace (updateFile) publishes folder metadata directly via sdk-core, advancing the store's IPNS sequence and the child's modifiedAt — but the SDK folderTree is never told, so it stays at the old sequence and old modifiedAt.
  • The subsequent deleteToBin reads the stale folderTree, so it:
    1. publishes the removal at a stale sequence → 409, then
    2. merges against a stale baseChildren (modifiedAt = t1) vs remote (modifiedAt = t2). The merge's edit-beats-delete branch (folder/merge.ts) sees remote.modifiedAt > base.modifiedAt and resurrects the just-deleted file.

The merge is correct given its inputs; the inputs (stale base/sequence) are the defect — so the fix is at the desync, not the merge.

Fix

  • packages/sdk/src/client.ts — new reconcileFolderState(ipnsName, children, sequenceNumber): advances an already-tracked folder's children + sequence only when strictly newer (higher IPNS sequence), preserving keys. SDK-routed mutations stay authoritative.
  • apps/web/src/lib/sdk-provider.tsensureFolderRegistered reconciles the folderTree forward to the store's state instead of no-opping when already registered.

With current state, the delete publishes at the correct sequence → no 409, no merge, file removed. Degrades safely: if the background replace-publish fails, the delete sequence still matches remote and no conflict arises.

Tests

  • packages/sdk/src/__tests__/client-extended.test.ts — 4 unit tests for reconcileFolderState (adopts-newer, ignores equal/older, no-op-when-absent, copies-array). Full file: 30/30 pass.
  • Web tsc --noEmit clean; ESLint clean on all changed files.

Validation

A web-e2e run is dispatched against this branch — since TC08 fails deterministically on the base commit, a green run is a definitive confirmation.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved folder state synchronization to prevent conflicts during updates.
  • Tests

    • Added comprehensive test coverage for folder state reconciliation logic.

…rrection

A file replace publishes folder metadata directly through sdk-core, advancing
the Zustand store's IPNS sequence and child modifiedAt while leaving the SDK
client's folderTree untouched. A following soft-delete reads that stale
folderTree, so it publishes at a stale sequence and merges its delete against a
stale base. The new 409 merge's edit-beats-delete heuristic then sees
remote.modifiedAt greater than the stale base.modifiedAt and resurrects the
just-deleted file, leaving it in the list while also moving it to the bin.

This is the root cause of the deterministic web-e2e failure in recycle-bin TC08
(permanent delete of versioned file reclaims all quota): after replace then
delete, the item never disappears from the file list.

Fix the desync rather than the merge, whose inputs were stale:

- Add CipherBoxClient.reconcileFolderState, which advances an already-tracked
  folder's children and sequence in place only when given a strictly-newer IPNS
  sequence, preserving keys. SDK-routed mutations stay authoritative.
- ensureFolderRegistered now reconciles the folderTree forward to the store's
  children and sequence instead of no-opping when the folder is already
  registered.

With current state, the delete publishes at the correct sequence so no 409 and
no merge occur. Degrades safely: if the background replace publish fails, the
delete sequence still matches remote and no conflict arises.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 0d7f33e40012
@coderabbitai

coderabbitai Bot commented Jun 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d630f5d6-f6a2-49c0-97c4-9e49f38ab9e7

📥 Commits

Reviewing files that changed from the base of the PR and between 1abceb4 and 567b6e0.

📒 Files selected for processing (3)
  • apps/web/src/lib/sdk-provider.ts
  • packages/sdk/src/__tests__/client-extended.test.ts
  • packages/sdk/src/client.ts

Walkthrough

Adds CipherBoxClient.reconcileFolderState(ipnsName, children, sequenceNumber) to the SDK, which updates a tracked folder's children and sequence number only when the incoming IPNS sequence is strictly higher. Updates ensureFolderRegistered in the web SDK provider to call this method instead of returning early when the folder is already registered.

Changes

Folder State Reconciliation

Layer / File(s) Summary
reconcileFolderState implementation and tests
packages/sdk/src/client.ts, packages/sdk/src/__tests__/client-extended.test.ts
New public method on CipherBoxClient conditionally replaces children, sequenceNumber, and lastLoadedAt for a tracked folder when the incoming sequence is strictly greater; no-ops otherwise. Tests cover newer-sequence adoption with key preservation, equal/older rejection, unregistered-folder no-op, and defensive array copying.
ensureFolderRegistered reconciliation wiring
apps/web/src/lib/sdk-provider.ts
The early-return branch in ensureFolderRegistered (triggered when client.hasFolder(ipnsName) is true) is replaced with a call to client.reconcileFolderState, forwarding the Zustand-observed children and sequenceNumber into the SDK before any subsequent mutation.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Suggested labels

release:web:fix, release:sdk:fix

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding a reconciliation mechanism to prevent deleted-file resurrection by syncing SDK folderTree sequence state.
Linked Issues check ✅ Passed The PR implements the folder state management infrastructure required by issue #39, specifically ensuring the folderTree synchronization functions correctly during concurrent file operations.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing the folder state desynchronization issue described in the objectives; no out-of-scope modifications are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/folder-tree-desync-resurrects-deleted-file

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added release:web:fix Patch version bump (bug fix) for web release:sdk:fix Patch version bump (bug fix) for sdk labels Jun 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Release Preview

Package Bump Label Source
sdk patch release:sdk:fix Direct (fix commit)
web minor release:web:fix Direct (fix commit)

@codecov

codecov Bot commented Jun 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 62.88%. Comparing base (1abceb4) to head (567b6e0).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #489      +/-   ##
==========================================
+ Coverage   62.82%   62.88%   +0.06%     
==========================================
  Files         139      139              
  Lines       10350    10359       +9     
  Branches     1123     1127       +4     
==========================================
+ Hits         6502     6514      +12     
+ Misses       3612     3609       -3     
  Partials      236      236              
Flag Coverage Δ
api 84.78% <100.00%> (+0.07%) ⬆️
api-client 84.78% <100.00%> (+0.07%) ⬆️
core 84.78% <100.00%> (+0.07%) ⬆️
crypto 84.78% <100.00%> (+0.07%) ⬆️
sdk 84.78% <100.00%> (+0.07%) ⬆️
sdk-core 84.78% <100.00%> (+0.07%) ⬆️

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

Files with missing lines Coverage Δ
packages/sdk/src/client.ts 81.07% <100.00%> (+0.45%) ⬆️
🚀 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.

@FSM1 FSM1 merged commit e7ea982 into main Jun 14, 2026
29 checks passed
FSM1 added a commit that referenced this pull request Jun 15, 2026
* docs(phase-47): add research, validation, and patterns

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(47): create phase plan for SDK folder-state and publish consolidation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(phase-47): resolve plan-checker findings in research and plans

Mark RESEARCH open questions resolved, enrich restore/deleteFileVersion
action detail, and correct plan-05 verification-gate file list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(47-01): add failing publishWithCas tests

Six behavior tests covering success, 409 merge retry, ConflictError
exhaustion, prunedCids passthrough, non-409 rethrow, and backoff toggle.
All fail with module-not-found until cas.ts is implemented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(sdk-core): unify file and folder IPNS CAS-retry into publishWithCas

Extract the duplicated 409-CAS-retry skeleton from updateFolderMetadataAndPublish
and updateFileMetadata into one generic publishWithCas<TData> helper. Both paths
now use maxAttempts 4 + backoff (file path reconciled up from 2/no-backoff).
updateFolderMetadataAndPublish encapsulates the baseChildren snapshot internally.
fileIpnsPrivateKey.fill(0) preserved in updateFileMetadata finally on all exit paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sdk): unpin prunedCids in updateSharedFile and drop redundant updatedChildren

updateSharedFile now destructures prunedCids from updateFileMetadata and
fire-and-forget unpins each via sdkCore.unpinFromIpfs(ctx, cid).catch, closing
the shared-file pin leak. Unpin failures (e.g. Phase-42 server 403 for a
non-owned CID) are logged, never thrown. Also removes the redundant pre-merge
updatedChildren field from all four shared-write return shapes; only
publishedChildren remains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(sdk): make SDK client the single owner of file folder-state via new methods

Add replaceFile, restoreFileVersion, and deleteFileVersion to CipherBoxClient.
Each method owns the publish, folderTree sequence bookkeeping, and folder:updated
emission internally, reading authoritative state from folderTree.get() at call
time. Methods accept pre-resolved fileIpnsPrivateKey and currentMetadata; they do
not zero keys (updateFileMetadata owns zeroing). replaceFile returns prunedCids
for caller-side unpin. Deletes the reconcileFolderState band-aid, now dead by
construction once the web bypass paths route through these methods.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(web): route file replace and version edits through SDK client methods

useFileOperations.updateFile now calls client.replaceFile; useFileVersions
restore/delete call client.restoreFileVersion / client.deleteFileVersion. These
hooks no longer publish folder metadata directly or write folder state to
Zustand; the folder:updated subscription is the sole writer of children and
sequenceNumber, closing the PR #489 desync race. file-metadata.service version
logic is split into pure transforms for the client to publish. fileIpnsPrivateKey
is resolved before the call and zeroed in the hook finally; owner-path prunedCids
unpin stays in the web hook. Removes the reconcileFolderState call in
ensureFolderRegistered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(web): add folder.store projection tests for folder:updated subscription

Prove the subscribeToSdk handler is the sole writer of children and
sequenceNumber, projecting folder:updated and folder:loaded events into Zustand
keyed by ipnsName reverse-lookup, including the root folder, with a no-op for
unknown names. Locks in store folder-state as projection-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(phase-47): add security threat verification

All six STRIDE threats from the Phase 47 plan threat models verified CLOSED:
T-47-01 key zeroing, T-47-02 CAS sequence handling, T-47-04 shared-file unpin,
T-47-05 stale updatedChildren removal, T-47-06 folder-state drift, T-47-SC
supply-chain (accepted: no new dependencies). threats_open: 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(phase-47): add goal-backward verification report

5/5 must-haves verified, zero blockers. Confirms reconcileFolderState deleted
(repo-wide grep zero), SDK client folderTree is the single source of truth via
replaceFile/restoreFileVersion/deleteFileVersion emitting folder:updated, store
projection-only, publishWithCas unified across file and folder, fill(0) key
zeroing intact, shared-write pin leak closed. Status human_needed: manual TC08
recycle-bin resurrection check pending on a live stack. The 3 live-API
integration test failures are environmental (no running server, skipped in CI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(sdk-core): type publishWithCas merge base as TData or undefined

Remove the unsafe 'params.baseData as TData' assertion in publishWithCas. baseData
is optional, so the merge callback's base parameter is now typed TData | undefined
to reflect reality — the latest-wins file path omits baseData and never reads base,
while the folder path already defends with 'base ?? []'. No runtime behavior change;
removes a type hole flagged by CodeRabbit. sdk-core/sdk build and web tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sdk-core): symmetric backoff jitter and zero file IPNS key on all exit paths

Address CodeRabbit review on PR #494:

- cas.ts retryDelayMs: the jitter multiplier (0.5 + Math.random() * 0.5) only
  spanned [0.5x, 1.0x) — one-sided -50%..0%, biased toward earlier retries and
  weakening collision spreading. Use (0.5 + Math.random()) for the documented
  symmetric +/-50% jitter => [0.5x, 1.5x).
- file/index.ts updateFileMetadata: resolveIpnsRecord and the not-found throw ran
  before the try/finally, so fileIpnsPrivateKey.fill(0) was skipped if either
  failed. Move both inside the try so the key is zeroed on all exit paths
  (T-47-01 caller-owned zeroing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 125df9e39274

* fix(sdk): unpin conflict-merge prunedCids on file version delete

Address CodeRabbit CLI review findings on PR #494:

- Major: client.deleteFileVersion discarded updateFileMetadata's return, dropping
  prunedCids. Deleting a version never caps history, but a 409-conflict merge round
  inside updateFileMetadata can re-add versions past the cap; those CIDs leaked as
  orphaned pins. deleteFileVersion now returns { deletedCid, prunedCids } (matching
  replaceFile / restoreFileVersion), and the useFileVersions delete hook unpins them
  alongside deletedCid.
- Minor: add two publishWithCas tests in cas.test.ts — multi-round prunedCids
  accumulation + de-dup, and 409-then-null-re-resolve throwing ConflictError without
  decode/merge — plus a client-file-ops test asserting deleteFileVersion surfaces
  prunedCids.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: cb78cd621535

* fix(web): unpin restore-merge prunedCids and drop dead client-bypassing upload handlers

Folds in the follow-up from the deleteFileVersion review plus a full audit of web
mutative paths for SDK-client routing (phase 47 folder-state ownership).

- restoreFileVersion hook: like deleteFileVersion, the SDK's returned prunedCids
  (produced by a 409-conflict merge inside updateFileMetadata) were discarded; only
  the web-computed cap-prune CIDs were unpinned. Capture the SDK return and unpin the
  deduped union of both sets so conflict-merge-pruned CIDs no longer leak as pins.
- useFileOperations: remove dead handleAddFile / handleAddFiles. They called sdk-core
  addFileToFolder / addFilesToFolder directly (old pattern that bypasses the SDK
  client folderTree and would desync useFolderStore), but nothing invokes them — the
  active upload path is getSdkClient().uploadFiles() via useDropUpload. Drop them, the
  now-unused imports, and the addFile/addFiles re-exports in useFolder.

Audit result: all active owned-folder mutations (folder CRUD, file replace, version
restore/delete, bin ops) already route through SDK client methods. Shared-folder
writes (useSharedWriteOps) use a separate SharedWriteContext path by design (shared
folders are not in the owner's folderTree) and are out of scope. Non-folder IPNS
records (device-registry, vault-settings, BYO config) and vault-init bootstrap
correctly publish directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 1e934539e49e

* docs: capture todo - Route shared-folder writes through the SDK client

The web shared-folder write path (useSharedWriteOps) still calls sdk share-write
functions directly via SharedWriteContext, bypassing client.folderTree. This is the
lone folder-state mutation not consolidated by Phase 47 / PR #494 (which routed all
owned-folder mutations through the SDK client). Captured as a medium, multi-PR
follow-up to extend single folder-state ownership to shared folders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 1f67ab10e915

* docs: mark phase 46 and 47 todos complete

Move the 6 phase-46 (desktop FUSE data-loss + replay hardening, merged to main)
and 4 phase-47 (SDK folder-state + publish consolidation, PR #494) captured todos
from pending/ to completed/, and check their ROADMAP scope boxes. Both phases'
verification reports confirm delivery (46: 6/6 must-haves verified; 47: all
requirements verified). STATE pending count 19 -> 9.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 1d472ff0248e

* docs: record phase 46 Linux remount-after-SIGKILL UAT pass

The only open item on phase 46 was the human UAT for REQ-3 (Linux stale-mount
auto-recovery), which could not run on the macOS verification host. Confirmed
PASSED on a Linux host 2026-06-15 — after SIGKILL mid-mount, the app auto-recovers
the stale mount and the vault remounts cleanly with no error/notify. Verification
status human_needed -> verified; STATE updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 08c705a01408

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@FSM1 FSM1 deleted the fix/folder-tree-desync-resurrects-deleted-file branch June 16, 2026 01:16
FSM1 added a commit that referenced this pull request Jun 16, 2026
…resolve

- refreshSharedFolder(shareId) re-resolves IPNS via sdkCore.loadFolderMetadata
- applies the #489 sequence-guard: stale/equal resolve re-emits existing state
- adopts a newer sequence into sharedFolderTree and emits sharedFolder:updated
- throws Shared folder not loaded on an unloaded shareId
- TDD-covered: newer adopt, stale no-clobber, null no-op, unloaded throw
FSM1 added a commit that referenced this pull request Jun 16, 2026
…resolve

- refreshSharedFolder(shareId) re-resolves IPNS via sdkCore.loadFolderMetadata
- applies the #489 sequence-guard: stale/equal resolve re-emits existing state
- adopts a newer sequence into sharedFolderTree and emits sharedFolder:updated
- throws Shared folder not loaded on an unloaded shareId
- TDD-covered: newer adopt, stale no-clobber, null no-op, unloaded throw
FSM1 added a commit that referenced this pull request Jun 16, 2026
…ame at rest (#500)

* docs: add phase 48 — SDK self-bootstrap regression fix and shared-folder consolidation

Bundles three pending todos onto one branch plus the PR #498 web-e2e regression:
- REQ-1 (P0): fix self-bootstrap loadFolder clobbering fresher folderTree state with a stale IPNS snapshot (regressed main web-e2e, run 27587113911)
- REQ-2 (#9): remove redundant web ensureFolderRegistered seeding + useFolderNavigation unwrap, gated on REQ-1
- REQ-3 (#8): route shared-folder writes through the SDK client
- REQ-4 (#5 / Phase-14 M1): encrypt share itemName at rest via ECIES
Defers CRDT-IPNS-inbox research (#2). REQ-1 acceptance: pre-merge web-e2e via 'gh workflow run web-e2e.yml --ref <branch>'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: f826ffa63025

* docs(48): research phase domain

Entire-Checkpoint: ed421ad6df8c

* docs(48): add context and validation strategy

Locks REQ-3 (sibling sharedFolderTree by shareId) and the two REQ-4 policy decisions: A2=lazy client backfill of legacy plaintext itemName, A3=include the invite flow. Adds Nyquist VALIDATION.md (REQ-1 acceptance = pre-merge web-e2e dispatch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: a1ae21343869

* docs(48): create phase plan (6 plans, 3 waves)

Entire-Checkpoint: 9b36cdf380e5

* docs(48): add pattern map

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 66a841737a7a

* test(48-01): add RED reconcile guard test for loadFolder

- Three cases: keep-fresher (RED), absent-loads (green), older-overwritten (green)
- Test A confirms the bug: existing sequenceNumber=5n clobbered by stale snapshot=3n
- Uses bigint literals and vitest *.test.ts convention per memory

Entire-Checkpoint: ebe50165e85d

* feat(48-01): add sequence guard to loadFolder to prevent stale clobber

- Read existing folderTree entry after IPNS resolve
- If existing.sequenceNumber >= result.sequenceNumber, keep existing and skip set()
- Emits folder:loaded with existing children/sequenceNumber on the guard path
- Absent folders still resolve and set normally (no #498 regression)
- Fixes P0: bin-restore and version-restore regressed in main web-e2e run 27587113911

Entire-Checkpoint: 035cb28ff942

* docs(48-01): SUMMARY and STATE for plan 01 checkpoint

Tasks 1-2 complete (RED+GREEN); awaiting Task 3 PRE-MERGE web-e2e gate.

Entire-Checkpoint: c531516e1121

* fix(48-01): re-resolve bin before auto-repair to prevent empty clobber

loadBin treated a null resolveIpnsRecord as 'bin empty' and published an empty record with no expectedSequenceNumber, so a transient cold-cache null after page.reload (with two concurrent loadBin calls) destroyed the real bin record server-side — the deleted item vanished from the bin after reload (bin-restore-after-reload.spec.ts step 6). Re-resolve before the destructive empty publish, and re-resolve after publishing to adopt any real higher-sequence record. SDK-only; 183 SDK tests pass.

Unsigned: 1Password SSH signer wedged mid-run (AFK); needs re-sign or admin-merge before landing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 23d236999200

* fix(48-01): guard folder-nav redirect so a stale subfolder resolve cannot bounce an off-route user

A subfolder resolve that fails after the user has navigated to another
route force-navigated to /files via the catch block, guarded only by
latestNavTarget. A react-router nav to /bin does not update that ref, so
the redirect bounced the user off /bin back to /files. Add a route-still-here
guard using the live pathname before the redirect fires.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 5ef54e2e74a7

* docs(48): REQ-1 gate blocked handoff - bin-restore-after-reload still red

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 8c2131ef9dab

* docs(48): correct REQ-1 root cause from trace - bin IPNS 404 auto-repair clobber, not nav

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 86e784543056

* fix(48-01): stop loadBin from clobbering the bin with an empty auto-repair publish

loadBin previously published an empty bin record on a double-null resolve via saveBinMetadata with no expectedSequenceNumber; the API then unconditionally incremented the sequence and overwrote latestCid with the empty-bin CID, wiping the real bin after delete+reload (two concurrent loadBin calls could both clobber). The API /ipns/resolve already DB-cache-falls-back, so a client-side null means the record is genuinely absent at that instant - publishing empty on read is never safe.

Changes (all client-side, no API change):
- loadBin: remove the destructive empty-bin auto-repair publish. On no record, return an in-memory empty BinState (entries=[], sequenceNumber=0) WITHOUT publishing. The first addToBin lazily creates the real record at seq 0+1=1, matching the API create-path. Added a bounded retry (6x ~500ms) to convert a transient null into a successful load.
- deleteToBin: self-heal by lazily loading the bin when binState is null. Bin init is fire-and-forget on login, so a delete soon after login/reload previously threw BinNotLoadedError and the web fell back to a HARD delete - the item vanished instead of moving to the bin. This was the actual cause of recycle-bin failures.
- client.loadBin: anti-clobber guard - the in-memory empty fallback never overwrites or broadcasts over an already-loaded non-empty bin.

Tests: invert the old bin.test that asserted the destructive empty publish to assert loadBin does NOT publish on null; keep the does-not-clobber test; add deleteToBin self-heal + loadBin anti-clobber tests. Full SDK suite green; recycle-bin.spec E2E green 8/8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 5869de8fb5a5

* fix(48-01): keep isLoading true during session restore to stop protected-route login bounce

After a reload, CoreKit re-initializes (coreKitInitialized flips true) a beat before restoreSession sets isLoggingIn/isAuthenticated, leaving a transient where !isLoading && !isAuthenticated are both true. A protected route mounting in that gap (opening /bin right after a reload) hit BinPage's auth guard and redirected /bin -> / -> /files, so the bin never rendered (bin-restore-after-reload.spec.ts step 6). Including (coreKitLoggedIn && !isAuthenticated) in isLoading keeps it true until restore resolves; a genuinely logged-out user has coreKitLoggedIn false so the login redirect still fires. Verified locally: the spec passes end-to-end under NODE_ENV=test (2x).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 8892055ce642

* refactor: remove web folder-seeding now that SDK self-bootstraps

Delete ensureFolderRegistered and all 14 call sites across
useDropUpload, useFileOperations, useFileVersions, and
useFolderMutations. The SDK requireFolder chokepoint (PR #498)
now self-bootstraps folders from the root IPNS key on first
mutation, making these web-side pre-seed calls redundant.

Also removes the unused FolderNode import from sdk-provider.ts.
useFolderNavigation.ts requires no change: the key-unwrap there
serves the display/metadata-load path, not SDK seeding.

REQ-2 (#9). Typecheck and lint green.

* docs(48-02): complete REQ-2 web folder-seeding removal plan

Entire-Checkpoint: 3cc6e6139aaf

* feat: add shared-folder state contracts to SDK

- Add SharedFolderState type carrying share-context fields
- Add SharedFolderTree keyed by shareId with key-zeroing on delete/clear
- Add sharedFolder:updated event member to SdkEvent union

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: add shared-folder tree isolation and client shared-write tests

- SharedFolderTree per-share isolation + key-zeroing (GREEN)
- client.uploadToSharedFolder read/delegate/write-back/emit contract (RED)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: SDK client owns shared-folder state and publish

- Add sharedFolderTree field + loadSharedFolder/hasSharedFolder/getSharedFolderState/unloadSharedFolder
- Add five shared write methods delegating to share/shared-write.ts via publishWithCas
- Each method reads state, builds context, writes back, emits sharedFolder:updated
- Export SharedFolderState and SharedFolderTree from index

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: complete 48-03 shared-folder SDK ownership plan

Entire-Checkpoint: dc7607abe28a

* feat: encrypt share itemName at rest via additive item_name_encrypted column

- Add additive nullable item_name_encrypted bytea to shares and share_invites
  via migration EncryptShareItemName1749200000000 (no data UPDATE; server is
  zero-knowledge and cannot re-encrypt legacy plaintext rows)
- Add itemNameEncrypted entity columns plus hex-validated optional DTO fields on
  create-share, create-invite, claim-invite and the response DTOs
- Persist client-supplied ECIES ciphertext in createShare, createInvite and the
  invite-claim path; server never encrypts (REQ-4 decision A3)
- Extend ECIES round-trip test for UTF-8 itemName and add ciphertext-persist plus
  no-server-encrypt service assertions
- Regenerate @cipherbox/api-client to expose itemNameEncrypted

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: complete 48-05 REQ-4 API itemName encryption plan

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: c873f80a93e3

* feat(web): route shared-folder writes through SDK with event-fed projection

- Add shared-folder-projection helpers seedSharedFolder + subscribeSharedFolderProjection
- Seed SDK sharedFolderTree on share/subfolder entry and up/breadcrumb depth restore
- Subscribe useSharedNavigation refs to sharedFolder:updated as projection-only state
- Route all five useSharedWriteOps handlers through getSdkClient shared methods, no write-back
- Drop withConflictRetry and buildSharedWriteContext ceremony; SDK owns CAS retry
- Add projection unit tests proving per-share isolation and reads-nothing-back

Implements REQ-3 web side (phase 48)

* docs(48-04): complete shared-folder write projection plan

Entire-Checkpoint: fcf515ad5df0

* test(48-06): add failing test for share itemName decrypt and backfill helpers

* docs(48-07): plan SDK-owned shared-folder refresh and poller consolidation

Entire-Checkpoint: 9f0cd23de3e3

* feat(48-06): ECIES-wrap share itemName on create and decrypt for display

- Wrap itemName with recipient pubkey in ShareDialog before createShare; send ciphertext-only (empty plaintext)
- Wrap itemName with ephemeral pubkey on invite-create; re-wrap for recipient on claim (decision A3)
- Decrypt itemNameEncrypted into the plaintext store projection on received-share load; display sites unchanged
- Add decryptItemName + shouldBackfill helpers and lazy-backfill pass for legacy rows (decision A2)

* docs(48-06): complete REQ-4 web itemName encryption plan

Entire-Checkpoint: 102cf857992f

* feat(48-07): add SDK client.refreshSharedFolder for shared-folder re-resolve

- refreshSharedFolder(shareId) re-resolves IPNS via sdkCore.loadFolderMetadata
- applies the #489 sequence-guard: stale/equal resolve re-emits existing state
- adopts a newer sequence into sharedFolderTree and emits sharedFolder:updated
- throws Shared folder not loaded on an unloaded shareId
- TDD-covered: newer adopt, stale no-clobber, null no-op, unloaded throw

* refactor(48-07): route shared poller through SDK refreshSharedFolder

- 30s poller calls getSdkClient().refreshSharedFolder(currentShareId)
- remove inline refreshFolderContents IPNS resolve + IPFS fetch + decrypt
- drop now-unused resolveIpnsRecord/fetchFromIpfs/decryptFolderMetadata imports
- projection subscription is now the sole ref writer on write AND poll paths
- revocation check unchanged

* test(48-07): cover poll-through-SDK refreshSharedFolder projection path

- add refreshSharedFolder to the fake SharedFolderClient recording the call
- assert the poller calls refreshSharedFolder with the active shareId
- assert refs update ONLY via the sharedFolder:updated subscription, not the call
- assert a mismatched-shareId refresh event is ignored per-share isolation

* docs(48-07): summarize SDK-owned shared-folder refresh and poller consolidation

- record Tasks 1-4 commits, verify results, and dist-rebuild confirmation
- Task 5 shared-folder sync UAT deferred to end-of-phase web-e2e

* fix(web): seed SDK shared-folder state when opening a write-shared file

The recipient save path routes through client.updateSharedFile (REQ-3, 48-04),
which calls requireSharedFolder and throws 'Shared folder not loaded' when the
share is absent from the SDK sharedFolderTree. The folder-open branch seeds the
tree, but the standalone file-share branch did not — so a recipient's Ctrl+S on
a write-shared file threw, the editor never closed, and writable-shares.spec.ts
'10.3 Bob opens the write-shared file and can edit it' timed out at waitFor
hidden. Seed the file branch (children: [], sequenceNumber: 0n; updateSharedFile
is a file-only publish that reads folderKey + owner/recipient pubkeys only and
ignores children/sequence). Only write shares (IPNS key present) seed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: c89f6e96802a

* fix(api): allow empty itemName on create-invite to match create-share

REQ-4 (48-06) moved the share display name into the ECIES itemNameEncrypted
ciphertext and changed the web client to POST itemName: '' on create. The
sibling CreateShareDto was relaxed to accept this, but CreateInviteDto kept
@minlength(1) on itemName, so the global ValidationPipe (whitelist +
forbidNonWhitelisted) rejected invite-link creation with HTTP 400 — failing
invite-link-workflow.spec.ts '3.1 Alice creates an invite link for a file' at
waitForSuccess. Drop @minlength(1) to mirror CreateShareDto. No OpenAPI/client
change: the Nest swagger plugin does not emit minLength, and the generated
client source is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: e0689f5cec4f

* refactor(48): drop dead shared-write folderKey prop, redundant 0x-strip, stale doc

Simplify-pass cleanups (no behavior change):
- useSharedWriteOps: remove the unused folderKey param (the SDK owns the folder
  key via seeded sharedFolderTree state) and its call-site wiring.
- shared-folder-projection.parsePublicKey: drop the redundant 0x-strip;
  hexToBytes already strips an optional 0x prefix.
- client.ts getFolderIpnsPrivateKey: fix a doc comment that referenced
  ensureFolderRegistered, which this branch deleted.

Deferred (noted, out of scope for this PR): SharedFolderTree is keyed by
shareId so the web re-seeds per nav depth (deeper fix = a shared
ensureFolderLoaded analog); serial DFS in ensureFolderLoaded; pre-existing
share-key cache bypass in updateSharedFileHandler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 5cdb4df5efb2

* test(48-05): cover invite/claim itemNameEncrypted ciphertext persistence

Add 3 behavioral tests to share-invite.service.spec.ts:
- createInvite: persists hex ciphertext as Buffer passthrough (no server encryption)
- createInvite: persists null for legacy clients that omit itemNameEncrypted
- claimInvite: re-wrapped itemNameEncrypted from DTO is stored as Buffer on the Share

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Entire-Checkpoint: a49ce185baba

* docs(48): mark phase nyquist-compliant after filling invite ciphertext test gap

Update 48-VALIDATION.md: real per-task verification map (REQ-1..4 all green via
unit + web-e2e), Wave 0 checked, sign-off approved, audit trail (1 gap found,
1 resolved). The gap — invite/claim itemNameEncrypted persistence — was closed
by share-invite.service.spec.ts (commit e8a3a2fe0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 42e4f8b5b5a4

* fix(api): widen itemNameEncrypted MaxLength to 2500 for worst-case names

The 1024-char cap was copied from the fixed-size encryptedKey field, but
itemNameEncrypted wraps a variable-length display name: a 255-char multibyte
name (CJK/emoji) is up to ~765 UTF-8 bytes, whose ECIES ciphertext is ~1724 hex
chars — exceeding 1024 and triggering a 400 (same class as the create-invite
MinLength regression). Raise the cap to 2500 across create-share, create-invite,
and claim-invite. No OpenAPI/client change (the swagger plugin does not emit
maxLength). Flagged by CodeRabbit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: aa221dedc616

* fix(web): harden shared itemName decrypt and unload SDK keys on unmount

Two review findings on the REQ-3/REQ-4 shared paths:
- fetchReceivedShares decrypted itemName inside Promise.all, so one corrupt /
  wrong-key / truncated ciphertext row rejected the WHOLE received-shares page.
  Guard per row, degrading just that row to its plaintext fallback (security
  review, MEDIUM availability).
- The sharedFolder:updated projection effect only unsubscribed on unmount; it
  never unloaded the SDK's cloned shared-folder state, so folderKey +
  ipnsPrivateKey lingered in SDK memory after leaving a share. Call
  unloadSharedFolder(activeShareId) in cleanup to zero them (CodeRabbit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 19d891c11b3e

* docs(48): add phase 48 security review report

Two-agent security review of the REQ-4 itemName at-rest encryption and REQ-3
shared-folder key handling. Overall risk LOW: zero-knowledge invariant holds
(server stores ciphertext verbatim, never encrypts/decrypts), crypto correct,
key-zeroing + cross-share isolation hold. One MEDIUM (received-shares decrypt
guard) fixed; LOW items deferred with rationale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 1a497a44e1ab

* chore: sync Cargo.lock to crate versions 0.6.0 for fuse and sdk

The cipherbox-fuse and cipherbox-sdk crate Cargo.toml files are at 0.6.0 but
the committed lockfile still pinned 0.5.3 / 0.5.0. Regenerate the lock to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 17ebf9d4ebfa

* chore(release): set release targets for PR #500

* test(48): cover itemNameEncrypted present/null branches in invite controllers

The new itemNameEncrypted hex-or-null ternaries in share-invites.controller
and invites.controller added per-file branches whose present-vs-null sides
were not both exercised, dropping branch coverage below threshold (63.88% <
65% and 73.33% < 74%). Add explicit tests for the present (Buffer to hex
string) and absent (null) branches in createInvite, listInvites, and
getInviteData, and set itemNameEncrypted on the base mocks. Both files now
clear their branch thresholds (69.44% and 76.66%).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: a5866cb6aa14

* test(48): update sdk-e2e bin suite to the self-healing bin contract

Commit 73c9f03 intentionally changed the bin contract: loadBin returns an
in-memory empty state at sequenceNumber 0 WITHOUT publishing (publishing an
empty record on a null resolve is destructive), and the client bin methods
self-heal or lazy-load instead of throwing BinNotLoadedError. The sdk-e2e
suite still asserted the old contract.

bin-operations.test.ts:
- fresh-account loadBin now expects sequenceNumber 0 (was 1).
- "BinNotLoadedError when bin not loaded" becomes a self-heal test: deleteToBin
  lazily loads the bin and self-bootstraps the root, then fails only on the
  missing child with 'Item not found'.

error-cases.test.ts (bin ops without an explicit loadBin):
- deleteToBin self-heals and fails with 'Item not found' (not BinNotLoadedError).
- restoreFromBin / permanentDelete reach the entry lookup (binState lazily
  loaded empty by the prior deleteToBin) and fail with 'Bin entry not found'.
- emptyBin resolves on the already-loaded empty bin.

Verified against the source-of-truth unit tests (packages/sdk/src/__tests__/
bin.test.ts, green) and the client self-heal logic in client.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 7b06e5dedd10

* fix(sdk): guard shared-folder adopt and emit against unload race, zero stale keys

Two CodeRabbit findings on the SDK shared-folder state:
- adoptSharedFolderResult and the updateSharedFile / refreshSharedFolder emit
  sites used a pre-await state snapshot, so a write/refresh completing AFTER
  unloadSharedFolder(shareId) (now triggered on unmount) could resurrect the
  unloaded entry and emit sharedFolder:updated for it. Re-read live state right
  before write-back/emit and no-op if absent; callers pass shareId.
- SharedFolderTree.set now zeroes the previous entry's folderKey/ipnsPrivateKey
  before overwriting so stale key material doesn't linger across re-seeds —
  guarded on reference identity so the adopt path (which spreads the live entry's
  same buffers) never zeroes the key it is about to clone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 45d6a1f9e98b

* fix(web): disable no-op itemName backfill and wipe plaintext name buffers

Two CodeRabbit findings on the REQ-4 share itemName paths:
- backfillSentShareItemNames re-wrapped every legacy row via ECIES on each
  owner share-list load but cannot persist (no PATCH endpoint, T-48-18) — wasted
  CPU with no durable effect. Disable the invocation until the update endpoint
  exists; the function is kept ready for one-line wiring.
- ShareDialog and invite.service encoded the plaintext display name into a
  Uint8Array and wrapped it without clearing the temporary bytes. Wrap in
  try/finally and fill(0) the encoded buffer, per the clear-sensitive-data rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: d9b1db6d1ed0

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:sdk:fix Patch version bump (bug fix) for sdk release:web:fix Patch version bump (bug fix) for web

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant