this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Split SSE stop from state wipe, and rename useSseConsumer

stopSseConsumer was doing two jobs at once: flipping the started flag
AND draining every keeper (tree, workspace, inbox). That's only a
coherent pair because every logged-in web session currently happens
to also run the SSE consumer — the invariant "logout implies crypto
wipe" was being enforced by coincidence, not by the type system.
Add any logged-in-but-no-SSE path (offline mode, read-only session)
and the wipe silently stops happening.

Split them. `stopSseConsumer` now only flips the started flag and
clears the proposal-sync debounce state — "no new events will be
applied," nothing more. `wipeState` is the new logout-teardown
method that drains every keeper and drops the cached ContentKeys.
OpakeProvider's unmount effect calls both in order; TopBar's full-
reload logout falls through the same cleanup path. Two separate
test assertions cover the pair.

While I was next door: rename `useSseConsumer` to
`useStartSseConsumer` and document the asymmetric semantics
(start-only, no stop). The provider intentionally doesn't delegate
to this hook because its lifetime is different — made that explicit
in the provider's effect comment.

+95 -36
+22 -9
crates/opake-wasm/src/sse_wasm.rs
··· 425 425 Ok(()) 426 426 } 427 427 428 - /// Stop the SSE consumer and wipe decrypted tree + workspace state. 429 - /// 430 - /// Synchronous from JS: React `useEffect` cleanup is sync, so the 431 - /// keeper drains (which need async locks) are fire-and-forget on 432 - /// the event loop. Setting `sse_started = false` is enough on its 433 - /// own to terminate the consumer loop on its next event — 434 - /// `uninstall_all` is what zeroes any cached `ContentKey`s and the 435 - /// workspace list. 428 + /// Stop the SSE consumer. Only flips the `sse_started` flag and 429 + /// clears the proposal-sync debounce state — the consumer loop 430 + /// terminates on its next `next_event().await`. Tree + workspace 431 + /// caches are intentionally preserved: stopping the stream doesn't 432 + /// mean the user is signing out, only that no new events will be 433 + /// applied. Call `wipeState()` separately when crypto material 434 + /// should be zeroed (logout, account switch). 436 435 #[wasm_bindgen(js_name = stopSseConsumer)] 437 436 pub fn stop_sse_consumer(&self) { 438 437 self.sse_started.set(false); 439 438 PROPOSAL_DEBOUNCE_GENERATIONS.with(|state| state.borrow_mut().clear()); 439 + } 440 440 441 + /// Drain every in-memory keeper: directory trees, the workspace 442 + /// list, the inbox. Drops cached `ContentKey`s (triggering their 443 + /// `ZeroizeOnDrop`) and cached decrypted directory names. 444 + /// 445 + /// Synchronous from JS so callers in React `useEffect` cleanup can 446 + /// invoke it directly. The async keeper locks are awaited on a 447 + /// `spawn_local` task — fire-and-forget is safe because no caller 448 + /// observes mid-wipe state. 449 + /// 450 + /// Typical sequence at logout is `stopSseConsumer()` then 451 + /// `wipeState()`. OpakeProvider's unmount effect does this pair. 452 + #[wasm_bindgen(js_name = wipeState)] 453 + pub fn wipe_state(&self) { 441 454 let tree_keeper = Rc::clone(&self.tree_keeper); 442 455 let workspace_keeper = Rc::clone(&self.workspace_keeper); 443 456 let inbox_keeper = Rc::clone(&self.inbox_keeper); ··· 451 464 let mut ik = inbox_keeper.lock().await; 452 465 ik.uninstall_all(); 453 466 log::debug!( 454 - "[sse] tree_keeper + workspace_keeper + inbox_keeper drained on stopSseConsumer" 467 + "[sse] tree_keeper + workspace_keeper + inbox_keeper drained on wipeState" 455 468 ); 456 469 }); 457 470 }
+2
packages/opake-react/src/__tests__/mock-opake.ts
··· 14 14 workspace: Mock<(keyringUri: string) => Promise<MockFileManager>>; 15 15 startSseConsumer: Mock<(indexerUrl?: string) => Promise<void>>; 16 16 stopSseConsumer: Mock<() => void>; 17 + wipeState: Mock<() => void>; 17 18 /** Inspect the last FileManager handed out (for cabinet). */ 18 19 lastCabinetFm: MockFileManager | null; 19 20 /** Inspect last FM per workspace keyring. */ ··· 119 120 workspace: vi.fn(), 120 121 startSseConsumer: vi.fn(async () => {}), 121 122 stopSseConsumer: vi.fn(), 123 + wipeState: vi.fn(), 122 124 lastCabinetFm: null, 123 125 workspaceFms: new Map(), 124 126 };
+8 -4
packages/opake-react/src/__tests__/provider.test.tsx
··· 60 60 await flush(); 61 61 62 62 expect(mock.stopSseConsumer).not.toHaveBeenCalled(); 63 + expect(mock.wipeState).not.toHaveBeenCalled(); 63 64 64 65 unmount(); 65 66 await flush(); 66 67 67 - // Cleanup should have run stopSseConsumer exactly once so that the 68 - // WASM TreeKeeper zeroes cached ContentKeys / decrypted metadata 69 - // before the next Opake instance takes over. 68 + // Cleanup should have run stopSseConsumer + wipeState exactly once: 69 + // stop the stream so no more events land against freshly-uninstalled 70 + // scopes, then wipe so the WASM keepers zero cached ContentKeys / 71 + // decrypted metadata before the next Opake instance takes over. 70 72 expect(mock.stopSseConsumer).toHaveBeenCalledTimes(1); 73 + expect(mock.wipeState).toHaveBeenCalledTimes(1); 71 74 }); 72 75 73 76 it("does NOT stop the SSE consumer on unmount when auto-start is disabled", async () => { ··· 84 87 await flush(); 85 88 86 89 // With auto-start off, the effect skipped entirely and there's no 87 - // cleanup path — stopSseConsumer should not be called either. 90 + // cleanup path — neither stopSseConsumer nor wipeState fires. 88 91 expect(mock.stopSseConsumer).not.toHaveBeenCalled(); 92 + expect(mock.wipeState).not.toHaveBeenCalled(); 89 93 }); 90 94 91 95 it("skips SSE auto-start when disableSseAutoStart is set", async () => {
+1 -1
packages/opake-react/src/hooks/use-directory.ts
··· 57 57 * (typically <1s). 58 58 * 59 59 * Requires an `OpakeProvider` ancestor with `disableSseAutoStart` 60 - * unset (the default), OR an explicit `useSseConsumer()` call 60 + * unset (the default), OR an explicit `useStartSseConsumer()` call 61 61 * somewhere higher in the tree. Without an active SSE consumer the 62 62 * hook will still load the initial tree, but won't receive live 63 63 * updates.
+21 -13
packages/opake-react/src/hooks/use-sse-consumer.ts
··· 1 1 "use client"; 2 2 3 - // useSseConsumer — imperative SSE consumer start. 3 + // useStartSseConsumer — imperative, start-only SSE consumer hook. 4 4 // 5 - // Alternative to the Provider's auto-start. Useful when you want to 6 - // gate the consumer on a runtime condition (authenticated && online, 7 - // feature flag, etc.) rather than starting unconditionally at 8 - // provider mount. 5 + // Contract: when rendered with a defined `indexerUrl`, calls 6 + // `opake.startSseConsumer()` once. There is deliberately no stop 7 + // branch — unmount doesn't tear down the consumer. OpakeProvider's 8 + // built-in auto-start covers the normal "start at mount, stop at 9 + // unmount" lifecycle; use it for that case. 9 10 // 10 - // Idempotent: calling this while another start is already in flight 11 - // is safe — the WASM-side `sse_started` flag prevents double-spawn. 12 - // So calling it alongside the Provider's auto-start is a no-op on 11 + // Reach for this hook only when you need a gate: the consumer starts 12 + // once `indexerUrl` flips from null to a value (e.g., authenticated 13 + // && online, feature flag on). Passing null skips the start, so 14 + // `useStartSseConsumer(isAuthed ? undefined : null)` composes 15 + // cleanly without violating rules-of-hooks. 16 + // 17 + // Idempotent: the WASM-side `sse_started` flag prevents double-spawn, 18 + // so calling this alongside the Provider's auto-start is a no-op on 13 19 // the second call. 14 20 15 21 import { useEffect } from "react"; 16 22 import { useOpake } from "../provider"; 17 23 18 24 /** 19 - * Start the WASM SSE consumer imperatively. 25 + * Start the WASM SSE consumer imperatively. No corresponding stop — 26 + * see the module comment for why. 20 27 * 21 28 * Omit `indexerUrl` to use the URL stored on the Opake instance from 22 29 * config (recommended). Pass an explicit value to override for 23 - * instances without stored config. 30 + * instances without stored config. Pass `null` to skip the start 31 + * (use when gating on a runtime condition). 24 32 * 25 33 * The Provider auto-starts the consumer unless `disableSseAutoStart` 26 34 * is set, so in most apps you don't need this hook at all. Use it ··· 32 40 * ```tsx 33 41 * function Gate() { 34 42 * const isAuthenticated = useAuth(); 35 - * useSseConsumer(isAuthenticated ? undefined : null); 43 + * useStartSseConsumer(isAuthenticated ? undefined : null); 36 44 * return <Outlet />; 37 45 * } 38 46 * ``` 39 47 */ 40 - export function useSseConsumer(indexerUrl?: string | null): void { 48 + export function useStartSseConsumer(indexerUrl?: string | null): void { 41 49 const opake = useOpake(); 42 50 43 51 useEffect(() => { 44 52 // Skip when explicitly nulled — lets callers opt out conditionally 45 - // (e.g., `useSseConsumer(isAuthenticated ? undefined : null)`) 53 + // (e.g., `useStartSseConsumer(isAuthenticated ? undefined : null)`) 46 54 // without breaking the rules of hooks. 47 55 if (indexerUrl === null) return; 48 56
+1 -1
packages/opake-react/src/index.ts
··· 9 9 // Subscription hooks (SSE-driven, live updates) — preferred for reads 10 10 export { useFileManager } from "./hooks/use-file-manager"; 11 11 export { useDirectory } from "./hooks/use-directory"; 12 - export { useSseConsumer } from "./hooks/use-sse-consumer"; 12 + export { useStartSseConsumer } from "./hooks/use-sse-consumer"; 13 13 14 14 // Query hooks (react-query cache) — kept for mutation invalidation 15 15 // paths and workspace list. New consumers should prefer `useDirectory`
+22 -5
packages/opake-react/src/provider.tsx
··· 67 67 * calls `opake.startSseConsumer()` on mount, which uses the indexer 68 68 * URL already stored on the Opake instance from `Opake.init()`. Set 69 69 * true for tests, or for consumers that want explicit control via 70 - * `useSseConsumer` or a manual `opake.startSseConsumer()` call. 70 + * `useStartSseConsumer` or a manual `opake.startSseConsumer()` call. 71 71 */ 72 72 readonly disableSseAutoStart?: boolean; 73 73 /** Optional QueryClient — one is created if not provided. */ ··· 130 130 }; 131 131 }, [cache]); 132 132 133 - // Auto-start the WASM SSE consumer and stop it on unmount so 134 - // `TreeKeeper::uninstall_all` runs — otherwise a previous user's 135 - // `ContentKey`s and decrypted directory names linger across an 136 - // account switch. 133 + // Auto-start the WASM SSE consumer on mount. On unmount, stop the 134 + // stream and wipe the in-memory keepers so a previous user's 135 + // `ContentKey`s and decrypted directory names don't linger across 136 + // an account switch. The stop + wipe pair is intentional: stopping 137 + // the stream alone leaves the tree cached (correct for a user who's 138 + // briefly offline), while wiping alone would race against in-flight 139 + // SSE event application. 140 + // 141 + // This effect intentionally duplicates part of `useStartSseConsumer` 142 + // rather than delegating. The two have different semantics: the 143 + // hook is start-only with no cleanup (so it's safe to call from 144 + // consumers that come and go), while the provider owns the full 145 + // start+stop+wipe lifecycle bound to its own mount. Unifying would 146 + // need either an option flag (sprawl) or refcounting across possible 147 + // competing callers (lifetime complexity). The ~10-line duplication 148 + // wins. 137 149 useEffect(() => { 138 150 if (disableSseAutoStart) return; 139 151 void opake.startSseConsumer().catch((err: unknown) => { ··· 144 156 opake.stopSseConsumer(); 145 157 } catch (err) { 146 158 console.warn("[opake-react] stopSseConsumer failed:", err); 159 + } 160 + try { 161 + opake.wipeState(); 162 + } catch (err) { 163 + console.warn("[opake-react] wipeState failed:", err); 147 164 } 148 165 }; 149 166 }, [opake, disableSseAutoStart]);
+18 -3
packages/opake-sdk/src/opake.ts
··· 795 795 796 796 /** 797 797 * Stop the WASM SSE consumer. Clears the internal running flag so a 798 - * subsequent `startSseConsumer` call can spawn a fresh consumer. 799 - * Also drains the WASM-side WorkspaceKeeper so account switches don't 800 - * leak the previous user's workspace list. 798 + * subsequent `startSseConsumer` call can spawn a fresh consumer. No 799 + * crypto material is wiped — call `wipeState()` for that when the 800 + * session is truly ending (logout, account switch). 801 801 */ 802 802 stopSseConsumer(): void { 803 803 const ctx = this.ctx; 804 804 if (ctx) ctx.stopSseConsumer(); 805 + } 806 + 807 + /** 808 + * Drain every in-memory keeper (directory trees, workspace list, 809 + * inbox). Drops cached ContentKeys (ZeroizeOnDrop fires) and the 810 + * decrypted directory-name cache. Call on logout / account switch 811 + * so one user's crypto state doesn't leak into the next session. 812 + * 813 + * Typical teardown order is `stopSseConsumer()` then `wipeState()` 814 + * — stop the stream first so no events land against freshly- 815 + * uninstalled scopes. OpakeProvider's unmount does this for you. 816 + */ 817 + wipeState(): void { 818 + const ctx = this.ctx; 819 + if (ctx) ctx.wipeState(); 805 820 } 806 821 807 822 /**