From 52bce624c6eb0cde1cedf5b657e62e4ec9acb30a Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Tue, 16 Jun 2026 02:12:13 +0200 Subject: [PATCH 1/4] docs: start work on todo - SDK client self-bootstrap folder tree from root IPNS key Co-Authored-By: Claude Opus 4.8 Entire-Checkpoint: 489e091e0cc7 --- .planning/STATE.md | 2 +- ...-sdk-client-self-bootstrap-folder-tree-from-root-ipns-key.md | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename .planning/todos/{pending => completed}/2026-06-16-sdk-client-self-bootstrap-folder-tree-from-root-ipns-key.md (100%) diff --git a/.planning/STATE.md b/.planning/STATE.md index 5c1f11f59a..2cdf5ee443 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -253,7 +253,7 @@ Recent for v1.1: ### Pending Todos -10 items in `.planning/todos/pending/` — see `/gsd:check-todos` for full list. The desktop (6) and SDK (4) groups addressed by Phase 46 (merged) and Phase 47 (PR #494) were moved to `.planning/todos/completed/` on 2026-06-15 and their ROADMAP scope boxes checked. Remaining pending includes the new route-shared-folder-writes follow-up — the lone folder-state mutation not consolidated by Phase 47 — and the new architecture todo to give the SDK client the root IPNS key so it self-bootstraps/lazy-loads `folderTree` (root cause of the "Folder not loaded" class; the bin-restore gap surfaced while combing the #494 fix). +8 items in `.planning/todos/pending/` — see `/gsd:check-todos` for full list. The desktop (6) and SDK (4) groups addressed by Phase 46 (merged) and Phase 47 (PR #494) were moved to `.planning/todos/completed/` on 2026-06-15 and their ROADMAP scope boxes checked. The architecture todo to give the SDK client the root IPNS key so it self-bootstraps/lazy-loads `folderTree` (root cause of the "Folder not loaded" class; bin-restore gap surfaced while combing the #494 fix) was started 2026-06-16 on branch `feat/sdk-client-self-bootstrap-folder-tree` and moved to `.planning/todos/completed/`. Remaining pending still includes the route-shared-folder-writes follow-up — the lone folder-state mutation not consolidated by Phase 47. ### Resolved diff --git a/.planning/todos/pending/2026-06-16-sdk-client-self-bootstrap-folder-tree-from-root-ipns-key.md b/.planning/todos/completed/2026-06-16-sdk-client-self-bootstrap-folder-tree-from-root-ipns-key.md similarity index 100% rename from .planning/todos/pending/2026-06-16-sdk-client-self-bootstrap-folder-tree-from-root-ipns-key.md rename to .planning/todos/completed/2026-06-16-sdk-client-self-bootstrap-folder-tree-from-root-ipns-key.md From d7fb39a2c1cce0ba9ad07b0d0c4f804d96449fc0 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Tue, 16 Jun 2026 02:31:31 +0200 Subject: [PATCH 2/4] feat(sdk): self-bootstrap folder tree from root IPNS key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK client owned folder state (folderTree) but its config carried only rootIpnsName + rootFolderKey, not the root IPNS signing key. So the client could not resolve/publish root or lazy-load folders on its own, and the web app had to seed folderTree via ensureFolderRegistered before every folderTree-dependent method. That asymmetry is the root cause of the whole "Folder not loaded" class — most visibly, restoring a recycle-bin item after a reload threw 'Target folder not loaded' when the item's subfolder parent was never navigated to that session (pre-existing since #296; recycle-bin.spec.ts misses it because it uploads in-session and never reloads). Changes: - Add optional rootIpnsKeypair to CipherBoxClientConfig; defensively copied in the constructor and zeroed in destroy(), mirroring vaultKeypair/rootFolderKey. - Add CipherBoxClient.ensureFolderLoaded(target): when a root IPNS keypair is configured, walk the folder tree from root (DFS, early exit), unwrapping each subfolder's folderKeyEncrypted/ipnsPrivateKeyEncrypted with the vault keypair and loading metadata until the target is registered. Every folder on the path is cached. A corrupt sibling entry is skipped, not fatal. - Wire ensureFolderLoaded as a self-heal fallback into every folderTree-dependent method (create/rename/move/delete/upload(s)/replace/version ops/share) and the bin deleteToBin/restoreFromBin wrappers. When no rootIpnsKeypair is configured it returns null before any crypto, preserving the prior 'Folder not loaded' behavior. - Web: pass vaultStore.rootIpnsKeypair into the SDK config at initSdkClient (already guaranteed non-null by the existing guard). Security: no new key exposure — the client already holds vaultKeypair.privateKey, rootFolderKey, and every loaded folder's ipnsPrivateKey in memory (zeroed on destroy). Reviewed: no key transposition, cycles bounded by a visited set, errors leak no key bytes. Tests: - SDK unit: ensureFolderLoaded — already-loaded no-op, no-keypair null, root-as-target, deep walk, unreachable target, skip unresolvable/corrupt sibling. - Web E2E: restore a subfolder item after reload without navigating into the subfolder (the scenario recycle-bin.spec.ts misses). Co-Authored-By: Claude Opus 4.8 Entire-Checkpoint: 78b22528e949 --- apps/web/src/hooks/useAuth.ts | 4 + .../__tests__/ensure-folder-loaded.test.ts | 173 ++++++++++++++++++ packages/sdk/src/client.ts | 156 ++++++++++++++-- packages/sdk/src/types.ts | 14 ++ .../tests/bin-restore-after-reload.spec.ts | 131 +++++++++++++ 5 files changed, 466 insertions(+), 12 deletions(-) create mode 100644 packages/sdk/src/__tests__/ensure-folder-loaded.test.ts create mode 100644 tests/web-e2e/tests/bin-restore-after-reload.spec.ts diff --git a/apps/web/src/hooks/useAuth.ts b/apps/web/src/hooks/useAuth.ts index e07e5a3226..24f9d838b7 100644 --- a/apps/web/src/hooks/useAuth.ts +++ b/apps/web/src/hooks/useAuth.ts @@ -305,6 +305,10 @@ export function useAuth() { }, rootIpnsName: vaultState.rootIpnsName, rootFolderKey: vaultState.rootFolderKey, + // Pass the root IPNS signing keypair so the client can self-bootstrap + // and lazy-load folderTree from root (dissolves "Folder not loaded"; + // fixes bin restore after reload). Guaranteed non-null by the guard above. + rootIpnsKeypair: vaultState.rootIpnsKeypair, teeKeys: authState.teeKeys ?? undefined, pinningConfig, shareCallbacks: { diff --git a/packages/sdk/src/__tests__/ensure-folder-loaded.test.ts b/packages/sdk/src/__tests__/ensure-folder-loaded.test.ts new file mode 100644 index 0000000000..cd753ff94e --- /dev/null +++ b/packages/sdk/src/__tests__/ensure-folder-loaded.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CipherBoxClient } from '../client'; +import { createTestConfig } from './helpers'; +import type { FolderEntry, FolderMetadata } from '@cipherbox/core'; + +// Mock sdk-core: keep everything real except loadFolderMetadata, which we drive +// per-folder so the DFS walk has metadata to descend through. +vi.mock('@cipherbox/sdk-core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadFolderMetadata: vi.fn(), + }; +}); + +// Mock crypto: ensureFolderLoaded unwraps each subfolder's keys with these. +// Return value content is irrelevant — loadFolderMetadata is mocked, so the +// "decrypted" key is never actually used to decrypt anything. +vi.mock('@cipherbox/crypto', () => ({ + unwrapKey: vi.fn().mockResolvedValue(new Uint8Array(32).fill(9)), + hexToBytes: vi.fn().mockReturnValue(new Uint8Array(32)), + clearBytes: vi.fn(), +})); + +import * as sdkCore from '@cipherbox/sdk-core'; + +const ROOT = 'k51test'; // matches createTestConfig().rootIpnsName + +function folderEntry(ipnsName: string, name: string): FolderEntry { + return { + type: 'folder', + id: `id-${ipnsName}`, + name, + ipnsName, + ipnsPrivateKeyEncrypted: 'aa', + folderKeyEncrypted: 'bb', + createdAt: 1, + modifiedAt: 1, + }; +} + +function metadata(children: FolderMetadata['children']): FolderMetadata { + return { version: 'v2', children }; +} + +/** Drive loadFolderMetadata to return canned metadata keyed by IPNS name. */ +function mockTree(tree: Record) { + vi.mocked(sdkCore.loadFolderMetadata).mockImplementation(async ({ ipnsName }) => { + const children = tree[ipnsName]; + if (!children) return null; + return { metadata: metadata(children), sequenceNumber: 1n, cid: `cid-${ipnsName}` }; + }); +} + +const rootIpnsKeypair = { + publicKey: new Uint8Array(32).fill(7), + privateKey: new Uint8Array(64).fill(8), +}; + +describe('CipherBoxClient.ensureFolderLoaded', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns the existing state without resolving IPNS when already loaded', async () => { + const client = new CipherBoxClient(createTestConfig({ rootIpnsKeypair })); + client.getFolderTree().set('k51a', { + ipnsName: 'k51a', + folderKey: new Uint8Array(32).fill(1), + ipnsKeypair: { publicKey: new Uint8Array(0), privateKey: new Uint8Array(32).fill(2) }, + sequenceNumber: 5n, + children: [], + metadata: null, + lastLoadedAt: 1, + }); + + const result = await client.ensureFolderLoaded('k51a'); + + expect(result?.ipnsName).toBe('k51a'); + expect(result?.sequenceNumber).toBe(5n); + expect(sdkCore.loadFolderMetadata).not.toHaveBeenCalled(); + }); + + it('returns null without resolving IPNS when no root IPNS keypair is configured', async () => { + const client = new CipherBoxClient(createTestConfig()); // no rootIpnsKeypair + + const result = await client.ensureFolderLoaded('k51missing'); + + expect(result).toBeNull(); + expect(sdkCore.loadFolderMetadata).not.toHaveBeenCalled(); + }); + + it('bootstraps the root folder when the target IS the root', async () => { + const client = new CipherBoxClient(createTestConfig({ rootIpnsKeypair })); + mockTree({ [ROOT]: [] }); + + const result = await client.ensureFolderLoaded(ROOT); + + expect(result?.ipnsName).toBe(ROOT); + expect(client.hasFolder(ROOT)).toBe(true); + expect(sdkCore.loadFolderMetadata).toHaveBeenCalledTimes(1); + }); + + it('walks from root down to a deep target, registering every folder on the path', async () => { + const client = new CipherBoxClient(createTestConfig({ rootIpnsKeypair })); + // root -> A -> B(target) + mockTree({ + [ROOT]: [folderEntry('k51a', 'A')], + k51a: [folderEntry('k51b', 'B')], + k51b: [], + }); + + const result = await client.ensureFolderLoaded('k51b'); + + expect(result?.ipnsName).toBe('k51b'); + // Whole path cached for cheap subsequent lookups. + expect(client.hasFolder(ROOT)).toBe(true); + expect(client.hasFolder('k51a')).toBe(true); + expect(client.hasFolder('k51b')).toBe(true); + // Keys for the two non-root subfolders were unwrapped with the vault keypair. + const crypto = await import('@cipherbox/crypto'); + expect(vi.mocked(crypto.unwrapKey).mock.calls.length).toBe(4); // folderKey + ipnsKey per subfolder + }); + + it('returns null when the target is not reachable from root', async () => { + const client = new CipherBoxClient(createTestConfig({ rootIpnsKeypair })); + mockTree({ + [ROOT]: [folderEntry('k51a', 'A')], + k51a: [], // no further descendants; target k51zzz absent + }); + + const result = await client.ensureFolderLoaded('k51zzz'); + + expect(result).toBeNull(); + // Still cached what it walked. + expect(client.hasFolder('k51a')).toBe(true); + }); + + it('short-circuits a subfolder that has no IPNS record (skips, keeps walking)', async () => { + const client = new CipherBoxClient(createTestConfig({ rootIpnsKeypair })); + // root has A (unresolvable) and B(target); A returns null from loadFolderMetadata. + mockTree({ + [ROOT]: [folderEntry('k51a', 'A'), folderEntry('k51b', 'B')], + // k51a intentionally absent -> loadFolderMetadata returns null + k51b: [], + }); + + const result = await client.ensureFolderLoaded('k51b'); + + expect(result?.ipnsName).toBe('k51b'); + expect(client.hasFolder('k51a')).toBe(false); + expect(client.hasFolder('k51b')).toBe(true); + }); + + it('skips a sibling whose key unwrap fails and still reaches the target', async () => { + const client = new CipherBoxClient(createTestConfig({ rootIpnsKeypair })); + mockTree({ + [ROOT]: [folderEntry('k51a', 'A'), folderEntry('k51b', 'B')], + k51a: [], + k51b: [], + }); + const crypto = await import('@cipherbox/crypto'); + // First unwrap (folder A's folderKey) throws; later unwraps succeed — one + // corrupt sibling must not abort the whole bootstrap. + vi.mocked(crypto.unwrapKey).mockRejectedValueOnce(new Error('unwrap failed')); + + const result = await client.ensureFolderLoaded('k51b'); + + expect(result?.ipnsName).toBe('k51b'); + expect(client.hasFolder('k51a')).toBe(false); // skipped on unwrap failure + expect(client.hasFolder('k51b')).toBe(true); + }); +}); diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index c5a1ddded2..1ce4c0b552 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -19,7 +19,7 @@ import type { UploadResult } from '@cipherbox/sdk-core'; import * as sdkCore from '@cipherbox/sdk-core'; import { selectEncryptionMode } from '@cipherbox/sdk-core'; import { createAxiosInstance, ipnsControllerUnenrollBatch } from '@cipherbox/api-client'; -import { clearBytes } from '@cipherbox/crypto'; +import { clearBytes, unwrapKey, hexToBytes } from '@cipherbox/crypto'; import pLimit from 'p-limit'; import type { FolderChild, @@ -60,6 +60,11 @@ export class CipherBoxClient { /** Internal copies of key material — zeroed on destroy() without affecting caller buffers */ private internalVaultKeypair: { publicKey: Uint8Array; privateKey: Uint8Array }; private internalRootFolderKey: Uint8Array; + /** + * Internal copy of the root IPNS signing keypair, or null when not configured. + * Enables self-bootstrapping the folder tree from root (see ensureFolderLoaded). + */ + private internalRootIpnsKeypair: { publicKey: Uint8Array; privateKey: Uint8Array } | null = null; constructor(config: CipherBoxClientConfig) { // Defensive copy of key material so destroy() only zeroes our copies @@ -68,10 +73,17 @@ export class CipherBoxClient { privateKey: new Uint8Array(config.vaultKeypair.privateKey), }; this.internalRootFolderKey = new Uint8Array(config.rootFolderKey); + if (config.rootIpnsKeypair) { + this.internalRootIpnsKeypair = { + publicKey: new Uint8Array(config.rootIpnsKeypair.publicKey), + privateKey: new Uint8Array(config.rootIpnsKeypair.privateKey), + }; + } this.config = { ...config, vaultKeypair: this.internalVaultKeypair, rootFolderKey: this.internalRootFolderKey, + ...(this.internalRootIpnsKeypair ? { rootIpnsKeypair: this.internalRootIpnsKeypair } : {}), }; const axiosInstance = config.axiosInstance ?? @@ -234,6 +246,10 @@ export class CipherBoxClient { this.internalVaultKeypair.privateKey.fill(0); this.internalVaultKeypair.publicKey.fill(0); this.internalRootFolderKey.fill(0); + if (this.internalRootIpnsKeypair) { + this.internalRootIpnsKeypair.privateKey.fill(0); + this.internalRootIpnsKeypair.publicKey.fill(0); + } this.binState = null; } @@ -380,6 +396,101 @@ export class CipherBoxClient { }); } + /** + * Ensure a folder is present in the internal folderTree, self-bootstrapping + * from root if necessary. + * + * If the target is already loaded, returns it immediately. Otherwise — when a + * root IPNS keypair was configured — walks the folder tree from root (DFS with + * early exit), resolving each folder's metadata and unwrapping each subfolder's + * `folderKeyEncrypted` / `ipnsPrivateKeyEncrypted` with the vault keypair, until + * the target is registered. Every folder visited along the way is cached, so + * later calls are cheap. + * + * Returns null when the client cannot self-bootstrap (no `rootIpnsKeypair` + * configured) or the target is not reachable from root. Callers fall back to + * their existing 'Folder not loaded' error on null, so behavior is unchanged + * when self-bootstrap is unavailable. This dissolves the "Folder not loaded" + * failure class that previously required consumers to pre-seed folderTree + * before every folderTree-dependent operation. + * + * @param targetIpnsName - IPNS name of the folder to ensure is loaded + * @returns The loaded FolderState, or null if it cannot be bootstrapped + * @internal + */ + async ensureFolderLoaded(targetIpnsName: string): Promise { + const existing = this.folderTree.get(targetIpnsName); + if (existing) return existing; + + // Cannot self-bootstrap without the root IPNS signing key. + if (!this.internalRootIpnsKeypair) return null; + + // 1. Ensure root is loaded. Root is special: it has no parent to unwrap its + // keys from, so they come from config (rootFolderKey + rootIpnsKeypair). + const rootIpnsName = this.config.rootIpnsName; + let root = this.folderTree.get(rootIpnsName) ?? null; + if (!root) { + root = await this.loadFolder( + rootIpnsName, + this.internalRootFolderKey, + this.internalRootIpnsKeypair + ); + if (!root) return null; + } + if (rootIpnsName === targetIpnsName) return root; + + // 2. DFS from root, unwrapping child keys and loading metadata until the + // target is found. `visited` guards against repeats and pathological + // cycles in folder metadata. + const visited = new Set([rootIpnsName]); + const stack: FolderState[] = [root]; + while (stack.length > 0) { + const current = stack.pop() as FolderState; + for (const child of current.children) { + if (child.type !== 'folder') continue; + const entry = child as FolderEntry; + if (visited.has(entry.ipnsName)) continue; + visited.add(entry.ipnsName); + + let childState = this.folderTree.get(entry.ipnsName) ?? null; + if (!childState) { + try { + // Unwrap this subfolder's keys with the vault keypair (ECIES), then + // load its metadata. loadFolder adopts independent clones into the + // folderTree (zeroed on destroy()); the transient unwrapped buffers + // are left to GC, matching the existing loadFolder/registerFolder + // paths which likewise don't eagerly zero their unwrapped inputs. + const folderKey = await unwrapKey( + hexToBytes(entry.folderKeyEncrypted), + this.internalVaultKeypair.privateKey + ); + const ipnsPrivateKey = await unwrapKey( + hexToBytes(entry.ipnsPrivateKeyEncrypted), + this.internalVaultKeypair.privateKey + ); + childState = await this.loadFolder(entry.ipnsName, folderKey, { + // Public key is derived from the private key at signing time. + publicKey: new Uint8Array(0), + privateKey: ipnsPrivateKey, + }); + } catch { + // A single corrupt/undecryptable sibling entry must not abort the + // whole bootstrap — skip it so unrelated targets stay reachable. + // (Generic catch: unwrapKey/hexToBytes throw key-free errors.) + continue; + } + } + // Could not resolve this subfolder (no IPNS record) — skip it. + if (!childState) continue; + if (entry.ipnsName === targetIpnsName) return childState; + stack.push(childState); + } + } + + // Target not found anywhere under root. + return null; + } + /** * Create a new subfolder inside an existing folder. * @@ -396,7 +507,8 @@ export class CipherBoxClient { name: string ): Promise<{ id: string; ipnsName: string; folderKey: Uint8Array; ipnsPrivateKey: Uint8Array }> { return this.withOperation('createFolder', async () => { - const parent = this.folderTree.get(parentIpnsName); + const parent = + this.folderTree.get(parentIpnsName) ?? (await this.ensureFolderLoaded(parentIpnsName)); if (!parent) throw new Error('Parent folder not loaded'); // 1. Check for duplicate name before allocating keys @@ -495,7 +607,8 @@ export class CipherBoxClient { */ async renameItem(folderIpnsName: string, childId: string, newName: string): Promise { return this.withOperation('renameItem', async () => { - const folder = this.folderTree.get(folderIpnsName); + const folder = + this.folderTree.get(folderIpnsName) ?? (await this.ensureFolderLoaded(folderIpnsName)); if (!folder) throw new Error('Folder not loaded'); // 1. Rename in metadata (pure operation) @@ -548,8 +661,10 @@ export class CipherBoxClient { */ async moveItem(sourceIpnsName: string, destIpnsName: string, childId: string): Promise { return this.withOperation('moveItem', async () => { - const source = this.folderTree.get(sourceIpnsName); - const dest = this.folderTree.get(destIpnsName); + const source = + this.folderTree.get(sourceIpnsName) ?? (await this.ensureFolderLoaded(sourceIpnsName)); + const dest = + this.folderTree.get(destIpnsName) ?? (await this.ensureFolderLoaded(destIpnsName)); if (!source) throw new Error('Source folder not loaded'); if (!dest) throw new Error('Destination folder not loaded'); @@ -622,7 +737,8 @@ export class CipherBoxClient { */ async deleteItem(folderIpnsName: string, childId: string): Promise<{ removedItem: FolderChild }> { return this.withOperation('deleteItem', async () => { - const folder = this.folderTree.get(folderIpnsName); + const folder = + this.folderTree.get(folderIpnsName) ?? (await this.ensureFolderLoaded(folderIpnsName)); if (!folder) throw new Error('Folder not loaded'); // 1. Remove from metadata (pure operation) @@ -690,7 +806,8 @@ export class CipherBoxClient { onProgress?: ProgressCallback ): Promise<{ cid: string }> { return this.withOperation('uploadFile', async () => { - const folder = this.folderTree.get(folderIpnsName); + const folder = + this.folderTree.get(folderIpnsName) ?? (await this.ensureFolderLoaded(folderIpnsName)); if (!folder) throw new Error('Folder not loaded'); if (folder.children.some((child) => child.name === fileName)) { @@ -884,7 +1001,8 @@ export class CipherBoxClient { failures: Array<{ fileName: string; error: string }>; }> { return this.withOperation('uploadFiles', async () => { - const folder = this.folderTree.get(folderIpnsName); + const folder = + this.folderTree.get(folderIpnsName) ?? (await this.ensureFolderLoaded(folderIpnsName)); if (!folder) throw new Error('Folder not loaded'); // Build BYO-IPFS pinFn override (same pattern as uploadFile) @@ -1158,7 +1276,8 @@ export class CipherBoxClient { } ): Promise<{ prunedCids: string[] }> { return this.withOperation('replaceFile', async () => { - const folder = this.folderTree.get(parentIpnsName); + const folder = + this.folderTree.get(parentIpnsName) ?? (await this.ensureFolderLoaded(parentIpnsName)); if (!folder) throw new Error('Folder not loaded'); // 1. Find the FilePointer's metadata IPNS name from authoritative state. @@ -1270,7 +1389,8 @@ export class CipherBoxClient { ): Promise<{ prunedCids: string[] }> { void versionIndex; return this.withOperation('restoreFileVersion', async () => { - const folder = this.folderTree.get(parentIpnsName); + const folder = + this.folderTree.get(parentIpnsName) ?? (await this.ensureFolderLoaded(parentIpnsName)); if (!folder) throw new Error('Folder not loaded'); const filePointer = folder.children.find( @@ -1353,7 +1473,8 @@ export class CipherBoxClient { ): Promise<{ deletedCid?: string; prunedCids: string[] }> { void versionIndex; return this.withOperation('deleteFileVersion', async () => { - const folder = this.folderTree.get(parentIpnsName); + const folder = + this.folderTree.get(parentIpnsName) ?? (await this.ensureFolderLoaded(parentIpnsName)); if (!folder) throw new Error('Folder not loaded'); const filePointer = folder.children.find( @@ -1553,6 +1674,10 @@ export class CipherBoxClient { return this.withOperation('deleteToBin', async () => { if (!this.binState) throw new BinNotLoadedError(); + // Self-bootstrap the folder if it isn't loaded (e.g. after a reload), so + // addToBin can read its keys to republish the parent. + await this.ensureFolderLoaded(folderIpnsName); + const { updatedBinState } = await binOps.addToBin({ folderIpnsName, childId, @@ -1590,6 +1715,12 @@ export class CipherBoxClient { return this.withOperation('restoreFromBin', async () => { if (!this.binState) throw new BinNotLoadedError(); + // Self-bootstrap the target folder if it isn't loaded. After a reload the + // user may restore into a folder they never navigated to this session; + // ensureFolderLoaded walks from root and unwraps its keys so restoreFromBin + // can republish the parent (fixes 'Target folder not loaded'). + await this.ensureFolderLoaded(targetFolderIpnsName); + const { updatedBinState } = await binOps.restoreFromBin({ entryId, targetFolderIpnsName, @@ -1719,7 +1850,8 @@ export class CipherBoxClient { recipientPublicKey: Uint8Array ): Promise<{ encryptedKey: string }> { return this.withOperation('shareFolder', async () => { - const folder = this.folderTree.get(folderIpnsName); + const folder = + this.folderTree.get(folderIpnsName) ?? (await this.ensureFolderLoaded(folderIpnsName)); if (!folder) throw new Error('Folder not loaded'); return shareOps.createShareKey({ diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 54f4d88d6e..7592e6cd85 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -52,6 +52,20 @@ export type CipherBoxClientConfig = { rootIpnsName: string; /** Root folder key (AES-256, decrypted on login) */ rootFolderKey: Uint8Array; + /** + * Root folder IPNS signing keypair (Ed25519). + * + * When provided, the client can self-bootstrap and lazy-load the folder tree + * from root on its own (see `CipherBoxClient.ensureFolderLoaded`): it resolves + * the root folder, then walks down to any target by unwrapping each subfolder's + * keys with the vault keypair. This dissolves the "Folder not loaded" failure + * class — consumers no longer need to pre-seed `folderTree` before every + * folderTree-dependent operation (e.g. bin restore after a reload). + * + * Optional for backward compatibility: when absent, folders must be registered + * externally via `registerFolder()` / `loadFolder()` before use. + */ + rootIpnsKeypair?: { publicKey: Uint8Array; privateKey: Uint8Array }; /** TEE keys for IPNS key wrapping */ teeKeys?: TeeKeys; /** diff --git a/tests/web-e2e/tests/bin-restore-after-reload.spec.ts b/tests/web-e2e/tests/bin-restore-after-reload.spec.ts new file mode 100644 index 0000000000..91c2d44fa9 --- /dev/null +++ b/tests/web-e2e/tests/bin-restore-after-reload.spec.ts @@ -0,0 +1,131 @@ +import { test, expect, Browser, BrowserContext, Page } from '@playwright/test'; +import type { PrivateKeyAccount } from 'viem/accounts'; +import { createTestAccount, setupMockWallet, loginViaWallet } from '../utils/wallet-login-helpers'; +import { createTestTextFile, cleanupTestFiles } from '../utils/test-files'; +import { deleteAccountViaPage } from '../utils/cleanup-helpers'; +import { FileListPage } from '../page-objects/file-browser/file-list.page'; +import { UploadZonePage } from '../page-objects/file-browser/upload-zone.page'; +import { ContextMenuPage } from '../page-objects/file-browser/context-menu.page'; +import { ConfirmDialogPage } from '../page-objects/dialogs/confirm-dialog.page'; +import { BinPage } from '../page-objects/pages/bin.page'; +import { CreateFolderDialogPage } from '../page-objects/dialogs/create-folder-dialog.page'; + +/** + * Recycle Bin: restore-after-reload regression + * + * Reproduces the "Target folder not loaded" bug class: restoring a bin item + * whose original parent is a SUBFOLDER that was never navigated to in the + * current session. After a reload only the root folder is re-seeded into the + * SDK folderTree, so the subfolder's keys are absent — pre-fix, restoreFromBin + * threw 'Target folder not loaded'. + * + * The fix gives the SDK client the root IPNS keypair so it can self-bootstrap: + * client.restoreFromBin → ensureFolderLoaded walks from root, unwraps the + * subfolder's keys, and loads it before republishing the parent. + * + * The existing recycle-bin.spec.ts misses this because it uploads in-session + * (parent already loaded) and never reloads. This spec lives on its own with + * its own login so nothing pre-seeds the subfolder into folderTree. + */ +test.describe.serial('Recycle Bin: restore after reload (subfolder parent)', () => { + let browser: Browser; + let context: BrowserContext; + let page: Page; + + let fileList: FileListPage; + let uploadZone: UploadZonePage; + let contextMenu: ContextMenuPage; + let confirmDialog: ConfirmDialogPage; + let binPage: BinPage; + let createFolderDialog: CreateFolderDialogPage; + + let account: PrivateKeyAccount; + + test.beforeAll(async ({ browser: testBrowser }) => { + test.setTimeout(90_000); // Core Kit init + SIWE can be slow + browser = testBrowser; + context = await browser.newContext(); + page = await context.newPage(); + + account = createTestAccount(); + await setupMockWallet(page, account); + + fileList = new FileListPage(page); + uploadZone = new UploadZonePage(page); + contextMenu = new ContextMenuPage(page); + confirmDialog = new ConfirmDialogPage(page); + binPage = new BinPage(page); + createFolderDialog = new CreateFolderDialogPage(page); + + await loginViaWallet(page, { timeout: 60_000 }); + await page.waitForURL('**/files', { timeout: 60000 }); + await Promise.race([ + fileList.fileListContainer().waitFor({ state: 'visible', timeout: 30000 }), + page.locator('[data-testid="empty-state"]').waitFor({ state: 'visible', timeout: 30000 }), + ]); + }); + + test.afterAll(async () => { + cleanupTestFiles(); + if (page) { + await deleteAccountViaPage(page); + } + if (context) { + await context.close(); + } + }); + + test('restores a subfolder item after reload without navigating into the subfolder', async () => { + test.slow(); // folder create + upload + delete + reload + restore IPNS cycles + + const runId = Date.now().toString(); + const subfolderName = `reload-sub-${runId}`; + const fileName = `reload-restore-${runId}.txt`; + + // 1. Create a subfolder under root. + await page.locator('.file-browser-new-folder-button').click(); + await createFolderDialog.waitForOpen(); + await createFolderDialog.createFolder(subfolderName); + await fileList.waitForItemToAppear(subfolderName, { timeout: 30000 }); + + // 2. Navigate into it (empty-state confirms we are inside the new folder). + await fileList.doubleClickFolder(subfolderName); + await page.locator('[data-testid="empty-state"]').waitFor({ state: 'visible', timeout: 30000 }); + + // 3. Upload a file into the subfolder. + const testFile = createTestTextFile(fileName, `restore-after-reload ${runId}`); + await uploadZone.uploadFile(testFile.path); + await fileList.waitForItemToAppear(fileName, { timeout: 60000 }); + + // 4. Delete it → moves to the recycle bin (originalParent = subfolder). + await fileList.rightClickItem(fileName); + await contextMenu.waitForOpen(); + await contextMenu.clickDelete(); + await confirmDialog.waitForOpen(); + await confirmDialog.clickConfirm(); + await fileList.waitForItemToDisappear(fileName, { timeout: 30000 }); + + // 5. Reload — wipes Zustand store AND the SDK folderTree. Only root is + // re-seeded on session restore; the subfolder is NOT loaded. + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.locator('[data-testid="user-menu"]').waitFor({ state: 'visible', timeout: 120000 }); // session auto-restore + + // 6. Go straight to the bin WITHOUT navigating into the subfolder, so the + // subfolder's keys are absent from folderTree at restore time. + await binPage.navigate(); + await binPage.waitForBinItem(fileName, { timeout: 30000 }); + + // 7. Restore. Pre-fix this throws 'Target folder not loaded'; post-fix the + // SDK self-bootstraps the subfolder from root and succeeds. + await binPage.restoreItem(fileName); + await binPage.waitForBinItemToDisappear(fileName, { timeout: 60000 }); + + // 8. Verify the file is restored back into the subfolder. + await page.getByTestId('nav-item-files').click(); + await page.waitForURL('**/files', { timeout: 15000 }); + await fileList.waitForItemToAppear(subfolderName, { timeout: 30000 }); + await fileList.doubleClickFolder(subfolderName); + await fileList.waitForItemToAppear(fileName, { timeout: 60000 }); + expect(await fileList.isItemVisible(fileName)).toBe(true); + }); +}); From eec7f8686279b852ab5227755252d4d1c3c719cb Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Tue, 16 Jun 2026 02:43:02 +0200 Subject: [PATCH 3/4] refactor(sdk): route folder mutations through a requireFolder chokepoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /simplify cleanup of the self-bootstrap change. Behavior-preserving: - Collapse the 11 duplicated `folderTree.get(x) ?? ensureFolderLoaded(x); if (!folder) throw 'X not loaded'` blocks into one private requireFolder(ipnsName, label) helper. Mutations and the two bin wrappers now route through it, so the get-or-self-load-or-throw contract lives in one place and a new method can't silently drop the self-heal fallback (the #494 drift class). - Drop the redundant `as FolderEntry` cast — the `type === 'folder'` guard already narrows the discriminated union. - Simplify the root load (single `?? await loadFolder` expression) and the constructor's rootIpnsKeypair assignment (`?? undefined` vs conditional spread). - Drop a brittle unwrapKey call-count assertion from the deep-walk test; the hasFolder assertions already cover path registration. Co-Authored-By: Claude Opus 4.8 Entire-Checkpoint: 6c13c23c3e2b --- .../__tests__/ensure-folder-loaded.test.ts | 3 - packages/sdk/src/client.ts | 98 +++++++++---------- 2 files changed, 46 insertions(+), 55 deletions(-) diff --git a/packages/sdk/src/__tests__/ensure-folder-loaded.test.ts b/packages/sdk/src/__tests__/ensure-folder-loaded.test.ts index cd753ff94e..b20f06d9ad 100644 --- a/packages/sdk/src/__tests__/ensure-folder-loaded.test.ts +++ b/packages/sdk/src/__tests__/ensure-folder-loaded.test.ts @@ -117,9 +117,6 @@ describe('CipherBoxClient.ensureFolderLoaded', () => { expect(client.hasFolder(ROOT)).toBe(true); expect(client.hasFolder('k51a')).toBe(true); expect(client.hasFolder('k51b')).toBe(true); - // Keys for the two non-root subfolders were unwrapped with the vault keypair. - const crypto = await import('@cipherbox/crypto'); - expect(vi.mocked(crypto.unwrapKey).mock.calls.length).toBe(4); // folderKey + ipnsKey per subfolder }); it('returns null when the target is not reachable from root', async () => { diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 1ce4c0b552..b9b51ec85f 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -83,7 +83,7 @@ export class CipherBoxClient { ...config, vaultKeypair: this.internalVaultKeypair, rootFolderKey: this.internalRootFolderKey, - ...(this.internalRootIpnsKeypair ? { rootIpnsKeypair: this.internalRootIpnsKeypair } : {}), + rootIpnsKeypair: this.internalRootIpnsKeypair ?? undefined, }; const axiosInstance = config.axiosInstance ?? @@ -428,15 +428,14 @@ export class CipherBoxClient { // 1. Ensure root is loaded. Root is special: it has no parent to unwrap its // keys from, so they come from config (rootFolderKey + rootIpnsKeypair). const rootIpnsName = this.config.rootIpnsName; - let root = this.folderTree.get(rootIpnsName) ?? null; - if (!root) { - root = await this.loadFolder( + const root = + this.folderTree.get(rootIpnsName) ?? + (await this.loadFolder( rootIpnsName, this.internalRootFolderKey, this.internalRootIpnsKeypair - ); - if (!root) return null; - } + )); + if (!root) return null; if (rootIpnsName === targetIpnsName) return root; // 2. DFS from root, unwrapping child keys and loading metadata until the @@ -447,12 +446,12 @@ export class CipherBoxClient { while (stack.length > 0) { const current = stack.pop() as FolderState; for (const child of current.children) { + // type === 'folder' narrows child to FolderEntry (discriminated union). if (child.type !== 'folder') continue; - const entry = child as FolderEntry; - if (visited.has(entry.ipnsName)) continue; - visited.add(entry.ipnsName); + if (visited.has(child.ipnsName)) continue; + visited.add(child.ipnsName); - let childState = this.folderTree.get(entry.ipnsName) ?? null; + let childState = this.folderTree.get(child.ipnsName) ?? null; if (!childState) { try { // Unwrap this subfolder's keys with the vault keypair (ECIES), then @@ -461,14 +460,14 @@ export class CipherBoxClient { // are left to GC, matching the existing loadFolder/registerFolder // paths which likewise don't eagerly zero their unwrapped inputs. const folderKey = await unwrapKey( - hexToBytes(entry.folderKeyEncrypted), + hexToBytes(child.folderKeyEncrypted), this.internalVaultKeypair.privateKey ); const ipnsPrivateKey = await unwrapKey( - hexToBytes(entry.ipnsPrivateKeyEncrypted), + hexToBytes(child.ipnsPrivateKeyEncrypted), this.internalVaultKeypair.privateKey ); - childState = await this.loadFolder(entry.ipnsName, folderKey, { + childState = await this.loadFolder(child.ipnsName, folderKey, { // Public key is derived from the private key at signing time. publicKey: new Uint8Array(0), privateKey: ipnsPrivateKey, @@ -482,7 +481,7 @@ export class CipherBoxClient { } // Could not resolve this subfolder (no IPNS record) — skip it. if (!childState) continue; - if (entry.ipnsName === targetIpnsName) return childState; + if (child.ipnsName === targetIpnsName) return childState; stack.push(childState); } } @@ -491,6 +490,23 @@ export class CipherBoxClient { return null; } + /** + * Resolve a folder from internal state, self-bootstrapping from root if needed. + * + * Returns the loaded FolderState or throws `${label} not loaded`. This is the + * single chokepoint every folderTree-dependent mutation routes through, so the + * get-or-self-load-or-throw contract lives in one place and a new method can't + * silently forget the self-heal fallback. + * + * @param ipnsName - IPNS name of the required folder + * @param label - Human label for the error (e.g. 'Parent folder', 'Source folder') + */ + private async requireFolder(ipnsName: string, label = 'Folder'): Promise { + const folder = this.folderTree.get(ipnsName) ?? (await this.ensureFolderLoaded(ipnsName)); + if (!folder) throw new Error(`${label} not loaded`); + return folder; + } + /** * Create a new subfolder inside an existing folder. * @@ -507,9 +523,7 @@ export class CipherBoxClient { name: string ): Promise<{ id: string; ipnsName: string; folderKey: Uint8Array; ipnsPrivateKey: Uint8Array }> { return this.withOperation('createFolder', async () => { - const parent = - this.folderTree.get(parentIpnsName) ?? (await this.ensureFolderLoaded(parentIpnsName)); - if (!parent) throw new Error('Parent folder not loaded'); + const parent = await this.requireFolder(parentIpnsName, 'Parent folder'); // 1. Check for duplicate name before allocating keys if (parent.children.some((child) => child.name === name)) { @@ -607,9 +621,7 @@ export class CipherBoxClient { */ async renameItem(folderIpnsName: string, childId: string, newName: string): Promise { return this.withOperation('renameItem', async () => { - const folder = - this.folderTree.get(folderIpnsName) ?? (await this.ensureFolderLoaded(folderIpnsName)); - if (!folder) throw new Error('Folder not loaded'); + const folder = await this.requireFolder(folderIpnsName); // 1. Rename in metadata (pure operation) const baseChildren = [...folder.children]; @@ -661,12 +673,8 @@ export class CipherBoxClient { */ async moveItem(sourceIpnsName: string, destIpnsName: string, childId: string): Promise { return this.withOperation('moveItem', async () => { - const source = - this.folderTree.get(sourceIpnsName) ?? (await this.ensureFolderLoaded(sourceIpnsName)); - const dest = - this.folderTree.get(destIpnsName) ?? (await this.ensureFolderLoaded(destIpnsName)); - if (!source) throw new Error('Source folder not loaded'); - if (!dest) throw new Error('Destination folder not loaded'); + const source = await this.requireFolder(sourceIpnsName, 'Source folder'); + const dest = await this.requireFolder(destIpnsName, 'Destination folder'); // 1. Compute updated children for both folders (pure operation) const baseDestChildren = [...dest.children]; @@ -737,9 +745,7 @@ export class CipherBoxClient { */ async deleteItem(folderIpnsName: string, childId: string): Promise<{ removedItem: FolderChild }> { return this.withOperation('deleteItem', async () => { - const folder = - this.folderTree.get(folderIpnsName) ?? (await this.ensureFolderLoaded(folderIpnsName)); - if (!folder) throw new Error('Folder not loaded'); + const folder = await this.requireFolder(folderIpnsName); // 1. Remove from metadata (pure operation) const baseChildren = [...folder.children]; @@ -806,9 +812,7 @@ export class CipherBoxClient { onProgress?: ProgressCallback ): Promise<{ cid: string }> { return this.withOperation('uploadFile', async () => { - const folder = - this.folderTree.get(folderIpnsName) ?? (await this.ensureFolderLoaded(folderIpnsName)); - if (!folder) throw new Error('Folder not loaded'); + const folder = await this.requireFolder(folderIpnsName); if (folder.children.some((child) => child.name === fileName)) { throw new Error('An item with this name already exists'); @@ -1001,9 +1005,7 @@ export class CipherBoxClient { failures: Array<{ fileName: string; error: string }>; }> { return this.withOperation('uploadFiles', async () => { - const folder = - this.folderTree.get(folderIpnsName) ?? (await this.ensureFolderLoaded(folderIpnsName)); - if (!folder) throw new Error('Folder not loaded'); + const folder = await this.requireFolder(folderIpnsName); // Build BYO-IPFS pinFn override (same pattern as uploadFile) const mode = this.config.pinningConfig?.mode ?? 'cipherbox'; @@ -1276,9 +1278,7 @@ export class CipherBoxClient { } ): Promise<{ prunedCids: string[] }> { return this.withOperation('replaceFile', async () => { - const folder = - this.folderTree.get(parentIpnsName) ?? (await this.ensureFolderLoaded(parentIpnsName)); - if (!folder) throw new Error('Folder not loaded'); + const folder = await this.requireFolder(parentIpnsName); // 1. Find the FilePointer's metadata IPNS name from authoritative state. const filePointer = folder.children.find( @@ -1389,9 +1389,7 @@ export class CipherBoxClient { ): Promise<{ prunedCids: string[] }> { void versionIndex; return this.withOperation('restoreFileVersion', async () => { - const folder = - this.folderTree.get(parentIpnsName) ?? (await this.ensureFolderLoaded(parentIpnsName)); - if (!folder) throw new Error('Folder not loaded'); + const folder = await this.requireFolder(parentIpnsName); const filePointer = folder.children.find( (c): c is FilePointer => c.type === 'file' && c.id === fileId @@ -1473,9 +1471,7 @@ export class CipherBoxClient { ): Promise<{ deletedCid?: string; prunedCids: string[] }> { void versionIndex; return this.withOperation('deleteFileVersion', async () => { - const folder = - this.folderTree.get(parentIpnsName) ?? (await this.ensureFolderLoaded(parentIpnsName)); - if (!folder) throw new Error('Folder not loaded'); + const folder = await this.requireFolder(parentIpnsName); const filePointer = folder.children.find( (c): c is FilePointer => c.type === 'file' && c.id === fileId @@ -1676,7 +1672,7 @@ export class CipherBoxClient { // Self-bootstrap the folder if it isn't loaded (e.g. after a reload), so // addToBin can read its keys to republish the parent. - await this.ensureFolderLoaded(folderIpnsName); + await this.requireFolder(folderIpnsName); const { updatedBinState } = await binOps.addToBin({ folderIpnsName, @@ -1717,9 +1713,9 @@ export class CipherBoxClient { // Self-bootstrap the target folder if it isn't loaded. After a reload the // user may restore into a folder they never navigated to this session; - // ensureFolderLoaded walks from root and unwraps its keys so restoreFromBin - // can republish the parent (fixes 'Target folder not loaded'). - await this.ensureFolderLoaded(targetFolderIpnsName); + // requireFolder walks from root and unwraps its keys so restoreFromBin can + // republish the parent (fixes 'Target folder not loaded'). + await this.requireFolder(targetFolderIpnsName, 'Target folder'); const { updatedBinState } = await binOps.restoreFromBin({ entryId, @@ -1850,9 +1846,7 @@ export class CipherBoxClient { recipientPublicKey: Uint8Array ): Promise<{ encryptedKey: string }> { return this.withOperation('shareFolder', async () => { - const folder = - this.folderTree.get(folderIpnsName) ?? (await this.ensureFolderLoaded(folderIpnsName)); - if (!folder) throw new Error('Folder not loaded'); + const folder = await this.requireFolder(folderIpnsName); return shareOps.createShareKey({ folderKey: folder.folderKey, From 040c015704a1bfd13c3cd41cf499f61fa9739a46 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:59:24 +0000 Subject: [PATCH 4/4] chore(release): set release targets for PR #498 --- release-please-config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/release-please-config.json b/release-please-config.json index d5d9c564ae..0c87fb3d2a 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -73,7 +73,7 @@ "component": "@cipherbox/web", "include-component-in-tag": true, "bump-minor-pre-major": true, - "release-as": "0.42.0" + "release-as": "0.43.0" }, "apps/desktop": { "release-type": "node", @@ -126,7 +126,7 @@ "component": "@cipherbox/sdk", "include-component-in-tag": true, "bump-minor-pre-major": true, - "release-as": "0.34.0" + "release-as": "0.35.0" }, "crates/crypto": { "release-type": "rust",