feat(web): shared-folder intra-share move and useFolderNavigation consolidation#509
Conversation
…tion consolidation Entire-Checkpoint: e58bf755e671
Entire-Checkpoint: 08cdb22c0127
Entire-Checkpoint: cc679fefc1f7
Entire-Checkpoint: 244b430c8211
…ivate-vault parity) Entire-Checkpoint: e7041909f23d
…ion consolidation 5 plans across 3 waves: SDK enumerate+move op (TDD), useFolderNavigation unwrap consolidation, web move UX, batch+drag parity, and the two-account decrypt-survival e2e. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 5aa79041aceb
…research resolved Entire-Checkpoint: aa1b86a5cc69
…hod and enumerateSharedSubtree Entire-Checkpoint: 3022e6584373
…rateSharedSubtree - Add moveInSharedFolder stateless op to shared-write.ts (dual src/dest context, publish DEST first, re-key FileMetadata, publish SOURCE, no key zeroing in op) - Export moveInSharedFolder from share/index.ts - Add CipherBoxClient.moveInSharedFolder with write-cap guard (T-49-01), fresh dest loadFolderMetadata (A1), adoptSharedFolderResult for SOURCE only, finally-zeroing destFolderKey/destIpnsPrivateKey/fileIpnsPrivateKey (T-49-04) - Add CipherBoxClient.enumerateSharedSubtree with stack-based DFS, visited Set guard (cycle prevention), writable flag from folder-ipns key presence Entire-Checkpoint: b1ede215cbe2
Entire-Checkpoint: 4ba821aa32f3
…ion with ensureFolderLoaded - Remove duplicate web-side unwrapKey + resolveIpnsRecord + fetchAndDecryptMetadata block (lines 241-302) - Delegate unwrap/resolve/decrypt to client.ensureFolderLoaded (SDK single source of truth) - Preserve 3x/2s IPNS-propagation retry wrapper with latestNavTarget guard on each iteration - Clone SDK-owned key buffers into FolderNode (new Uint8Array) to prevent use-after-zero on client.destroy() - Remove now-dead imports: unwrapKey, hexToBytes, fetchAndDecryptMetadata, resolveIpnsRecord, useAuthStore - Add FolderState type import from @cipherbox/sdk for mapping Entire-Checkpoint: 923d8deca666
Entire-Checkpoint: fc527fd40cf7
…lowlist + moveItemHandler - Add 'moveInSharedFolder' and 'enumerateSharedSubtree' to SharedFolderClient Pick union - Add moveItemHandler to useSharedWriteOps routing through runWrite -> client.moveInSharedFolder - Expose moveItem in useSharedNavigation return type Entire-Checkpoint: 2dffc00ec239
- Add vi.mock stubs for react/useCallback, auth store, sdk-provider, share.service - Add 3 moveItemHandler cases: routes runWrite->client.moveInSharedFolder, surfaces errors, guards absent keypair - Tests run in node env (no React render harness) matching existing file style Entire-Checkpoint: a54d3d8ef26e
…type - New SharedMoveDialog loading picker nodes via enumerateSharedSubtree - Props: open/onClose/onConfirm(destFolderId,destIpnsName)/item/currentFolderId/shareId/isLoading - Disables read-only and current-folder rows; role=button + onKeyDown a11y - :focus-visible style added to dialogs.css with modern rgb() notation - SharedMoveDialog readonly badge [RO] for non-writable folders - Fix vi.fn type argument in test for newer vitest compat Entire-Checkpoint: 37b6e4209fa6
- Import SharedMoveDialog and destructure moveItem from useSharedNavigation - Add moveDialogItem state + handleMoveClick callback - Wire onMove on folder-view ContextMenu for write-permission recipients (files only) - List-view (synthetic top-level shares) ContextMenu has no onMove (T-49-09) - Mount SharedMoveDialog with onConfirm calling moveItem; refresh via sharedFolder:updated projection Entire-Checkpoint: a468fcb78a68
- 49-03-SUMMARY.md: SharedMoveDialog + moveItemHandler + onMove wiring - STATE.md: advance to plan 4 of 5, add decisions - ROADMAP.md: mark 49-03 complete Entire-Checkpoint: dccd7953e33e
…Handler - Add batchMoveItemsHandler to useSharedWriteOps: loops client.moveInSharedFolder per item inside runWrite, stops on first failure, clearSelection on success - Add batchMoveItems to UseSharedNavigationReturn type - Add selectedIds Set, selectedItems useMemo, multiSelectActive, clearSelection, handleSelect, and handleBatchMoveClick to SharedFileBrowser - Wire SelectionActionBar into folder view (write shares only, multiSelectActive) - Mount batch-mode SharedMoveDialog opened from SelectionActionBar - Pass isSelected/onSelect/onMoveItemTo/selectedItems to SharedFolderRow Entire-Checkpoint: 4ea2f59e45f2
- Add optional items?: FolderChild[] prop to SharedMoveDialog (mirrors private
MoveDialog item|items shape from MoveDialog.tsx:20-21)
- isBatchMode flag: items.length > 1 auto-adapts title (Move N items) and label
- SharedFileBrowser opens dialog in batch mode from handleBatchMoveClick,
passing items={batchMoveItems_} and routing onConfirm to batchMoveItems
- Single-item path (item prop) unchanged
Entire-Checkpoint: a88d61c493fc
- handleDragStart: multi-select-aware payload (application/json {items,parentId})
includes all selectedItems when dragged item is part of a selection (> 1),
else single item — mirrors FileListItem :160-177
- handleDrop: folder rows only; parses application/json defensively (T-49-13);
routes single item -> moveItemHandler, multi -> batchMoveItemsHandler via
onMoveItemTo callback in SharedFileBrowser; ignores payload parentId (drop
target row id/ipnsName is the authoritative dest, T-49-12)
- handleDragOver/handleDragLeave: visual affordance (isDragOver -> css class
file-list-item--drag-over); distinguishes internal moves from external drops
via dataTransfer.types.includes('application/json')
- onSelect click handler for Ctrl/Cmd+click multi-select selection
- External file upload drop (container level) preserved; SharedFolderRow does
not handle external file drops (falls through to parent handler)
- a11y: draggable + existing role=row + onKeyDown Enter/Space for navigation
Entire-Checkpoint: b1e16fc43eff
Entire-Checkpoint: 4ee9d4a04375
- Mirrors MoveDialogPage shape for the shared-folder picker dialog - dialog() scoped via .move-dialog-folder-list filter (avoids collision with private MoveDialog) - getFolderItem/folderItems target .shared-move-dialog-folder-item rows (role=button) - waitForTreeLoaded polls loading indicator hidden + listbox visible - move() helper: waitForTreeLoaded -> selectFolder -> clickMove -> waitForClose Entire-Checkpoint: 245398a29315
- Two-account Alice/Bob setup mirroring writable-shares.spec.ts (createWalletTestAccount, closeWalletTestAccounts, navigateToShared)
- Alice creates parent folder with a subfolder and a text file; shares read-write with Bob
- Bob navigates via SharedFileBrowserPage, right-clicks file -> Move to... -> selects subfolder in SharedMoveDialogPage -> confirm
- Asserts file disappears from source view, appears in subfolder in Bob view
- readContentViaEditor (right-click -> Edit -> waitForContentLoaded -> getContent) asserts content decrypts for Bob (REQ-5 T-49-14)
- alice.page.reload({waitUntil:'networkidle'}) -> owner navigates to subfolder -> readContentViaEditor asserts same decrypted content for Alice
- Decrypt-on-read assertion (TextEditorDialogPage.getContent) not mere list visibility
Entire-Checkpoint: 6ff2b365878a
…e e2e deferred to main-push) Entire-Checkpoint: 9821869af98d
…ernalFileDrag, collapse reset effects) Entire-Checkpoint: acb4a538dbbd
… on success - moveInSharedFolder throws when a folder is moved into itself - enumerateSharedSubtree returns parentId so the picker can disable the moved folder's own subtree (move-into-descendant would cycle the tree) - SharedMoveDialog disables moved folder(s) + descendants; SharedFolderRow drag drop skips dropping a folder onto itself - runWrite returns success; batchMoveItems clears selection only on success Entire-Checkpoint: d63fc00d5327
…rity review) Each DFS step unwrapped a subfolder AES folderKey but dropped it without zeroing, leaving plaintext folder keys live in the heap across the walk. Wrap each iteration in try/finally with folderKey.fill(0). Entire-Checkpoint: e696538da67f
Entire-Checkpoint: b300fa0a45ce
… review) The shared multi-select action bar stubbed onDownload/onDelete to no-ops, so a Delete button rendered that silently did nothing — misleading users about deletion of shared data. Make those handlers optional in SelectionActionBar and omit them in the shared browser so only the wired Move action renders. Entire-Checkpoint: 4ca756afaef6
Release Preview
Cascade Details
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #509 +/- ##
===========================================
+ Coverage 64.03% 85.23% +21.20%
===========================================
Files 142 107 -35
Lines 10937 6827 -4110
Branches 1226 1234 +8
===========================================
- Hits 7003 5819 -1184
+ Misses 3689 766 -2923
+ Partials 245 242 -3
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
🧹 Nitpick comments (4)
tests/web-e2e/page-objects/file-browser/shared-move-dialog.page.ts (2)
45-47: ⚡ Quick winUse exact-name matching for folder row selection.
Line 46 currently uses substring matching (
hasText: name), which can select the wrong row when folder names overlap (e.g.,docsvsdocs-old).Proposed fix
getFolderItem(name: string): Locator { - return this.folderList().locator('.shared-move-dialog-folder-item', { hasText: name }); + return this.folderList().locator('.shared-move-dialog-folder-item').filter({ + has: this.page.locator('.move-dialog-folder-name', { hasText: new RegExp(`^${name}$`) }), + }); }🤖 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 `@tests/web-e2e/page-objects/file-browser/shared-move-dialog.page.ts` around lines 45 - 47, The getFolderItem method uses substring matching with hasText: name, which can incorrectly select folders when names overlap (e.g., searching for "docs" matches both "docs" and "docs-old"). To fix this, modify the locator in getFolderItem to use exact-name matching instead. Replace the hasText option with a more specific selector or filtering logic that matches only the complete folder name exactly, ensuring that partial name matches are not selected.
109-113: ⚡ Quick winFail fast in
waitForTreeLoadedwhen the dialog enters an error state.Line 109-Line 113 can report “loaded” even when subtree loading failed; explicitly checking
loadErrorimproves diagnostics and reduces flaky downstream failures.Proposed fix
async waitForTreeLoaded(options?: { timeout?: number }): Promise<void> { await this.folderList().waitFor({ state: 'visible', ...options }); // Allow an extra tick for the loading indicator to disappear await this.loadingIndicator().waitFor({ state: 'hidden', timeout: options?.timeout ?? 15_000 }); + if (await this.loadError().isVisible()) { + throw new Error(`SharedMoveDialog failed to load folder tree: ${await this.loadError().textContent()}`); + } }🤖 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 `@tests/web-e2e/page-objects/file-browser/shared-move-dialog.page.ts` around lines 109 - 113, The waitForTreeLoaded method in the SharedMoveDialogPage class currently only checks if the folder list is visible and the loading indicator is hidden, but it does not explicitly verify that the dialog has not entered an error state during loading. Add an additional wait condition to check that the loadError element is hidden or not visible before the method resolves, similar to how the loadingIndicator is checked. This will ensure that if subtree loading fails and triggers an error state, the method will fail fast rather than reporting a successful load, reducing flaky downstream test failures.apps/web/src/components/file-browser/SharedFolderRow.tsx (1)
114-152: 💤 Low valueType narrowing gap when accessing
item.ipnsName.The
handleDropcallback accessesitem.ipnsNameat line 149, but TypeScript won't narrow theFolderChildunion type based on theisFolderguard at line 121 becauseisFolderis a derived boolean rather than a type predicate. This could cause a TypeScript error depending on strict settings, sinceipnsNameonly exists onFolderEntry, notFilePointer.🔧 Suggested type-safe access
// Guard: never drop a folder onto itself (one of the dragged items) — that // would move it into itself and cycle the tree (mirrors FileListItem). if (parsed.items.some((i) => i.id === item.id)) return; + // Type guard: onMoveItemTo only set for folders, but narrow for TS + if (item.type !== 'folder') return; + // Route through the parent's handleDropOnFolder-equivalent, forwarding the // dragged items so the parent moves exactly what was dragged. Per-item // validation (name collision + write-capability) is in the SDK. onMoveItemTo(item.id, item.ipnsName, parsed.items);🤖 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/web/src/components/file-browser/SharedFolderRow.tsx` around lines 114 - 152, The handleDrop callback accesses item.ipnsName without proper type narrowing. Although there is an isFolder guard that returns early if not a folder, TypeScript cannot narrow the FolderChild union type based on a simple boolean variable check. To fix this, add an explicit type guard for the item parameter before accessing ipnsName. You can either use a type predicate function to check if item is a FolderEntry with ipnsName property, or add a type assertion at the point where item.ipnsName is accessed in the onMoveItemTo call. This ensures TypeScript understands that item has the ipnsName property at that point in the code.apps/web/src/hooks/__tests__/useSharedWriteOps.test.ts (1)
382-477: ⚡ Quick winConsider adding test coverage for
batchMoveItems.The
moveItemtests are well-structured and cover the critical paths (success, error surfacing, missing keypair). However,batchMoveItemshas distinct behavior worth testing:
- Empty items array early-return
clearSelectioncalled only on full success- Partial batch failure (first N items succeed, then one fails)
These edge cases would strengthen confidence in the batch flow.
🤖 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/web/src/hooks/__tests__/useSharedWriteOps.test.ts` around lines 382 - 477, Add test cases for the batchMoveItems function to cover its distinct behavior paths that differ from moveItem. Create tests that verify: (1) when an empty items array is passed, the function returns early without calling the underlying moveInSharedFolder; (2) clearSelection is invoked only when all items in the batch are moved successfully, not on partial failures; (3) when the first N items succeed but a subsequent item fails, the error is properly surfaced via setError and clearSelection is not called. These tests should follow the same pattern as the existing moveItem tests, using vi.fn() for mock callbacks and makeChild for creating test items.
🤖 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.
Nitpick comments:
In `@apps/web/src/components/file-browser/SharedFolderRow.tsx`:
- Around line 114-152: The handleDrop callback accesses item.ipnsName without
proper type narrowing. Although there is an isFolder guard that returns early if
not a folder, TypeScript cannot narrow the FolderChild union type based on a
simple boolean variable check. To fix this, add an explicit type guard for the
item parameter before accessing ipnsName. You can either use a type predicate
function to check if item is a FolderEntry with ipnsName property, or add a type
assertion at the point where item.ipnsName is accessed in the onMoveItemTo call.
This ensures TypeScript understands that item has the ipnsName property at that
point in the code.
In `@apps/web/src/hooks/__tests__/useSharedWriteOps.test.ts`:
- Around line 382-477: Add test cases for the batchMoveItems function to cover
its distinct behavior paths that differ from moveItem. Create tests that verify:
(1) when an empty items array is passed, the function returns early without
calling the underlying moveInSharedFolder; (2) clearSelection is invoked only
when all items in the batch are moved successfully, not on partial failures; (3)
when the first N items succeed but a subsequent item fails, the error is
properly surfaced via setError and clearSelection is not called. These tests
should follow the same pattern as the existing moveItem tests, using vi.fn() for
mock callbacks and makeChild for creating test items.
In `@tests/web-e2e/page-objects/file-browser/shared-move-dialog.page.ts`:
- Around line 45-47: The getFolderItem method uses substring matching with
hasText: name, which can incorrectly select folders when names overlap (e.g.,
searching for "docs" matches both "docs" and "docs-old"). To fix this, modify
the locator in getFolderItem to use exact-name matching instead. Replace the
hasText option with a more specific selector or filtering logic that matches
only the complete folder name exactly, ensuring that partial name matches are
not selected.
- Around line 109-113: The waitForTreeLoaded method in the SharedMoveDialogPage
class currently only checks if the folder list is visible and the loading
indicator is hidden, but it does not explicitly verify that the dialog has not
entered an error state during loading. Add an additional wait condition to check
that the loadError element is hidden or not visible before the method resolves,
similar to how the loadingIndicator is checked. This will ensure that if subtree
loading fails and triggers an error state, the method will fail fast rather than
reporting a successful load, reducing flaky downstream test failures.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 7c9d7742-18eb-469d-a82e-3687ef43b9d3
📒 Files selected for processing (36)
.planning/ROADMAP.md.planning/STATE.md.planning/phases/49-shared-folder-move-intra-share-and-usefoldernavigation-unwra/.gitkeep.planning/phases/49-shared-folder-move-intra-share-and-usefoldernavigation-unwra/49-01-PLAN.md.planning/phases/49-shared-folder-move-intra-share-and-usefoldernavigation-unwra/49-01-SUMMARY.md.planning/phases/49-shared-folder-move-intra-share-and-usefoldernavigation-unwra/49-02-PLAN.md.planning/phases/49-shared-folder-move-intra-share-and-usefoldernavigation-unwra/49-02-SUMMARY.md.planning/phases/49-shared-folder-move-intra-share-and-usefoldernavigation-unwra/49-03-PLAN.md.planning/phases/49-shared-folder-move-intra-share-and-usefoldernavigation-unwra/49-03-SUMMARY.md.planning/phases/49-shared-folder-move-intra-share-and-usefoldernavigation-unwra/49-04-PLAN.md.planning/phases/49-shared-folder-move-intra-share-and-usefoldernavigation-unwra/49-04-SUMMARY.md.planning/phases/49-shared-folder-move-intra-share-and-usefoldernavigation-unwra/49-05-PLAN.md.planning/phases/49-shared-folder-move-intra-share-and-usefoldernavigation-unwra/49-05-SUMMARY.md.planning/phases/49-shared-folder-move-intra-share-and-usefoldernavigation-unwra/49-CONTEXT.md.planning/phases/49-shared-folder-move-intra-share-and-usefoldernavigation-unwra/49-PATTERNS.md.planning/phases/49-shared-folder-move-intra-share-and-usefoldernavigation-unwra/49-RESEARCH.md.planning/phases/49-shared-folder-move-intra-share-and-usefoldernavigation-unwra/49-VALIDATION.md.planning/phases/49-shared-folder-move-intra-share-and-usefoldernavigation-unwra/49-VERIFICATION.md.planning/security/REVIEW-phase49-20260618T025359Z.mdapps/web/src/components/file-browser/SelectionActionBar.tsxapps/web/src/components/file-browser/SharedFileBrowser.tsxapps/web/src/components/file-browser/SharedFolderRow.tsxapps/web/src/components/file-browser/SharedMoveDialog.tsxapps/web/src/hooks/__tests__/useSharedWriteOps.test.tsapps/web/src/hooks/shared-folder-projection.tsapps/web/src/hooks/useFolderNavigation.tsapps/web/src/hooks/useSharedNavigation.tsapps/web/src/hooks/useSharedWriteOps.tsapps/web/src/styles/dialogs.csspackages/sdk/src/__tests__/enumerate-shared-subtree.test.tspackages/sdk/src/__tests__/move-in-shared-folder.test.tspackages/sdk/src/client.tspackages/sdk/src/share/index.tspackages/sdk/src/share/shared-write.tstests/web-e2e/page-objects/file-browser/shared-move-dialog.page.tstests/web-e2e/tests/shared-folder-move.spec.ts
… load, batch coverage - shared-move-dialog page object: exact folder-name match (no docs/docs-old collision) + waitForTreeLoaded fails fast with a clear message on load error - add batchMoveItems tests: empty early-return, clearSelection only on full success, stop-on-first-failure (locks in the on-success-only selection clear) - skipped the 'item.type !== folder' narrowing nitpick: redundant (isFolder already guards) and tsc -b passes strict without it Entire-Checkpoint: ca5f6fcd5105
|
Addressed the CodeRabbit nitpicks in
The earlier review round (CLI) findings — drag move routing by the actually-dragged items, |
The shared-view batch action bar was gated on `selectedIds.size > 0`, so a plain single click — which is also the first click of a double-click — selected one item and rendered the SelectionActionBar above the file list. That layout shift moved the rows down mid-double-click, so the second click missed the row and the `dblclick` never fired: folder navigation silently failed (e2e writable-shares 8.2 + shared-folder-move 3.x). Gate the bar on `size > 1`, matching the private vault (FileBrowser :205). A single click still highlights the row but no longer pops the bar. Also strip the decorative trailing "/" from folder names in the shared file-browser page object's getFolderItemNames so exact `toContain(name)` assertions work (other specs already used slash-tolerant `.includes`). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 6a5174789ead
A read-write folder share now grants the recipient the IPNS signing keys (folder-ipns / file-ipns) for owner-created descendant subfolders and files, not just the read keys. Without them a recipient could only write to items they created themselves, so moving a file into an owner-created subfolder was impossible. - collectChildKeys re-wraps each descendant's ipnsPrivateKey for the recipient when permission is write (file-ipns guarded on the optional field; folders always carry it). Invite links stay read-only. - CHILD_KEY_TYPES gains folder-ipns so the create-share endpoint accepts it; regenerated the API client. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 2a7e3a5b1b04
Key the folder_ipns cache by ipnsName alone (one canonical record per name) and authorize publishes by verifying the record's Ed25519 SignatureV2 (key possession) instead of per-user ownership or a write-share lookup. Any holder of a name's key may update it -- the model IPNS itself uses -- so a write-share recipient can publish owner-created descendant records (enabling intra-share moves) with no server-side share check. - verifyIpnsRecordSignature / parseIpnsRecord added to @cipherbox/crypto via the ipns package, replacing the API hand-rolled protobuf parser. - publishRecord verifies the signature before create OR update. - Anti-rollback: reject a record whose embedded (signature-covered) sequence regresses below the stored record's, blocking replay of an old still-valid record to roll a folder back to a stale CID. - Migration dedupes to the highest-sequence row, then swaps the unique constraint user_id+ipns_name to ipns_name. userId retained as a denormalized creator marker for listing / TEE enrollment / cleanup. Tradeoff: write access is now revocable only by IPNS key rotation, not a server-side share revoke (the decentralization goal). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 355a7f89d28a
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 `@apps/web/src/lib/crypto/key-wrapping.ts`:
- Around line 107-122: The write permission branch unconditionally accesses
folder.ipnsPrivateKeyEncrypted without checking if it exists, which will cause a
runtime crash if legacy folders lack IPNS keys. Add a null guard condition to
check if folder.ipnsPrivateKeyEncrypted exists before proceeding with the
reWrapEncryptedKey call, similar to the file branch pattern that checks
fp.ipnsPrivateKeyEncrypted. Only execute the reWrapEncryptedKey logic and push
to childKeys if the encrypted key is present and not null.
🪄 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: a63d597f-d751-4ecc-9770-f43b236d7917
⛔ Files ignored due to path filters (131)
packages/api-client/openapi.jsonis excluded by!packages/api-client/**packages/api-client/src/generated/auth/auth.tsis excluded by!**/generated/**,!**/generated/**,!packages/api-client/**packages/api-client/src/generated/device-approval/device-approval.tsis excluded by!**/generated/**,!**/generated/**,!packages/api-client/**packages/api-client/src/generated/health/health.tsis excluded by!**/generated/**,!**/generated/**,!packages/api-client/**packages/api-client/src/generated/identity/identity.tsis excluded by!**/generated/**,!**/generated/**,!packages/api-client/**packages/api-client/src/generated/invites/invites.tsis excluded by!**/generated/**,!**/generated/**,!packages/api-client/**packages/api-client/src/generated/ipfs/ipfs.tsis excluded by!**/generated/**,!**/generated/**,!packages/api-client/**packages/api-client/src/generated/ipns/ipns.tsis excluded by!**/generated/**,!**/generated/**,!packages/api-client/**packages/api-client/src/generated/root/root.tsis excluded by!**/generated/**,!**/generated/**,!packages/api-client/**packages/api-client/src/generated/share-invites/share-invites.tsis excluded by!**/generated/**,!**/generated/**,!packages/api-client/**packages/api-client/src/generated/shares/shares.tsis excluded by!**/generated/**,!**/generated/**,!packages/api-client/**packages/api-client/src/generated/tee/tee.tsis excluded by!**/generated/**,!**/generated/**,!packages/api-client/**packages/api-client/src/generated/vault/vault.tsis excluded by!**/generated/**,!**/generated/**,!packages/api-client/**packages/api-client/src/models/addShareKeysDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/authMethodResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/authMethodResponseDtoType.tsis excluded by!packages/api-client/**packages/api-client/src/models/batchPublishIpnsDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/batchPublishIpnsResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/batchUnenrollIpnsDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/batchUnenrollIpnsResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/childKeyDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/childKeyDtoKeyType.tsis excluded by!packages/api-client/**packages/api-client/src/models/claimChildKeyDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/claimChildKeyDtoKeyType.tsis excluded by!packages/api-client/**packages/api-client/src/models/claimInviteDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/claimInviteResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/connectionTestRequestDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/connectionTestResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/connectionTestResponseDtoProtocol.tsis excluded by!packages/api-client/**packages/api-client/src/models/createApprovalDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/createInviteDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/createInviteDtoItemType.tsis excluded by!packages/api-client/**packages/api-client/src/models/createShareDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/createShareDtoItemType.tsis excluded by!packages/api-client/**packages/api-client/src/models/createShareDtoPermission.tsis excluded by!packages/api-client/**packages/api-client/src/models/createShareResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/createShareResponseDtoItemType.tsis excluded by!packages/api-client/**packages/api-client/src/models/createShareResponseDtoPermission.tsis excluded by!packages/api-client/**packages/api-client/src/models/deleteAccountDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/deleteAccountResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/desktopRefreshDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/deviceApprovalControllerCreateRequest201.tsis excluded by!packages/api-client/**packages/api-client/src/models/deviceApprovalControllerGetPending200Item.tsis excluded by!packages/api-client/**packages/api-client/src/models/deviceApprovalControllerGetStatus200.tsis excluded by!packages/api-client/**packages/api-client/src/models/deviceApprovalControllerGetStatus200Status.tsis excluded by!packages/api-client/**packages/api-client/src/models/googleLoginDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/googleLoginDtoIntent.tsis excluded by!packages/api-client/**packages/api-client/src/models/healthControllerCheck200.tsis excluded by!packages/api-client/**packages/api-client/src/models/healthControllerCheck200Details.tsis excluded by!packages/api-client/**packages/api-client/src/models/healthControllerCheck200DetailsDatabase.tsis excluded by!packages/api-client/**packages/api-client/src/models/healthControllerCheck200Error.tsis excluded by!packages/api-client/**packages/api-client/src/models/healthControllerCheck200Info.tsis excluded by!packages/api-client/**packages/api-client/src/models/healthControllerCheck200InfoDatabase.tsis excluded by!packages/api-client/**packages/api-client/src/models/identityControllerGetJwks200.tsis excluded by!packages/api-client/**packages/api-client/src/models/identityControllerGetJwks200KeysItem.tsis excluded by!packages/api-client/**packages/api-client/src/models/identityControllerGetWalletNonce200.tsis excluded by!packages/api-client/**packages/api-client/src/models/identityTokenResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/index.tsis excluded by!packages/api-client/**packages/api-client/src/models/initVaultDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/inviteChildKeyDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/inviteChildKeyDtoKeyType.tsis excluded by!packages/api-client/**packages/api-client/src/models/inviteDataResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/inviteDataResponseDtoItemType.tsis excluded by!packages/api-client/**packages/api-client/src/models/inviteDataResponseDtoStatus.tsis excluded by!packages/api-client/**packages/api-client/src/models/inviteResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/inviteResponseDtoItemType.tsis excluded by!packages/api-client/**packages/api-client/src/models/inviteResponseDtoStatus.tsis excluded by!packages/api-client/**packages/api-client/src/models/inviteStatusResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/inviteStatusResponseDtoStatus.tsis excluded by!packages/api-client/**packages/api-client/src/models/ipfsControllerUploadBody.tsis excluded by!packages/api-client/**packages/api-client/src/models/ipnsControllerResolveRecordParams.tsis excluded by!packages/api-client/**packages/api-client/src/models/linkMethodDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/linkMethodDtoLoginType.tsis excluded by!packages/api-client/**packages/api-client/src/models/loginDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/loginDtoLoginType.tsis excluded by!packages/api-client/**packages/api-client/src/models/loginResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/logoutResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/lookupUserResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/paginatedReceivedSharesDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/paginatedSentSharesDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/pendingRotationResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/pendingRotationResponseDtoItemType.tsis excluded by!packages/api-client/**packages/api-client/src/models/publishIpnsDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/publishIpnsEntryDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/publishIpnsEntryDtoRecordType.tsis excluded by!packages/api-client/**packages/api-client/src/models/publishIpnsResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/quotaResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/receivedShareResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/receivedShareResponseDtoItemType.tsis excluded by!packages/api-client/**packages/api-client/src/models/receivedShareResponseDtoPermission.tsis excluded by!packages/api-client/**packages/api-client/src/models/registerCidDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/registerCidResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/resolveIpnsResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/respondApprovalDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/respondApprovalDtoAction.tsis excluded by!packages/api-client/**packages/api-client/src/models/sendOtpDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/sendOtpResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/sentShareResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/sentShareResponseDtoItemType.tsis excluded by!packages/api-client/**packages/api-client/src/models/sentShareResponseDtoPermission.tsis excluded by!packages/api-client/**packages/api-client/src/models/setByoStatusDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/setByoStatusResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/shareInvitesControllerListInvitesParams.tsis excluded by!packages/api-client/**packages/api-client/src/models/shareKeyEntryDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/shareKeyEntryDtoKeyType.tsis excluded by!packages/api-client/**packages/api-client/src/models/shareKeyResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/shareKeyResponseDtoKeyType.tsis excluded by!packages/api-client/**packages/api-client/src/models/sharesControllerGetReceivedSharesParams.tsis excluded by!packages/api-client/**packages/api-client/src/models/sharesControllerGetSentSharesParams.tsis excluded by!packages/api-client/**packages/api-client/src/models/sharesControllerLookupUserParams.tsis excluded by!packages/api-client/**packages/api-client/src/models/teeKeysDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/testLoginDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/testLoginResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/tokenResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/unlinkMethodDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/unlinkMethodResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/unpinDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/unpinResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/updateEncryptedKeyDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/updateItemNameDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/updatePermissionDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/updatePermissionDtoPermission.tsis excluded by!packages/api-client/**packages/api-client/src/models/uploadResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/vaultConfigResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/vaultExportDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/vaultResponseDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/vaultResponseDtoTeeKeys.tsis excluded by!packages/api-client/**packages/api-client/src/models/verifyOtpDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/verifyOtpDtoIntent.tsis excluded by!packages/api-client/**packages/api-client/src/models/walletVerifyDto.tsis excluded by!packages/api-client/**packages/api-client/src/models/walletVerifyDtoIntent.tsis excluded by!packages/api-client/**
📒 Files selected for processing (16)
apps/api/src/ipns/entities/folder-ipns.entity.tsapps/api/src/ipns/ipns-record-parser.spec.tsapps/api/src/ipns/ipns-record-parser.tsapps/api/src/ipns/ipns.module.tsapps/api/src/ipns/ipns.service.spec.tsapps/api/src/ipns/ipns.service.tsapps/api/src/migrations/1749300000000-IpnsCacheKeyedByName.tsapps/api/src/shares/types.tsapps/web/src/components/file-browser/ShareDialog.tsxapps/web/src/lib/crypto/key-wrapping.tsapps/web/src/services/invite.service.tspackages/crypto/src/__tests__/ipns-record.test.tspackages/crypto/src/index.tspackages/crypto/src/ipns/parse-record.tspackages/crypto/src/ipns/verify-record.tsrelease-please-config.json
💤 Files with no reviewable changes (3)
- apps/api/src/ipns/ipns.module.ts
- apps/api/src/ipns/ipns-record-parser.spec.ts
- apps/api/src/ipns/ipns-record-parser.ts
✅ Files skipped from review due to trivial changes (1)
- release-please-config.json
The conflict-detection helper faked a competing-device publish by POSTing a dummy unsigned record to /ipns/publish to advance the server sequence. Under the new signature-gated IPNS model the server rejects unsigned records (400), so the bump failed -- surfacing only on Windows, whose .ps1 throws on a failed bump (the .sh swallowed it as a warning and passed falsely). Replace the dummy bump with bump-ipns-sequence.mjs, which derives the deterministic vault IPNS keypair and republishes the current root metadata UNCHANGED at sequence+1 via the SDK CAS path -- a real, validly-signed, non-destructive bump, exactly what a legitimate second device does. Restores genuine conflict exercise on every platform. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: c4a5195bab1e
The .mjs E2E helper scripts import dist bundles and are skipped by tsc/eslint/ vitest/jest, so they drift from SDK contract changes and only break deep in slow single-OS E2E runs (e.g. the conflict-detection bump just broke on Windows only). Capture a pending todo to type them so drift fails fast at CI build time. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: f930d519570b
The write-share branch re-wrapped folder.ipnsPrivateKeyEncrypted unconditionally. The field is typed required on FolderEntry (so no crash for type-conforming data), but the file branch already guards its optional equivalent — mirror that here so malformed/legacy folder metadata decrypted from IPFS degrades to read-only instead of throwing. Addresses CodeRabbit review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 3c5bc9c42ea9
packages/sdk had release-as pinned to 0.36.0 in release-please-config.json, equal to both the manifest version and the latest tag. After #509 landed a releasable feat touching packages/sdk source, release-please attributed it to sdk (by path) but the pin forced version 0.36.0 again, producing a self-comparing compare/@cipherbox/sdk-v0.36.0...@cipherbox/sdk-v0.36.0 changelog entry re-appended on every push to main (#510, #514). The pin went stale because the preview bot's release-target commit for #509 (b13e993) was orphaned by a branch rebase/force-push and is not an ancestor of main; cancel-in-progress also aborts the bot's re-run, so sdk's target was never advanced even though sdk source changed. - Bump packages/sdk release-as to 0.37.0 so the pending feat releases as a real minor bump, advancing the version and ending the loop. - Remove the two duplicate self-comparing 0.36.0 blocks from packages/sdk/CHANGELOG.md, leaving the legitimate 0.35.0...0.36.0 entry. release-please regenerates a clean 0.37.0 section (incl. #509) next run. Entire-Checkpoint: 8c1d13c810a7 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Phase 49 lets a write-permission share recipient move a file between subfolders within a single shared folder, re-encrypting the file's
FileMetadatafrom the source subfolder'sfolderKeyto the destination's so it stays decryptable for owner and recipient after the move. It also consolidates the webuseFolderNavigationkey-unwrap onto the SDK and brings the shared view to batch + drag move parity with the private vault.Important
Scope grew during review. Getting the move to work for owner-created destination subfolders surfaced a cross-layer gap, and the fix became a deliberate, owner-approved re-architecture of IPNS publishing. This PR now also contains:
folder_ipnscache is keyed byipnsName(not per-user) and publishes are authorized by record signature (key possession), not a server-side share check.These were folded in here (rather than a separate PR) by request, to avoid a rebase. See the two sections below.
What's included (Phase 49)
enumerateSharedSubtree(lazy DFS, per-node writability) and statelessmoveInSharedFolder(publish DEST → re-keyFileMetadataviareencryptFileMetadataForFolderChange→ publish SOURCE → adopt → emit; temp keys zeroed).moveItemHandler, a shared subtreeMoveDialog,onMovewired into the folder-view context menu.useFolderNavigationconsolidation: replaced the hand-rolled ECIES unwrap + IPNS-resolve + decrypt withclient.ensureFolderLoaded, preserving the IPNS-propagation retry and cloning SDK-owned key buffers.shared-folder-move.spec.ts), asserting content decrypts via the TextEditor read path for both Bob and Alice after sync.SelectionActionBar, batch handler loopingmoveInSharedFolder,MoveDialogitemsprop, internal drag-and-drop ontoSharedFolderRow.4bb181995): the shared batch action bar was gated onselectedIds.size > 0and rendered above the list, so a plain single click (the first click of a double-click) popped it in and shifted the rows mid-double-click — silently breaking folder navigation (writable-shares8.2). Gated onsize > 1, mirroring the private vault.Added: write-key distribution for shared descendants (
1be0dca11)collectChildKeyspreviously re-wrapped only read keys (folder/file) for a folder share's descendants — never the IPNS signing keys — so a write-share recipient could only write to items they created themselves, and moving a file into an owner-created subfolder was impossible.ipnsPrivateKey→folder-ipns(folders) /file-ipns(files, guarded on the optional field). Invite links stay read-only ('read').CHILD_KEY_TYPESgainsfolder-ipnsso the create-share endpoint accepts it; the API client was regenerated.Added: decentralized, signature-gated IPNS (
909c50c55)The server relayed IPNS through a per-user
folder_ipnstable and authorized non-owner publishes via a write-share lookup — so a recipient publishing an owner-created descendant forked a duplicate record instead of updating the canonical one (the move would not complete end-to-end). Rather than extend the share-based authorization, we moved to the model IPNS itself uses:folder_ipnsis keyed byipnsNamealone (one canonical record per name).userIdis retained as a denormalized creator marker (listing / TEE enrollment / cleanup).publishRecordverifies the record's Ed25519 SignatureV2 (@cipherbox/crypto.verifyIpnsRecordSignature, backed by theipnspackage) before create or update. Possession of the key — proven by the signature — is the authority to update a name, regardless of which user publishes.@cipherbox/crypto.parseIpnsRecord(alsoipns-backed); the resolve response contract (cid/sequenceNumber/signatureV2/data/pubKey) is preserved.1749300000000dedupes to the highest-sequence row, then swaps the unique constraint(user_id, ipns_name)→(ipns_name). Idempotent.Tradeoff (accepted)
Write access is now revocable only by IPNS key rotation + re-share, not a server-side share revoke. This is the intended decentralization property: the server no longer gates who may publish a name — the key does.
Security review
Overall LOW after the anti-rollback fix. The signature-binding core is sound and fail-closed (a record signed by a different key for a target name is rejected; V1-only / expired / malformed records are rejected). The one High finding (cross-user replay/rollback via the optional CAS + server-counter masking) is addressed by the embedded-sequence anti-rollback check. Documented fast-follows: client-side
signatureVerifiedenforcement and un-cached-name resolve verification (defense-in-depth; the server fix already prevents rolled-back records from being stored).Test status
verifyIpnsRecordSignature/parseIpnsRecordround-trip vs real records).nest buildclean.tsc -b+ eslint clean across web/crypto/api; e2etsc --noEmitclean.shared-folder-move.spec.ts) validated via CI E2E against this branch.Notes
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
Summary by CodeRabbit
Release Notes
New Features
Improvements