this repo has no description
1
fork

Configure Feed

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

Address 15 PR review findings: WorkspaceKeeper, AccountConfigPatch, detachWatcher

HIGH fixes:
- appview URL was only updated when non-None; now always synced from accountConfig
- try_build_entry now returns Some(entry without metadata) on transient decrypt failure
instead of None, preventing spurious workspace disappearance
- detachWatcher() added to workspace store; route.lazy.tsx cleanup calls it on unmount
so live updates survive re-navigation without a flash-to-empty

MEDIUM fixes:
- AccountConfigPatch with tri-state appviewUrl (string | null | absent) replaces
Partial<AccountConfig> footgun; updateAccountConfig, settings page, SDK types updated
- add_account_config_tests: two new integration tests (preserve untouched fields,
explicit null clears appviewUrl)
- useWorkspaces: module-level bootstrapPromise dedup prevents N parallel listWorkspaces
calls from N concurrent hook mounts
- optimistic workspace insert in createWorkspace before SSE echo arrives
- Zeroizing<X25519PrivateKey> return type on private_key_bytes() for key hygiene
- resolveForeignWorkspace WASM binding deleted (leaked group key to JS)
- accurate deadlock comment in apply_keyring_to_workspace_keeper

LOW fixes:
- spurious mut on opake guard in download_from_grant / resolve_identity / resolve_grant_metadata
- member_entries field removed from WorkspaceEntry (internal leak of raw JSON)
- set_default_appview_url deleted (dead method, replaced by accountConfig path)
- accountConfig heartbeat hook comment updated in events_controller

+1874 -531
+1 -1
CLAUDE.md
··· 24 24 6. **Public keys as PDS records.** atproto DID docs only have signing keys. Opake publishes X25519 encryption public keys as `app.opake.publicKey/self` singleton records. 25 25 7. **Multi-device: seed phrase.** Identity keypairs are derived from a BIP-39 24-word mnemonic via PBKDF2 + HKDF. The seed phrase is the default identity creation path — no random keypair fallback. Recovery via `opake recover` (CLI) or the web UI. 26 26 8. **Storage trait in opake-core.** Config, Identity, Session types and the `Storage` trait live in core so both CLI (`FileStorage`, filesystem) and web (`IndexedDbStorage`, IndexedDB) share the same contract. Platform-specific I/O is injected, never imported. 27 - 9. **Domain API: `Opake` → `FileManager` / `WorkspaceAdmin`.** The `Opake<T, R, S>` struct bundles client + identity + RNG + time + storage. All CLI commands route through Opake (sole holdout: `pair request` on a new device with no identity). Call `.file_context(workspace_name?)` + `.file_manager(&context)` for file ops, `.workspace_admin()` for membership ops (add/remove member, leave). Opake itself handles workspace CRUD, sharing, identity, pairing, config, maintenance. All mutations auto-persist sessions via `#[signoff]` (FileManager) or `#[signoff(self)]` (Opake). Raw functions are `pub(crate)`; the domain types ARE the public API. 27 + 9. **Domain API: `Opake` → `FileManager` / `WorkspaceAdmin`.** The `Opake<T, R, S>` struct bundles client + identity + RNG + time + storage. All CLI commands route through Opake (sole holdout: `pair request` on a new device with no identity). Call `.file_context(workspace_name?)` + `.file_manager(&context)` for file ops, `.workspace_admin()` for membership ops (add/remove member, leave). Opake itself handles workspace CRUD, sharing, identity, pairing, config, maintenance. All mutations auto-persist sessions via `#[signoff]` (FileManager) or `#[signoff(self)]` (Opake). Raw functions are `pub(crate)`; the domain types ARE the public API. Live workspace-list state is kept in a `WorkspaceKeeper` (parallel to `TreeKeeper` for directory trees) — bootstrapped by `listWorkspaces`, patched incrementally by SSE `keyring:upsert` / `keyring:delete` events. 28 28 10. **Workspace is the domain concept.** Keyrings are crypto plumbing. The `Workspace` type wraps keyring data with domain semantics. CLI uses `opake workspace`, not `opake keyring`. Lexicon stays `app.opake.keyring` (wire format). 29 29 11. **Sensitive types auto-zeroize.** `RedactedDebug` derive macro generates `Zeroize + Drop` for `#[redact]` fields. ContentKey, Identity, DpopKeyPair, Session types are all zeroized on drop. Nested structs chain — dropping an OAuthSession also zeroizes its DpopKeyPair. 30 30 12. **WASM is the security boundary.** Tokens, DPoP keys, session credentials, and all crypto MUST live in WASM (opake-core). JS cannot zeroize memory — strings are immutable and GC'd on the runtime's schedule. The OAuth login flow itself runs in WASM (`startOAuthLogin`, `completeOAuthLogin`, `loginWithAppPasswordWasm`). Token expiry is checked via `tokenExpiresAt()` (returns only the timestamp). Refresh runs via `proactiveRefresh()` (calls `refresh_token` directly). JS never calls `session()` for auth state — that leaks tokens to the GC. Exception: `PendingLogin` state crosses the boundary during redirect flows (DPoP key in sessionStorage), bounded by a 10-minute TTL and auto-cleared on read.
-1
Cargo.lock
··· 1383 1383 "serde_json", 1384 1384 "wasm-bindgen", 1385 1385 "wasm-bindgen-futures", 1386 - "web-sys", 1387 1386 ] 1388 1387 1389 1388 [[package]]
+1 -1
apps/appview/lib/opake_appview_web/controllers/events_controller.ex
··· 160 160 |> MapSet.new() 161 161 162 162 is_member = MapSet.member?(member_dids, state.did) 163 - was_subscribed = uri && MapSet.member?(state.subscribed_keyrings, uri) 163 + was_subscribed = not is_nil(uri) and MapSet.member?(state.subscribed_keyrings, uri) 164 164 165 165 cond do 166 166 # New membership — subscribe.
+2 -2
apps/cli/src/identity.rs
··· 106 106 assert_eq!(loaded.verify_key, identity.verify_key); 107 107 108 108 assert_eq!(loaded.public_key_bytes().unwrap(), [1u8; 32]); 109 - assert_eq!(loaded.private_key_bytes().unwrap(), [2u8; 32]); 109 + assert_eq!(*loaded.private_key_bytes().unwrap(), [2u8; 32]); 110 110 assert_eq!(loaded.signing_key_bytes().unwrap().unwrap(), [3u8; 32]); 111 111 assert_eq!(loaded.verify_key_bytes().unwrap().unwrap(), [4u8; 32]); 112 112 } ··· 159 159 assert!(identity.has_signing_keys()); 160 160 // X25519 keys preserved. 161 161 assert_eq!(identity.public_key_bytes().unwrap(), [1u8; 32]); 162 - assert_eq!(identity.private_key_bytes().unwrap(), [2u8; 32]); 162 + assert_eq!(*identity.private_key_bytes().unwrap(), [2u8; 32]); 163 163 164 164 // Re-load should have signing keys persisted. 165 165 let reloaded = load_identity(&storage, did).unwrap();
+14 -5
apps/web/src/routes/cabinet/route.lazy.tsx
··· 17 17 const [sidebarOpen, setSidebarOpen] = useState(false); 18 18 const dialogRef = useRef<CreateWorkspaceDialogHandle>(null); 19 19 20 - // Load workspaces on mount (deduped by module-level promise) 20 + // Subscribe to the WASM WorkspaceKeeper on mount. The store installs 21 + // a watcher and (if not already loaded) kicks off the bootstrap 22 + // fetch that populates it. SSE events keep the store live thereafter. 23 + // On unmount, detach the watcher so a remount reinstalls a fresh one 24 + // (the previous keeper was wiped by stopSseConsumer). State is kept 25 + // so the sidebar doesn't flash empty during the unmount/remount cycle. 21 26 useEffect(() => { 22 - void useWorkspaceStore.getState().loadWorkspaces(); 27 + useWorkspaceStore.getState().subscribe(); 28 + return () => { 29 + useWorkspaceStore.getState().detachWatcher(); 30 + }; 23 31 }, []); 24 32 25 33 // Start the WASM SSE consumer; stop it on teardown so 26 34 // `TreeKeeper::uninstall_all` runs and the previous user's 27 35 // `ContentKey`s / decrypted names don't linger across login. 36 + // 37 + // The appview URL is resolved inside WASM from the stored config — 38 + // seeded at boot via `setDefaultAppviewUrl`. No env read here. 28 39 useEffect(() => { 29 - const appviewUrl = import.meta.env.VITE_APPVIEW_URL as string | undefined; 30 - if (!appviewUrl) return; 31 40 const opake = getOpake(); 32 - void opake.startSseConsumer(appviewUrl).catch((err: unknown) => { 41 + void opake.startSseConsumer().catch((err: unknown) => { 33 42 console.warn("[opake] startSseConsumer failed:", err); 34 43 }); 35 44 return () => {
+74 -54
apps/web/src/routes/cabinet/settings.lazy.tsx
··· 1 1 import { useCallback, useEffect, useState } from "react"; 2 2 import { createLazyFileRoute } from "@tanstack/react-router"; 3 - import { UserIcon, FloppyDiskIcon, GearIcon } from "@phosphor-icons/react"; 4 - import type { AccountConfig } from "@opake/sdk"; 3 + import { FloppyDiskIcon } from "@phosphor-icons/react"; 4 + import type { AccountConfigPatch } from "@opake/sdk"; 5 5 import { PanelShell } from "@/components/cabinet/PanelShell"; 6 6 import { getOpake, useAuthStore } from "@/stores/auth"; 7 7 import { truncateDid } from "@/lib/format"; ··· 13 13 const handle = session.status === "active" ? session.handle : null; 14 14 const pdsUrl = session.status === "active" ? session.pdsUrl : null; 15 15 16 - const [config, setConfig] = useState<AccountConfig | null>(null); 16 + const [config, setConfig] = useState<import("@opake/sdk").AccountConfig | null>(null); 17 17 const [appviewUrl, setAppviewUrl] = useState(""); 18 18 const [savedAppviewUrl, setSavedAppviewUrl] = useState(""); 19 19 const [saving, setSaving] = useState(false); ··· 47 47 const handleAppviewSave = useCallback(() => { 48 48 if (!did) return; 49 49 const trimmed = appviewUrl.trim(); 50 + 51 + // Validate URL before saving — a malicious URL would receive Ed25519 52 + // auth signatures that could be replayed within the 60s window. 53 + if (trimmed.length > 0) { 54 + try { 55 + const parsed = new URL(trimmed); 56 + if (parsed.protocol !== "https:") { 57 + toastError("AppView URL must use HTTPS"); 58 + return; 59 + } 60 + } catch { 61 + toastError("Invalid URL"); 62 + return; 63 + } 64 + } 65 + 50 66 setSaving(true); 51 67 void (async () => { 52 68 try { 53 - const updated = await getOpake().updateAccountConfig({ 54 - appviewUrl: trimmed.length > 0 ? trimmed : undefined, 55 - }); 69 + const patch: AccountConfigPatch = { 70 + // Empty field → explicit null (clear the stored override). 71 + // Non-empty → set the new URL. Never undefined, which would 72 + // leave the current value untouched instead of clearing it. 73 + appviewUrl: trimmed.length > 0 ? trimmed : null, 74 + }; 75 + const updated = await getOpake().updateAccountConfig(patch); 56 76 setConfig(updated); 57 77 setSavedAppviewUrl(updated.appviewUrl ?? ""); 58 78 toastSuccess("AppView URL saved"); ··· 80 100 81 101 return ( 82 102 <PanelShell depth={0} breadcrumbs={breadcrumbs} footer=""> 83 - <div className="mx-auto max-w-2xl space-y-8 p-6"> 84 - <h1 className="flex items-center gap-2 text-2xl font-bold"> 85 - <GearIcon size={24} /> Settings 86 - </h1> 103 + <div className="mx-auto max-w-lg space-y-8 px-6 py-6"> 104 + <h1 className="text-base-content text-lg font-semibold">Settings</h1> 87 105 88 106 {/* Account info */} 89 - <section className="card bg-base-200 space-y-2 p-4"> 90 - <h2 className="flex items-center gap-2 font-semibold"> 91 - <UserIcon size={18} /> Account 92 - </h2> 93 - <div className="space-y-1 text-sm"> 94 - <div> 95 - <span className="text-base-content/60">Handle:</span>{" "} 96 - <span className="font-mono">{handle}</span> 97 - </div> 98 - <div> 99 - <span className="text-base-content/60">DID:</span>{" "} 100 - <span className="font-mono text-xs">{truncateDid(did)}</span> 101 - </div> 102 - <div> 103 - <span className="text-base-content/60">PDS:</span>{" "} 104 - <span className="font-mono text-xs">{pdsUrl}</span> 107 + <section> 108 + <h2 className="text-base-content mb-3 text-sm font-semibold">Account</h2> 109 + <div className="space-y-3"> 110 + <div className="space-y-1 text-sm"> 111 + <div> 112 + <span className="text-base-content/60">Handle:</span>{" "} 113 + <span className="font-mono">{handle}</span> 114 + </div> 115 + <div> 116 + <span className="text-base-content/60">DID:</span>{" "} 117 + <span className="font-mono text-xs">{truncateDid(did)}</span> 118 + </div> 119 + <div> 120 + <span className="text-base-content/60">PDS:</span>{" "} 121 + <span className="font-mono text-xs">{pdsUrl}</span> 122 + </div> 105 123 </div> 106 124 </div> 107 125 </section> 108 126 109 127 {/* AppView URL */} 110 - <section className="card bg-base-200 space-y-3 p-4"> 111 - <h2 className="font-semibold">AppView URL</h2> 112 - <p className="text-base-content/60 text-sm"> 113 - The AppView indexes workspace membership and incoming shares. Leave blank to use the 114 - default. 115 - </p> 116 - <div className="flex gap-2"> 117 - <input 118 - type="url" 119 - className="input input-bordered input-sm flex-1" 120 - placeholder="https://appview.opake.app" 121 - value={appviewUrl} 122 - onChange={(e) => setAppviewUrl(e.target.value)} 123 - disabled={saving} 124 - /> 125 - <button 126 - type="button" 127 - className="btn btn-sm btn-primary gap-1.5" 128 - disabled={!appviewDirty || saving} 129 - onClick={handleAppviewSave} 130 - > 131 - <FloppyDiskIcon size={16} /> Save 132 - </button> 133 - </div> 134 - {config && ( 135 - <p className="text-base-content/40 text-xs"> 136 - Last saved: {new Date(config.modifiedAt).toLocaleString()} 128 + <section> 129 + <h2 className="text-base-content mb-3 text-sm font-semibold">AppView URL</h2> 130 + <div className="space-y-3"> 131 + <p className="text-base-content/60 text-sm"> 132 + The AppView indexes workspace membership and incoming shares. Leave blank to use the 133 + default. 137 134 </p> 138 - )} 135 + <div className="flex gap-2"> 136 + <input 137 + type="url" 138 + className="input input-bordered input-sm flex-1" 139 + placeholder="https://appview.opake.app" 140 + value={appviewUrl} 141 + onChange={(e) => setAppviewUrl(e.target.value)} 142 + disabled={saving} 143 + /> 144 + <button 145 + type="button" 146 + className="btn btn-sm btn-primary gap-1.5" 147 + disabled={!appviewDirty || saving} 148 + onClick={handleAppviewSave} 149 + > 150 + <FloppyDiskIcon size={16} /> Save 151 + </button> 152 + </div> 153 + {config && ( 154 + <p className="text-base-content/40 text-xs"> 155 + Last saved: {new Date(config.modifiedAt).toLocaleString()} 156 + </p> 157 + )} 158 + </div> 139 159 </section> 140 160 </div> 141 161 </PanelShell>
+57 -86
apps/web/src/routes/cabinet/workspace-settings/$rkey.lazy.tsx
··· 57 57 ); 58 58 const keyringUri = workspace?.uri ?? null; 59 59 60 - // Members + key material — fetched on-demand for this page 60 + // Members — fetched on-demand for this page. The workspace group key 61 + // never enters JS state; every mutation re-resolves it inside WASM via 62 + // the keyring URI. 61 63 const [rawMembers, setRawMembers] = useState<readonly WorkspaceMember[]>([]); 62 - const [groupKey, setGroupKey] = useState<Uint8Array | null>(null); 63 64 const [loadError, setLoadError] = useState<string | null>(null); 64 65 const members: readonly KeyringMemberEntry[] = useMemo( 65 66 () => rawMembers.map(toMemberEntry), ··· 96 97 // Loaders 97 98 // ----------------------------------------------------------------- 98 99 99 - const reloadMembersAndKey = useCallback(async (uri: string) => { 100 - const opake = getOpake(); 101 - const ms = await opake.listWorkspaceMembers(uri); 100 + const reloadMembers = useCallback(async (uri: string) => { 101 + const ms = await getOpake().listWorkspaceMembers(uri); 102 102 setRawMembers(ms); 103 - // Re-unwrap — owner rotations change the key, additions don't 104 - const key = await opake.unwrapGroupKey(ms); 105 - setGroupKey(key); 106 103 }, []); 107 104 108 - // Refresh after a member mutation. Pulls the fresh member list + key 109 - // from the PDS (no appview lag on that path), then optimistically 110 - // patches the sidebar's member count. We deliberately skip calling 111 - // `loadWorkspaces()` because it would re-fetch via appview and clobber 112 - // any optimistic metadata patch from a prior save. The visibility 113 - // listener reconciles the full workspace record eventually. 105 + // Refresh the page-local member list after a mutation. The sidebar's 106 + // workspace record updates on its own via the SSE echo → 107 + // WorkspaceKeeper path, so we don't patch the store here. 114 108 const refreshAfterMemberChange = useCallback( 115 - async (uri: string, memberCountDelta: number) => { 116 - await reloadMembersAndKey(uri); 117 - useWorkspaceStore.setState((draft) => { 118 - if (!(uri in draft.workspaces)) return; 119 - const w = draft.workspaces[uri]; 120 - draft.workspaces[uri] = { 121 - ...w, 122 - memberCount: Math.max(0, w.memberCount + memberCountDelta), 123 - }; 124 - }); 109 + async (uri: string) => { 110 + await reloadMembers(uri); 125 111 }, 126 - [reloadMembersAndKey], 112 + [reloadMembers], 127 113 ); 128 114 129 115 // Initial load ··· 133 119 const done = loading("workspace-settings-load"); 134 120 (async () => { 135 121 try { 136 - await reloadMembersAndKey(uri); 122 + await reloadMembers(uri); 137 123 setLoadError(null); 138 124 } catch (err) { 139 125 setLoadError(err instanceof Error ? err.message : "Failed to load members"); ··· 143 129 })().catch((err: unknown) => { 144 130 console.error("[workspace-settings] load failed:", err); 145 131 }); 146 - }, [keyringUri, reloadMembersAndKey]); 132 + }, [keyringUri, reloadMembers]); 147 133 148 - // Profile resolution 134 + // Profile resolution — depends only on `members`, not `profiles`. 135 + // `resolveMemberProfile` memoizes per-DID for the page lifetime, so 136 + // re-fetches on member list changes are free for already-resolved DIDs. 137 + // The functional setter avoids a `profiles` dep (which would cause 138 + // N+1 re-runs as each resolution triggers a new reference). 149 139 useEffect(() => { 150 140 if (members.length === 0) return; 151 - const unresolved = members.map((m) => m.did).filter((did) => !(did in profiles)); 152 - if (unresolved.length === 0) return; 153 - void Promise.all( 154 - unresolved.map(async (did) => { 155 - const profile = await resolveMemberProfile(did); 156 - setProfiles((prev) => ({ ...prev, [did]: profile })); 157 - }), 158 - ); 159 - }, [members, profiles]); 141 + members.forEach((m) => { 142 + void resolveMemberProfile(m.did).then((resolved) => { 143 + setProfiles((prev) => (prev[m.did] === resolved ? prev : { ...prev, [m.did]: resolved })); 144 + }); 145 + }); 146 + }, [members]); 160 147 161 148 // ----------------------------------------------------------------- 162 149 // Handlers 163 150 // ----------------------------------------------------------------- 164 151 165 152 const handleSaveMetadata = useCallback(() => { 166 - if (!keyringUri || !groupKey || !name.trim()) return; 153 + if (!keyringUri || !name.trim()) return; 167 154 const uri = keyringUri; 168 155 const done = loading("save-workspace-metadata"); 169 156 (async () => { ··· 171 158 const nextName = nameOverride != null ? name.trim() : undefined; 172 159 const nextDesc = descriptionOverride != null ? description.trim() : undefined; 173 160 const nextIcon = iconOverride ?? undefined; 174 - await getOpake().updateWorkspaceMetadata(uri, groupKey, { 161 + await getOpake().updateWorkspaceMetadata(uri, { 175 162 name: nextName, 176 163 description: nextDesc, 177 164 icon: nextIcon, 178 165 }); 179 166 toastSuccess("Workspace updated"); 180 167 181 - // Optimistic patch: the appview has a 4s cursor lag, so 182 - // `loadWorkspaces` would return stale data and blank the form. 183 - // Update the store locally with the known-saved values; the 184 - // visibility listener (or a future SSE keyring callback) will 185 - // eventually reconcile against the real record. 186 - useWorkspaceStore.setState((draft) => { 187 - if (!(uri in draft.workspaces)) return; 188 - const w = draft.workspaces[uri]; 189 - draft.workspaces[uri] = { 190 - ...w, 191 - name: nextName ?? w.name, 192 - description: nextDesc ?? w.description, 193 - icon: nextIcon ?? w.icon, 194 - }; 195 - }); 168 + // The SSE echo for this keyring write will fire KeyringUpsert, 169 + // which the WorkspaceKeeper applies → watcher → store update. 170 + // No manual store patch needed. 196 171 setNameOverride(null); 197 172 setDescriptionOverride(null); 198 173 setIconOverride(null); ··· 204 179 })().catch((err: unknown) => { 205 180 console.error("[workspace-settings] save failed:", err); 206 181 }); 207 - }, [keyringUri, groupKey, name, description, nameOverride, descriptionOverride, iconOverride]); 182 + }, [keyringUri, name, description, nameOverride, descriptionOverride, iconOverride]); 208 183 209 184 const handleIconSelected = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { 210 185 const file = e.target.files?.[0]; ··· 236 211 237 212 const handleRemove = useCallback( 238 213 (memberDid: string) => { 239 - if (!keyringUri || !groupKey) return; 214 + if (!keyringUri) return; 240 215 if (confirmingRemove !== memberDid) { 241 216 setConfirmingRemove(memberDid); 242 217 return; ··· 247 222 const done = loading("remove-workspace-member"); 248 223 (async () => { 249 224 try { 250 - const result = await getOpake().removeWorkspaceMember(uri, groupKey, memberDid); 225 + const result = await getOpake().removeWorkspaceMember(uri, memberDid); 251 226 toastSuccess(result.proposed ? "Removal proposed" : "Member removed, key rotated"); 252 - await refreshAfterMemberChange(uri, -1); 227 + await refreshAfterMemberChange(uri); 253 228 } catch (err) { 254 229 toastError(err instanceof Error ? err.message : "Failed to remove member"); 255 230 } finally { ··· 259 234 console.error("[workspace-settings] remove failed:", err); 260 235 }); 261 236 }, 262 - [keyringUri, groupKey, confirmingRemove, refreshAfterMemberChange], 237 + [keyringUri, confirmingRemove, refreshAfterMemberChange], 263 238 ); 264 239 265 240 const handleLeave = useCallback(() => { ··· 274 249 try { 275 250 await getOpake().leaveWorkspace(uri); 276 251 toastSuccess("Left workspace"); 277 - await useWorkspaceStore.getState().loadWorkspaces(); 252 + // SSE echo (keyring:upsert without our DID, or keyring:delete 253 + // for an owner-side purge) removes the entry from the keeper. 278 254 void navigate({ to: "/cabinet/files" }); 279 255 } catch (err) { 280 256 toastError(err instanceof Error ? err.message : "Failed to leave workspace"); ··· 288 264 289 265 const handleDeleteWorkspace = useCallback(() => { 290 266 if (!keyringUri) return; 291 - // TODO: workspace deletion not yet implemented in core. Needs purge of 292 - // docs + directories + keyring record as a single transaction. For now 293 - // the confirmation just navigates away — no destructive action taken. 294 - toastError("Delete workspace not yet implemented"); 295 - void navigate({ to: "/cabinet/files" }); 296 - }, [keyringUri, navigate]); 267 + // Workspace deletion not yet implemented in core — needs purge of 268 + // docs + directories + keyring record as a single transaction. 269 + // Reset the confirmation UI without navigating (navigating after a 270 + // typed confirmation phrase makes it look like the delete succeeded). 271 + toastError("Workspace deletion is not yet available"); 272 + setShowDeleteConfirm(false); 273 + }, [keyringUri]); 297 274 298 275 const handleAddMember = useCallback( 299 276 (handle: string, memberRole: WorkspaceRole) => { 300 - if (!keyringUri || !groupKey) return; 277 + if (!keyringUri) return; 301 278 const uri = keyringUri; 302 279 const done = loading("add-workspace-member"); 303 280 (async () => { 304 281 try { 305 282 const opake = getOpake(); 306 283 const identity = await opake.resolveIdentity(handle); 307 - await opake.addWorkspaceMember( 308 - uri, 309 - groupKey, 310 - identity.did, 311 - identity.publicKey, 312 - memberRole, 313 - ); 284 + await opake.addWorkspaceMember(uri, identity.did, identity.publicKey, memberRole); 314 285 toastSuccess(`Added ${identity.handle ?? identity.did}`); 315 - await refreshAfterMemberChange(uri, 1); 286 + await refreshAfterMemberChange(uri); 316 287 } catch (err) { 317 288 toastError(err instanceof Error ? err.message : "Failed to add member"); 318 289 } finally { ··· 322 293 console.error("[workspace-settings] add failed:", err); 323 294 }); 324 295 }, 325 - [keyringUri, groupKey, refreshAfterMemberChange], 296 + [keyringUri, refreshAfterMemberChange], 326 297 ); 327 298 328 299 const handleRoleChange = useCallback( ··· 334 305 try { 335 306 await getOpake().updateMemberRole(uri, memberDid, newRole); 336 307 toastSuccess("Role updated"); 337 - await refreshAfterMemberChange(uri, 0); 308 + await refreshAfterMemberChange(uri); 338 309 } catch (err) { 339 310 toastError(err instanceof Error ? err.message : "Failed to update role"); 340 311 } finally { ··· 408 379 /> 409 380 ) : ( 410 381 <div className="bg-accent text-primary flex size-14 items-center justify-center rounded-xl text-xl font-semibold"> 411 - {name[0].toUpperCase()} 382 + {(name.charAt(0) || "?").toUpperCase()} 412 383 </div> 413 384 )} 414 385 {canManage && ( ··· 461 432 {metaDirty && ( 462 433 <button 463 434 onClick={handleSaveMetadata} 464 - disabled={!name.trim() || !groupKey} 435 + disabled={!name.trim()} 465 436 className="btn btn-primary btn-sm rounded-lg text-xs" 466 437 > 467 438 Save changes ··· 474 445 <section> 475 446 <div className="mb-3 flex items-center justify-between"> 476 447 <h2 className="text-base-content text-sm font-semibold">Members ({members.length})</h2> 477 - {isManager && ( 448 + {canManage && ( 478 449 <button 479 450 onClick={() => addMemberDialogRef.current?.show()} 480 451 className="btn btn-ghost btn-xs gap-1 rounded-lg" ··· 492 463 member={member} 493 464 profile={member.did in profiles ? (profiles[member.did] ?? null) : null} 494 465 isMe={member.did === myDid} 495 - isManager={isManager} 466 + canManage={canManage} 496 467 confirmingRemove={confirmingRemove === member.did} 497 468 onRemove={() => handleRemove(member.did)} 498 469 onRoleChange={(newRole) => handleRoleChange(member.did, newRole)} ··· 561 532 member, 562 533 profile, 563 534 isMe, 564 - isManager, 535 + canManage, 565 536 confirmingRemove, 566 537 onRemove, 567 538 onRoleChange, ··· 569 540 readonly member: KeyringMemberEntry; 570 541 readonly profile: MemberProfile | null; 571 542 readonly isMe: boolean; 572 - readonly isManager: boolean; 543 + readonly canManage: boolean; 573 544 readonly confirmingRemove: boolean; 574 545 readonly onRemove: () => void; 575 546 readonly onRoleChange: (role: WorkspaceRole) => void; 576 547 }) { 577 548 const RoleIcon = ROLE_ICON[member.role]; 578 549 const displayName = profile?.handle ?? member.did; 579 - const canRemove = isManager && !isMe; 580 - const canChangeRole = isManager && !isMe; 550 + const canRemove = canManage && !isMe; 551 + const canChangeRole = canManage && !isMe; 581 552 582 553 return ( 583 554 <li className="flex items-center gap-3 rounded-lg px-3 py-2.5"> ··· 585 556 <img src={profile.avatarUrl} alt="" className="size-8 shrink-0 rounded-full object-cover" /> 586 557 ) : ( 587 558 <div className="bg-accent text-primary text-micro flex size-8 shrink-0 items-center justify-center rounded-full font-semibold"> 588 - {displayName[0].toUpperCase()} 559 + {(displayName.charAt(0) || "?").toUpperCase()} 589 560 </div> 590 561 )} 591 562 <div className="flex min-w-0 flex-1 flex-col">
+24
apps/web/src/stores/auth.ts
··· 60 60 const loadSdk = () => import("@opake/sdk"); 61 61 const loadStorage = () => import("@opake/sdk/storage/indexeddb"); 62 62 63 + /** 64 + * Seed the appview URL into a freshly-initialized Opake instance. 65 + * 66 + * The WASM binary ships with a compile-time `DEFAULT_APPVIEW_URL` 67 + * baked in via `OPAKE_APPVIEW_URL`, but one binary serves multiple 68 + * web deployments — staging, prod, local dev — so the runtime 69 + * `VITE_APPVIEW_URL` has to win. Later writes to `accountConfig` on 70 + * the PDS override this value via `set_account_config` inside core, 71 + * so a user-configured appview still beats the host default. 72 + */ 73 + async function seedAppviewUrl(opake: import("@opake/sdk").Opake): Promise<void> { 74 + const envUrl = import.meta.env.VITE_APPVIEW_URL as string | undefined; 75 + if (!envUrl) return; 76 + try { 77 + await opake.setAppviewUrl(envUrl); 78 + } catch (err) { 79 + console.warn("[auth] setAppviewUrl failed:", err); 80 + } 81 + } 82 + 63 83 // --------------------------------------------------------------------------- 64 84 // Module-level singletons 65 85 // --------------------------------------------------------------------------- ··· 198 218 await s.saveIdentity(did, identity); 199 219 200 220 const opake = await Opake.init({ storage: s, did }); 221 + await seedAppviewUrl(opake); 201 222 opakeInstance?.destroy(); 202 223 opakeInstance = opake; 203 224 } ··· 253 274 }); 254 275 return; 255 276 } 277 + await seedAppviewUrl(opake); 256 278 opakeInstance = opake; 257 279 258 280 // Probe: verify the session is actually usable. Opake.init() ··· 364 386 }); 365 387 366 388 const opake = await Opake.init({ storage: s }); 389 + await seedAppviewUrl(opake); 367 390 opakeInstance = opake; 368 391 369 392 set((draft) => { ··· 458 481 await s.saveIdentity(did, identity); 459 482 460 483 const opake = await Opake.init({ storage: s, did }); 484 + await seedAppviewUrl(opake); 461 485 opakeInstance?.destroy(); 462 486 opakeInstance = opake; 463 487
+90 -94
apps/web/src/stores/workspace.ts
··· 1 - // Workspace store — lists and creates workspaces via @opake/sdk. 1 + // Workspace store — subscribes to the WASM-side WorkspaceKeeper for 2 + // live updates and exposes a record-shaped view for the sidebar + settings. 2 3 // 3 - // Module-level promise dedup prevents StrictMode double-effect from 4 - // sending concurrent `&mut self` borrows into WASM (RefCell panic). 4 + // The keeper is the source of truth: it's bootstrapped once on first 5 + // subscription (via `listWorkspaces`) and patched incrementally from 6 + // SSE `keyring:upsert` / `keyring:delete` events inside WASM. The 7 + // store just mirrors whatever the watcher hands it. 5 8 // 6 - // Real-time refresh: the WASM SSE consumer dispatches an 7 - // `opake:workspace-updated` CustomEvent on the `window` whenever the 8 - // appview broadcasts a keyring record change (this device's writes, 9 - // peer writes, other-device writes). We listen and re-fetch. A 10 - // visibility listener catches edge cases where SSE is disconnected or 11 - // the page was hidden across events. 9 + // There is no optimistic-update cooldown, no visibility-listener 10 + // fallback, and no CustomEvent bridge. Those were workarounds for the 11 + // old "re-fetch listWorkspaces on every SSE hint" pattern, which paid 12 + // 1–4s of appview cursor lag per update. 12 13 13 14 import { create } from "zustand"; 14 15 import { immer } from "zustand/middleware/immer"; 15 - import type { WorkspaceEntry } from "@opake/sdk"; 16 + import type { WorkspaceEntry, WorkspaceWatcher } from "@opake/sdk"; 16 17 import { getOpake } from "@/stores/auth"; 17 18 import { loading } from "@/stores/app"; 18 19 19 20 // --------------------------------------------------------------------------- 20 - // Module-level dedup guards (same pattern as auth store's bootPromise) 21 + // Module-level subscription state 21 22 // --------------------------------------------------------------------------- 22 23 24 + // One watcher at a time. Keyed off the opake instance that created it — 25 + // on account switch, we close the old watcher before opening a new one. 23 26 // eslint-disable-next-line functional/no-let 24 - let loadPromise: Promise<void> | null = null; 27 + let activeWatcher: WorkspaceWatcher | null = null; 25 28 26 - /** 27 - * Shallow change detection over the workspace record. Rotation bumps on 28 - * every keyring mutation so it's a reliable signal — combined with name, 29 - * description, and member count, it catches everything a user cares about 30 - * without a full deep-equal. 31 - */ 32 - function workspacesChanged( 33 - prev: Readonly<Record<string, WorkspaceEntry>>, 34 - next: Readonly<Record<string, WorkspaceEntry>>, 35 - ): boolean { 36 - const prevKeys = Object.keys(prev); 37 - const nextKeys = Object.keys(next); 38 - if (prevKeys.length !== nextKeys.length) return true; 39 - return nextKeys.some((key) => { 40 - const a = prev[key] as WorkspaceEntry | undefined; 41 - const b = next[key] as WorkspaceEntry | undefined; 42 - if (!a || !b) return true; 43 - return ( 44 - a.rotation !== b.rotation || 45 - a.memberCount !== b.memberCount || 46 - a.name !== b.name || 47 - a.description !== b.description 48 - ); 49 - }); 50 - } 29 + // Tracks whether `listWorkspaces` has been called during this subscription. 30 + // The keeper bootstraps as a side effect of that call, so the first 31 + // subscriber kicks it off — subsequent subscribers pick up the same keeper. 32 + // eslint-disable-next-line functional/no-let 33 + let bootstrapPromise: Promise<void> | null = null; 51 34 52 35 // --------------------------------------------------------------------------- 53 36 // Types ··· 60 43 } 61 44 62 45 interface WorkspaceActions { 63 - loadWorkspaces(): Promise<void>; 46 + /** 47 + * Install the WorkspaceKeeper watcher and trigger the initial 48 + * bootstrap fetch if it hasn't happened yet. Idempotent — calling 49 + * twice returns the same watcher lifecycle. 50 + * 51 + * Typically invoked from the auth store once the session becomes 52 + * active, and closed via `reset()` on logout. 53 + */ 54 + subscribe(): void; 64 55 createWorkspace(name: string, description?: string): Promise<string>; 56 + /** 57 + * Close the watcher and clear module-level handles, but keep the 58 + * workspace list in state. Called when the SSE consumer stops (e.g. 59 + * on route unmount) so a later `subscribe()` picks up a fresh watcher 60 + * without flashing the UI to empty. State is cleared separately via 61 + * `reset()` on session transition. 62 + */ 63 + detachWatcher(): void; 64 + /** 65 + * Tear down the watcher and clear store state. Called on logout / 66 + * account switch so the next session doesn't see the previous 67 + * account's workspaces. 68 + */ 65 69 reset(): void; 66 70 } 67 71 ··· 77 81 loaded: false, 78 82 error: null, 79 83 80 - async loadWorkspaces() { 81 - if (loadPromise) { 82 - await loadPromise; 83 - return; 84 - } 84 + subscribe() { 85 + if (activeWatcher) return; 85 86 86 - loadPromise = (async () => { 87 - const done = loading("workspaces"); 88 - try { 89 - const appviewUrl = import.meta.env.VITE_APPVIEW_URL as string | undefined; 90 - const entries = await getOpake().listWorkspaces(appviewUrl); 91 - const record = Object.fromEntries(entries.map((e) => [e.uri, e])); 87 + // Install the watcher first — fires immediately with the current 88 + // (possibly empty, `loaded = false`) snapshot so the UI can show 89 + // a loading state while bootstrap is in flight. 90 + activeWatcher = getOpake().watchWorkspaces((snapshot) => { 91 + const record = Object.fromEntries(snapshot.entries.map((e) => [e.uri, e])); 92 + set((draft) => { 93 + draft.workspaces = record; 94 + draft.loaded = snapshot.loaded; 95 + draft.error = null; 96 + }); 97 + }); 92 98 93 - set((draft) => { 94 - // Skip the write if the record is shallow-equal to the current 95 - // state — avoids spurious Zustand notifications on no-op reloads 96 - // (common under SSE event bursts). 97 - if (!workspacesChanged(draft.workspaces, record)) { 98 - draft.loaded = true; 99 - draft.error = null; 100 - return; 101 - } 102 - draft.workspaces = record; 103 - draft.loaded = true; 104 - draft.error = null; 105 - }); 99 + // Kick off bootstrap if it hasn't happened during this 100 + // subscription. The keeper populates itself as a side effect of 101 + // `listWorkspaces`, and the watcher above sees the resulting 102 + // snapshot. Failures surface as store-level errors but don't 103 + // tear down the watcher — subsequent SSE events can still 104 + // populate it. The appview URL is resolved inside WASM from the 105 + // stored config — seeded at boot via `setDefaultAppviewUrl`. 106 + if (bootstrapPromise) return; 107 + const done = loading("workspaces-bootstrap"); 108 + bootstrapPromise = (async () => { 109 + try { 110 + await getOpake().listWorkspaces(); 106 111 } catch (err) { 107 112 set((draft) => { 108 113 draft.error = err instanceof Error ? err.message : "Failed to load workspaces"; 114 + // Still mark loaded — the UI needs to render _something_, 115 + // and subsequent SSE events can fill in real data. 109 116 draft.loaded = true; 110 117 }); 111 118 } finally { 112 - loadPromise = null; 119 + bootstrapPromise = null; 113 120 done(); 114 121 } 115 122 })(); 116 - 117 - await loadPromise; 118 123 }, 119 124 120 125 async createWorkspace(name, description) { 121 - // No dedup — each call creates a different workspace. 122 - // Dialog disables its button during the async call. 126 + // The keeper watcher picks up the new workspace via the SSE echo 127 + // that follows the PDS write, so no explicit refresh is needed. 128 + // Dialog disables its button during the call. 123 129 const done = loading("create-workspace"); 124 130 try { 125 131 const result = await getOpake().createWorkspace(name, description ?? ""); 126 - loadPromise = null; 127 - await useWorkspaceStore.getState().loadWorkspaces(); 128 132 return result.keyringUri; 129 133 } finally { 130 134 done(); 131 135 } 132 136 }, 133 137 138 + detachWatcher() { 139 + // Idempotent — safe even if the WASM side was already wiped by 140 + // stopSseConsumer. 141 + if (activeWatcher) { 142 + activeWatcher.close(); 143 + activeWatcher = null; 144 + } 145 + bootstrapPromise = null; 146 + // State is intentionally NOT cleared here — the workspace list stays 147 + // visible while the route remounts so there's no flash-to-empty. 148 + }, 149 + 134 150 reset() { 135 - loadPromise = null; 151 + if (activeWatcher) { 152 + activeWatcher.close(); 153 + activeWatcher = null; 154 + } 155 + bootstrapPromise = null; 136 156 set((draft) => { 137 157 draft.workspaces = {}; 138 158 draft.loaded = false; ··· 141 161 }, 142 162 })), 143 163 ); 144 - 145 - // SSE-driven refresh: the WASM consumer fires this CustomEvent on any 146 - // keyring-record-level change. Only relevant after the initial load 147 - // (so we don't fetch before login). `loadWorkspaces` dedups concurrent 148 - // calls, so event bursts coalesce naturally. 149 - if (typeof window !== "undefined") { 150 - window.addEventListener("opake:workspace-updated", () => { 151 - const state = useWorkspaceStore.getState(); 152 - if (!state.loaded) return; 153 - void state.loadWorkspaces(); 154 - }); 155 - } 156 - 157 - // Visibility fallback: covers the case where SSE was disconnected 158 - // while the page was hidden (browser backgrounding, sleep, etc). 159 - // Same dedup semantics as the SSE path. 160 - if (typeof document !== "undefined") { 161 - document.addEventListener("visibilitychange", () => { 162 - if (document.visibilityState !== "visible") return; 163 - const state = useWorkspaceStore.getState(); 164 - if (!state.loaded) return; 165 - void state.loadWorkspaces(); 166 - }); 167 - }
+144
crates/opake-core/src/account_config_tests.rs
··· 1 1 use super::*; 2 2 use crate::client::HttpResponse; 3 + use crate::crypto::OsRng; 3 4 use crate::records::SCHEMA_VERSION; 5 + use crate::storage::{Identity, NoopStorage}; 4 6 use crate::test_utils::MockTransport; 5 7 6 8 fn success(body: &str) -> HttpResponse { ··· 24 26 .to_string() 25 27 } 26 28 29 + fn account_config_json_ext(telemetry: bool, appview: Option<&str>, modified_at: &str) -> String { 30 + let record = AccountConfigRecord { 31 + telemetry_enabled: telemetry, 32 + appview_url: appview.map(str::to_string), 33 + ..AccountConfigRecord::new(modified_at) 34 + }; 35 + serde_json::json!({ 36 + "uri": "at://did:plc:test/app.opake.accountConfig/self", 37 + "cid": "bafyrecord", 38 + "value": record, 39 + }) 40 + .to_string() 41 + } 42 + 43 + fn put_record_response() -> HttpResponse { 44 + success(r#"{"uri":"at://did:plc:test/app.opake.accountConfig/self","cid":"bafynew"}"#) 45 + } 46 + 27 47 fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 28 48 let session = crate::client::Session::Legacy(crate::client::LegacySession { 29 49 did: "did:plc:test".into(), ··· 32 52 refresh_jwt: "test-refresh".into(), 33 53 }); 34 54 XrpcClient::with_session(mock, "https://pds.test".into(), session) 55 + } 56 + 57 + /// Build a minimal `Opake<MockTransport, OsRng, NoopStorage>` suitable for 58 + /// testing methods that go through the XRPC client. 59 + fn make_test_opake(mock: MockTransport) -> crate::opake::Opake<MockTransport, OsRng, NoopStorage> { 60 + let client = mock_client(mock); 61 + let identity = Identity::generate("did:plc:test", &mut OsRng); 62 + crate::opake::Opake::new( 63 + client, 64 + "did:plc:test".into(), 65 + Some(identity), 66 + OsRng, 67 + NoopStorage, 68 + || "2026-01-01T00:00:00Z".to_string(), 69 + || 1_700_000_000_000_000, 70 + ) 35 71 } 36 72 37 73 #[tokio::test] ··· 131 167 assert!(json.get("telemetry_enabled").is_none()); 132 168 assert!(json.get("modified_at").is_none()); 133 169 } 170 + 171 + #[test] 172 + fn updates_absent_field_leaves_value_untouched() { 173 + use crate::records::AccountConfigUpdates; 174 + 175 + let updates: AccountConfigUpdates = serde_json::from_str("{}").unwrap(); 176 + assert!(updates.telemetry_enabled.is_none()); 177 + assert!(updates.appview_url.is_none()); 178 + } 179 + 180 + #[test] 181 + fn updates_explicit_null_clears_appview_url() { 182 + use crate::records::AccountConfigUpdates; 183 + 184 + let updates: AccountConfigUpdates = serde_json::from_str(r#"{"appviewUrl": null}"#).unwrap(); 185 + assert_eq!(updates.appview_url, Some(None)); 186 + } 187 + 188 + #[test] 189 + fn updates_value_sets_appview_url() { 190 + use crate::records::AccountConfigUpdates; 191 + 192 + let updates: AccountConfigUpdates = 193 + serde_json::from_str(r#"{"appviewUrl": "https://appview.test"}"#).unwrap(); 194 + assert_eq!( 195 + updates.appview_url, 196 + Some(Some("https://appview.test".into())) 197 + ); 198 + } 199 + 200 + #[test] 201 + fn updates_accepts_telemetry_toggle() { 202 + use crate::records::AccountConfigUpdates; 203 + 204 + let updates: AccountConfigUpdates = 205 + serde_json::from_str(r#"{"telemetryEnabled": true}"#).unwrap(); 206 + assert_eq!(updates.telemetry_enabled, Some(true)); 207 + } 208 + 209 + // --------------------------------------------------------------------------- 210 + // update_account_config round-trip (#6) 211 + // --------------------------------------------------------------------------- 212 + 213 + /// Updating one field must leave all other fields at their stored values. 214 + /// Specifically: omitting `appview_url` in the updates payload must NOT 215 + /// clear the existing URL on the PDS. 216 + #[tokio::test] 217 + async fn update_account_config_preserves_untouched_fields() { 218 + use crate::records::AccountConfigUpdates; 219 + 220 + let mock = MockTransport::new(); 221 + // Seeded record: telemetry off, custom appview URL 222 + mock.enqueue(success(&account_config_json_ext( 223 + false, 224 + Some("https://custom.appview/"), 225 + "2025-01-01T00:00:00Z", 226 + ))); 227 + mock.enqueue(put_record_response()); 228 + 229 + let mut opake = make_test_opake(mock); 230 + let updates = AccountConfigUpdates { 231 + telemetry_enabled: Some(true), 232 + appview_url: None, // leave alone 233 + }; 234 + let result = opake.update_account_config(updates).await.unwrap(); 235 + 236 + assert!( 237 + result.telemetry_enabled, 238 + "telemetry should be updated to true" 239 + ); 240 + assert_eq!( 241 + result.appview_url.as_deref(), 242 + Some("https://custom.appview/"), 243 + "appview_url must be preserved when absent from updates" 244 + ); 245 + assert_eq!( 246 + result.modified_at, "2026-01-01T00:00:00Z", 247 + "modified_at must be refreshed to the mocked now()" 248 + ); 249 + } 250 + 251 + /// Passing `appview_url: Some(None)` in the updates (explicit JSON null) 252 + /// must overwrite the stored URL with `None`. 253 + #[tokio::test] 254 + async fn update_account_config_explicit_null_clears_appview_url() { 255 + use crate::records::AccountConfigUpdates; 256 + 257 + let mock = MockTransport::new(); 258 + // Seeded record: has a custom appview URL 259 + mock.enqueue(success(&account_config_json_ext( 260 + false, 261 + Some("https://custom.appview/"), 262 + "2025-01-01T00:00:00Z", 263 + ))); 264 + mock.enqueue(put_record_response()); 265 + 266 + let mut opake = make_test_opake(mock); 267 + let updates = AccountConfigUpdates { 268 + telemetry_enabled: None, 269 + appview_url: Some(None), // explicit clear 270 + }; 271 + let result = opake.update_account_config(updates).await.unwrap(); 272 + 273 + assert!( 274 + result.appview_url.is_none(), 275 + "explicit null must clear the stored appview_url" 276 + ); 277 + }
+1 -1
crates/opake-core/src/cabinet.rs
··· 27 27 Ok(Self { 28 28 did: identity.did.clone(), 29 29 public_key: identity.public_key_bytes()?, 30 - private_key: identity.private_key_bytes()?, 30 + private_key: *identity.private_key_bytes()?, 31 31 }) 32 32 } 33 33
+1 -1
crates/opake-core/src/cabinet_tests.rs
··· 9 9 10 10 assert_eq!(cabinet.did, "did:plc:test"); 11 11 assert_eq!(cabinet.public_key, identity.public_key_bytes().unwrap()); 12 - assert_eq!(cabinet.private_key, identity.private_key_bytes().unwrap()); 12 + assert_eq!(cabinet.private_key, *identity.private_key_bytes().unwrap()); 13 13 } 14 14 15 15 #[test]
+1
crates/opake-core/src/lib.rs
··· 45 45 pub mod tid; 46 46 pub mod tree_keeper; 47 47 pub mod workspace; 48 + pub mod workspace_keeper; 48 49 49 50 #[cfg(any(test, feature = "test-utils"))] 50 51 pub mod test_utils;
+1 -1
crates/opake-core/src/manager/editor.rs
··· 100 100 match &self.context { 101 101 FileContext::Cabinet(cabinet) => Ok((cabinet.did.clone(), cabinet.private_key, None)), 102 102 FileContext::Workspace(ws) => { 103 - let private_key = self.opake.require_identity()?.private_key_bytes()?; 103 + let private_key = *self.opake.require_identity()?.private_key_bytes()?; 104 104 Ok((self.opake.did.clone(), private_key, Some(ws.key.clone()))) 105 105 } 106 106 }
+34 -12
crates/opake-core/src/opake.rs
··· 1394 1394 1395 1395 // -- AppView helpers -- 1396 1396 1397 - /// Set the AppView URL if not already configured (e.g. from a build-time default). 1398 - pub fn set_default_appview_url(&mut self, url: &str) { 1399 - if self.appview_url.is_none() { 1400 - self.appview_url = Some(url.to_string()); 1401 - } 1402 - } 1403 - 1404 1397 /// Override the AppView URL unconditionally (runtime env var override). 1405 1398 pub fn set_appview_url(&mut self, url: String) { 1406 1399 self.appview_url = Some(url); ··· 1582 1575 &mut self, 1583 1576 config: &crate::records::AccountConfigRecord, 1584 1577 ) -> Result<String, Error> { 1585 - // Update cached appview URL when config explicitly sets one. 1586 - // Don't overwrite the compile-time default with None. 1587 - if config.appview_url.is_some() { 1588 - self.appview_url = config.appview_url.clone(); 1589 - } 1578 + // Propagate whatever the user last committed to their PDS — including 1579 + // explicit clears (None). The compile-time default is seeded separately 1580 + // via `set_appview_url` and lives below this in priority order. 1581 + self.appview_url = config.appview_url.clone(); 1590 1582 let result = crate::account_config::publish_account_config(&mut self.client, config).await; 1591 1583 self.signoff(result).await 1584 + } 1585 + 1586 + /// Read-merge-write the account config atomically. 1587 + /// 1588 + /// Fetches the current record (or synthesizes a default stamped with 1589 + /// the current `SCHEMA_VERSION`), applies the given partial updates, 1590 + /// refreshes `modified_at`, and writes back. Concurrent callers 1591 + /// serialize under the `&mut self` borrow — no silent clobbers. 1592 + /// 1593 + /// Cuts the WASM boundary crossings in half vs. read-merge-write from 1594 + /// JS (one call instead of two) and keeps the default-record shape 1595 + /// owned by core. 1596 + pub async fn update_account_config( 1597 + &mut self, 1598 + updates: crate::records::AccountConfigUpdates, 1599 + ) -> Result<crate::records::AccountConfigRecord, Error> { 1600 + let now = self.now(); 1601 + let current = self.get_account_config().await?; 1602 + let mut next = current.unwrap_or_else(|| crate::records::AccountConfigRecord::new(&now)); 1603 + 1604 + if let Some(v) = updates.telemetry_enabled { 1605 + next.telemetry_enabled = v; 1606 + } 1607 + if let Some(v) = updates.appview_url { 1608 + next.appview_url = v; 1609 + } 1610 + next.modified_at = now; 1611 + 1612 + self.set_account_config(&next).await?; 1613 + Ok(next) 1592 1614 } 1593 1615 1594 1616 // -- Cross-PDS document download --
+46
crates/opake-core/src/records/account_config.rs
··· 29 29 } 30 30 } 31 31 } 32 + 33 + /// Partial update payload for `AccountConfigRecord`. 34 + /// 35 + /// Field semantics: `Some(v)` replaces the current value, `None` leaves it 36 + /// untouched. `appview_url` uses a nested `Option` so callers can clear it 37 + /// by passing `Some(None)` — serialized as an explicit JSON `null`, which 38 + /// is distinct from an absent/`undefined` field (the latter leaves the 39 + /// current value intact). 40 + #[derive(Debug, Clone, Default, Serialize, Deserialize)] 41 + #[serde(rename_all = "camelCase", default)] 42 + pub struct AccountConfigUpdates { 43 + #[serde(skip_serializing_if = "Option::is_none")] 44 + pub telemetry_enabled: Option<bool>, 45 + #[serde( 46 + default, 47 + skip_serializing_if = "Option::is_none", 48 + with = "double_option" 49 + )] 50 + pub appview_url: Option<Option<String>>, 51 + } 52 + 53 + /// Distinguish absent (`None`) from explicit null (`Some(None)`) for 54 + /// nested `Option` fields. Absent: field wasn't in the input. Explicit 55 + /// null: caller wants the field cleared. 56 + mod double_option { 57 + use serde::{Deserialize, Deserializer, Serialize, Serializer}; 58 + 59 + pub fn serialize<T, S>(value: &Option<Option<T>>, s: S) -> Result<S::Ok, S::Error> 60 + where 61 + T: Serialize, 62 + S: Serializer, 63 + { 64 + match value { 65 + Some(inner) => inner.serialize(s), 66 + None => s.serialize_unit(), 67 + } 68 + } 69 + 70 + pub fn deserialize<'de, T, D>(d: D) -> Result<Option<Option<T>>, D::Error> 71 + where 72 + T: Deserialize<'de>, 73 + D: Deserializer<'de>, 74 + { 75 + Option::<T>::deserialize(d).map(Some) 76 + } 77 + }
+3 -1
crates/opake-core/src/records/mod.rs
··· 29 29 pub use crate::atproto::{AtBytes, BlobRef, CidLink}; 30 30 31 31 // Re-export all record types at the `records::` level. 32 - pub use account_config::{AccountConfigRecord, ACCOUNT_CONFIG_COLLECTION, ACCOUNT_CONFIG_RKEY}; 32 + pub use account_config::{ 33 + AccountConfigRecord, AccountConfigUpdates, ACCOUNT_CONFIG_COLLECTION, ACCOUNT_CONFIG_RKEY, 34 + }; 33 35 pub use defs::{ 34 36 DirectKeyWrapping, EncryptedMetadata, EncryptionEnvelope, KeyWrapping, KeyringKeyWrapping, 35 37 KeyringMember, KeyringRef, Role, WrappedKey,
+4 -3
crates/opake-core/src/storage.rs
··· 15 15 X25519PrivateKey, X25519PublicKey, 16 16 }; 17 17 use crate::error::Error; 18 + use zeroize::Zeroizing; 18 19 19 20 // --------------------------------------------------------------------------- 20 21 // Cache types ··· 151 152 decode_key_bytes(&self.public_key, "public_key") 152 153 } 153 154 154 - pub fn private_key_bytes(&self) -> Result<X25519PrivateKey, Error> { 155 - decode_key_bytes(&self.private_key, "private_key") 155 + pub fn private_key_bytes(&self) -> Result<Zeroizing<X25519PrivateKey>, Error> { 156 + decode_key_bytes(&self.private_key, "private_key").map(Zeroizing::new) 156 157 } 157 158 158 159 pub fn signing_key_bytes(&self) -> Result<Option<Ed25519SecretKey>, Error> { ··· 504 505 verify_key: Some(BASE64.encode([4u8; 32])), 505 506 }; 506 507 assert_eq!(identity.public_key_bytes().unwrap(), [1u8; 32]); 507 - assert_eq!(identity.private_key_bytes().unwrap(), [2u8; 32]); 508 + assert_eq!(*identity.private_key_bytes().unwrap(), [2u8; 32]); 508 509 assert_eq!(identity.signing_key_bytes().unwrap().unwrap(), [3u8; 32]); 509 510 assert_eq!(identity.verify_key_bytes().unwrap().unwrap(), [4u8; 32]); 510 511 }
+386
crates/opake-core/src/workspace_keeper/mod.rs
··· 1 + //! Persistent in-memory workspace list state driven by SSE events. 2 + //! 3 + //! `WorkspaceKeeper` is to the workspace list what [`TreeKeeper`] is to 4 + //! directory trees: a single in-memory source of truth that receives 5 + //! patches from SSE events and notifies subscribers with a typed 6 + //! snapshot. 7 + //! 8 + //! This replaces the older "re-fetch `list_workspaces` on every SSE 9 + //! keyring event" pattern, which relied on a round-trip through the 10 + //! appview and paid 1–4s of cursor-lag latency per update. With the 11 + //! keeper, SSE events patch the list directly — the appview is a 12 + //! cold-start bootstrap path only. 13 + //! 14 + //! ## Cold-start 15 + //! 16 + //! The keeper is constructed empty and `loaded == false`. The first 17 + //! full-list fetch (see `listWorkspaces` in the WASM layer) calls 18 + //! [`WorkspaceKeeper::bootstrap`], which replaces the entry set and 19 + //! flips `loaded = true`. Thereafter, individual [`SseEvent::KeyringUpsert`] 20 + //! and [`SseEvent::KeyringDelete`] events apply incrementally. 21 + //! 22 + //! ## Membership changes 23 + //! 24 + //! When an `SseEvent::KeyringUpsert` arrives for a keyring this client 25 + //! is no longer a member of (DID absent from the member list), 26 + //! [`try_build_entry`] returns `None` and the consumer deletes the 27 + //! workspace from the keeper. Transient key-unwrap failures return 28 + //! `Some(entry)` with `name = None` rather than a delete — the 29 + //! workspace stays visible and self-corrects on the next event. 30 + //! See [`apply_keyring_record`] for the canonical dispatch logic. 31 + //! 32 + //! [`TreeKeeper`]: crate::tree_keeper::TreeKeeper 33 + //! [`SseEvent::KeyringUpsert`]: crate::sse::events::SseEvent::KeyringUpsert 34 + //! [`SseEvent::KeyringDelete`]: crate::sse::events::SseEvent::KeyringDelete 35 + 36 + use std::collections::HashMap; 37 + 38 + use serde::{Deserialize, Serialize}; 39 + 40 + use crate::crypto::{self, KeyringMetadata, X25519PrivateKey}; 41 + use crate::records::{EncryptedMetadata, KeyringMember}; 42 + 43 + // --------------------------------------------------------------------------- 44 + // Types 45 + // --------------------------------------------------------------------------- 46 + 47 + /// Opaque handle returned by [`WorkspaceKeeper::install_watcher`]. Pass 48 + /// to [`WorkspaceKeeper::unwatch`] to stop receiving notifications. 49 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 50 + pub struct WorkspaceWatcherHandle(u64); 51 + 52 + /// A workspace's projected view state — platform-neutral, serializable. 53 + /// 54 + /// Carries everything the UI layer needs to render a workspace in the 55 + /// sidebar. The settings page fetches members on demand via 56 + /// `listWorkspaceMembers` — they are not included here. 57 + /// 58 + /// Two entries are considered equal (no watcher re-fire) when every 59 + /// field matches — rotation alone isn't enough, since metadata can 60 + /// change without a rotation bump. 61 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 62 + #[serde(rename_all = "snake_case")] 63 + pub struct WorkspaceEntry { 64 + pub uri: String, 65 + pub owner_did: String, 66 + pub rotation: u64, 67 + pub member_count: usize, 68 + pub created_at: Option<String>, 69 + pub name: Option<String>, 70 + pub description: Option<String>, 71 + pub icon: Option<String>, 72 + /// The current user's role in this workspace (if a member). 73 + pub my_role: Option<String>, 74 + } 75 + 76 + /// A snapshot of the full workspace list at one moment in time. 77 + #[derive(Debug, Clone, Serialize, Deserialize)] 78 + #[serde(rename_all = "snake_case")] 79 + pub struct WorkspaceSnapshot { 80 + pub entries: Vec<WorkspaceEntry>, 81 + /// `true` once the keeper has been bootstrapped at least once. The 82 + /// initial watcher snapshot fires with `loaded == false` and an 83 + /// empty `entries` list so the UI can show a loading state rather 84 + /// than "no workspaces." 85 + pub loaded: bool, 86 + } 87 + 88 + /// Callback fired whenever the workspace list changes. 89 + /// 90 + /// The keeper fires this synchronously inside the method that caused 91 + /// the change (bootstrap, upsert, delete). Callbacks must not re-enter 92 + /// the keeper. 93 + pub type WorkspaceWatcherCallback = Box<dyn FnMut(&WorkspaceSnapshot)>; 94 + 95 + // --------------------------------------------------------------------------- 96 + // WorkspaceKeeper 97 + // --------------------------------------------------------------------------- 98 + 99 + /// Owns the workspace list and routes SSE keyring events to it. 100 + pub struct WorkspaceKeeper { 101 + entries: HashMap<String, WorkspaceEntry>, 102 + watchers: HashMap<WorkspaceWatcherHandle, WorkspaceWatcherCallback>, 103 + next_watcher_id: u64, 104 + loaded: bool, 105 + } 106 + 107 + impl WorkspaceKeeper { 108 + pub fn new() -> Self { 109 + Self { 110 + entries: HashMap::new(), 111 + watchers: HashMap::new(), 112 + next_watcher_id: 0, 113 + loaded: false, 114 + } 115 + } 116 + 117 + /// `true` once [`bootstrap`] has been called at least once. 118 + /// 119 + /// [`bootstrap`]: Self::bootstrap 120 + pub fn is_loaded(&self) -> bool { 121 + self.loaded 122 + } 123 + 124 + /// Current number of workspaces tracked. 125 + pub fn entry_count(&self) -> usize { 126 + self.entries.len() 127 + } 128 + 129 + /// Number of currently-registered watchers. 130 + pub fn watcher_count(&self) -> usize { 131 + self.watchers.len() 132 + } 133 + 134 + /// Look up a single entry by keyring URI. 135 + pub fn get(&self, uri: &str) -> Option<&WorkspaceEntry> { 136 + self.entries.get(uri) 137 + } 138 + 139 + /// Build a fresh snapshot. Entries are sorted by URI for stable 140 + /// iteration order. 141 + pub fn snapshot(&self) -> WorkspaceSnapshot { 142 + let mut entries: Vec<WorkspaceEntry> = self.entries.values().cloned().collect(); 143 + entries.sort_by(|a, b| a.uri.cmp(&b.uri)); 144 + WorkspaceSnapshot { 145 + entries, 146 + loaded: self.loaded, 147 + } 148 + } 149 + 150 + // -- Mutation API -- 151 + 152 + /// Replace the entire entry set. Called after a full-list fetch 153 + /// from the appview. 154 + pub fn bootstrap(&mut self, entries: Vec<WorkspaceEntry>) { 155 + self.entries = entries.into_iter().map(|e| (e.uri.clone(), e)).collect(); 156 + self.loaded = true; 157 + self.notify(); 158 + } 159 + 160 + /// Insert or replace one entry. If the new entry deep-equals the 161 + /// existing one, no watchers fire — handles idempotent SSE echoes 162 + /// after a local write gracefully. 163 + pub fn upsert(&mut self, entry: WorkspaceEntry) { 164 + let uri = entry.uri.clone(); 165 + if let Some(existing) = self.entries.get(&uri) { 166 + if existing == &entry { 167 + return; 168 + } 169 + } 170 + self.entries.insert(uri, entry); 171 + self.notify(); 172 + } 173 + 174 + /// Remove an entry by URI. No-op (no watcher fire) if it wasn't 175 + /// tracked. 176 + pub fn delete(&mut self, uri: &str) { 177 + if self.entries.remove(uri).is_some() { 178 + self.notify(); 179 + } 180 + } 181 + 182 + /// Apply a rebuilt [`WorkspaceEntry`] derived from a keyring 183 + /// record event. `None` means the caller isn't a member (e.g. 184 + /// they were just rotated out) — we `delete` in that case so the 185 + /// sidebar drops the workspace. 186 + pub fn apply_keyring_record(&mut self, uri: &str, entry: Option<WorkspaceEntry>) { 187 + match entry { 188 + Some(e) => self.upsert(e), 189 + None => self.delete(uri), 190 + } 191 + } 192 + 193 + // -- Watcher API -- 194 + 195 + /// Install a watcher. The callback fires **once immediately** with 196 + /// the current snapshot (matching the `watchDirectory` contract), 197 + /// and again on every subsequent change. 198 + pub fn install_watcher( 199 + &mut self, 200 + mut callback: WorkspaceWatcherCallback, 201 + ) -> WorkspaceWatcherHandle { 202 + let handle = WorkspaceWatcherHandle(self.next_watcher_id); 203 + self.next_watcher_id += 1; 204 + 205 + // Eager first snapshot. 206 + let snap = self.snapshot(); 207 + callback(&snap); 208 + 209 + self.watchers.insert(handle, callback); 210 + handle 211 + } 212 + 213 + /// Remove a previously-installed watcher. 214 + pub fn unwatch(&mut self, handle: WorkspaceWatcherHandle) { 215 + self.watchers.remove(&handle); 216 + } 217 + 218 + /// Drop every entry, clear every watcher, and reset `loaded`. 219 + /// 220 + /// Called on `stopSseConsumer` so account switches don't leak the 221 + /// previous user's workspace list into the next session's UI. 222 + pub fn uninstall_all(&mut self) { 223 + self.entries.clear(); 224 + self.watchers.clear(); 225 + self.loaded = false; 226 + } 227 + 228 + fn notify(&mut self) { 229 + let snap = self.snapshot(); 230 + for callback in self.watchers.values_mut() { 231 + callback(&snap); 232 + } 233 + } 234 + } 235 + 236 + impl Default for WorkspaceKeeper { 237 + fn default() -> Self { 238 + Self::new() 239 + } 240 + } 241 + 242 + // --------------------------------------------------------------------------- 243 + // Entry builder 244 + // --------------------------------------------------------------------------- 245 + 246 + /// Construct a [`WorkspaceEntry`] from raw keyring fields. 247 + /// 248 + /// Returns `None` only when the caller is provably not a member — their 249 + /// DID is absent from the member list. Callers map `None` to a keeper 250 + /// delete so the sidebar drops the workspace. 251 + /// 252 + /// Key-unwrap failures (corrupt data, key material mismatch) return 253 + /// `Some(entry)` with `name`/`description`/`icon` as `None`. The workspace 254 + /// stays visible in the sidebar, just unnamed — matches the pre-refactor 255 + /// `list_workspaces` behavior. A later SSE event or full bootstrap will 256 + /// reconcile once the underlying issue resolves. 257 + /// 258 + /// The metadata-decrypt step is independently best-effort: if the member 259 + /// list unwraps but the encrypted metadata blob can't be decoded, the 260 + /// visible fields are `None` for the same reason. 261 + #[allow(clippy::too_many_arguments)] 262 + pub fn try_build_entry( 263 + uri: &str, 264 + owner_did: &str, 265 + rotation: u64, 266 + raw_members: &[serde_json::Value], 267 + encrypted_metadata: Option<&serde_json::Value>, 268 + created_at: Option<&str>, 269 + my_did: &str, 270 + private_key: &X25519PrivateKey, 271 + ) -> Option<WorkspaceEntry> { 272 + // Parse member records. Anything that doesn't round-trip through the 273 + // `KeyringMember` shape is dropped — matches the existing 274 + // `listWorkspaces` behavior. 275 + let parsed_members: Vec<KeyringMember> = raw_members 276 + .iter() 277 + .filter_map(|v| serde_json::from_value(v.clone()).ok()) 278 + .collect(); 279 + 280 + // Locate our member entry. If not found, we're definitely not a 281 + // member — return None so the caller deletes from the keeper. 282 + let my_member = parsed_members.iter().find(|m| m.did() == my_did)?; 283 + let my_role = my_member.role; 284 + 285 + // Inlined rather than calling `Opake::unwrap_workspace_key`: that 286 + // method lives on the generic `impl<T, R, S> Opake<T, R, S>` block, 287 + // so calling it as a free function would require naming dummy 288 + // generics. The logic is two lines; duplicating avoids the dance. 289 + let group_key = match crypto::unwrap_key(&my_member.wrapped_key, private_key) { 290 + Ok(k) => k, 291 + Err(_) => { 292 + // Unwrap failed — corrupt data or wrong key material. 293 + // Keep the workspace visible without metadata; the next SSE 294 + // event or full bootstrap will reconcile. 295 + return Some(WorkspaceEntry { 296 + uri: uri.to_string(), 297 + owner_did: owner_did.to_string(), 298 + rotation, 299 + member_count: raw_members.len(), 300 + created_at: created_at.map(str::to_string), 301 + name: None, 302 + description: None, 303 + icon: None, 304 + my_role: Some(my_role.to_string()), 305 + }); 306 + } 307 + }; 308 + 309 + // Best-effort metadata decrypt. 310 + let (name, description, icon) = match encrypted_metadata { 311 + Some(em_json) => { 312 + let decoded = serde_json::from_value::<EncryptedMetadata>(em_json.clone()) 313 + .ok() 314 + .and_then(|em| crypto::decrypt_metadata::<KeyringMetadata>(&group_key, &em).ok()); 315 + match decoded { 316 + Some(meta) => (Some(meta.name), meta.description, meta.icon), 317 + None => (None, None, None), 318 + } 319 + } 320 + None => (None, None, None), 321 + }; 322 + 323 + Some(WorkspaceEntry { 324 + uri: uri.to_string(), 325 + owner_did: owner_did.to_string(), 326 + rotation, 327 + member_count: raw_members.len(), 328 + created_at: created_at.map(str::to_string), 329 + name, 330 + description, 331 + icon, 332 + my_role: Some(my_role.to_string()), 333 + }) 334 + } 335 + 336 + /// Convenience wrapper: build an entry from an [`AppviewKeyring`]. 337 + /// 338 + /// [`AppviewKeyring`]: crate::client::AppviewKeyring 339 + pub fn try_build_entry_from_appview_keyring( 340 + keyring: &crate::client::AppviewKeyring, 341 + my_did: &str, 342 + private_key: &X25519PrivateKey, 343 + ) -> Option<WorkspaceEntry> { 344 + try_build_entry( 345 + &keyring.uri, 346 + &keyring.owner_did, 347 + keyring.rotation, 348 + &keyring.members, 349 + keyring.encrypted_metadata.as_ref(), 350 + keyring.created_at.as_deref(), 351 + my_did, 352 + private_key, 353 + ) 354 + } 355 + 356 + /// Convenience wrapper: build an entry from an [`SseKeyringRecord`]. 357 + /// 358 + /// `rotation` defaults to `0` when absent from the SSE payload (a 359 + /// well-formed broadcaster always emits it, but the field is `Option` 360 + /// in the wire type so we handle the gap defensively). 361 + /// 362 + /// [`SseKeyringRecord`]: crate::sse::events::SseKeyringRecord 363 + pub fn try_build_entry_from_sse_record( 364 + record: &crate::sse::events::SseKeyringRecord, 365 + my_did: &str, 366 + private_key: &X25519PrivateKey, 367 + ) -> Option<WorkspaceEntry> { 368 + try_build_entry( 369 + &record.uri, 370 + &record.owner_did, 371 + record.rotation.unwrap_or(0), 372 + &record.member_entries, 373 + record.encrypted_metadata.as_ref(), 374 + record.created_at.as_deref(), 375 + my_did, 376 + private_key, 377 + ) 378 + } 379 + 380 + // --------------------------------------------------------------------------- 381 + // Tests 382 + // --------------------------------------------------------------------------- 383 + 384 + #[cfg(test)] 385 + #[path = "tests.rs"] 386 + mod tests;
+310
crates/opake-core/src/workspace_keeper/tests.rs
··· 1 + // WorkspaceKeeper unit tests. 2 + // 3 + // Keeper state/watcher lifecycle only — the crypto side (try_build_entry) 4 + // is covered indirectly through the WASM listWorkspaces + SSE consumer 5 + // integration path, which already has end-to-end tests. 6 + 7 + use std::cell::RefCell; 8 + use std::rc::Rc; 9 + 10 + use super::*; 11 + use crate::crypto::{CryptoRng, RngCore, X25519PrivateKey}; 12 + 13 + fn sample_entry(uri: &str, rotation: u64) -> WorkspaceEntry { 14 + WorkspaceEntry { 15 + uri: uri.to_string(), 16 + owner_did: "did:plc:alice".to_string(), 17 + rotation, 18 + member_count: 2, 19 + created_at: Some("2026-04-17T00:00:00Z".to_string()), 20 + name: Some("Test".to_string()), 21 + description: None, 22 + icon: None, 23 + my_role: Some("manager".to_string()), 24 + } 25 + } 26 + 27 + /// Capture every snapshot that fires through a watcher so tests can 28 + /// assert on the delivered sequence. 29 + fn capture_snapshots() -> ( 30 + Rc<RefCell<Vec<WorkspaceSnapshot>>>, 31 + WorkspaceWatcherCallback, 32 + ) { 33 + let captured: Rc<RefCell<Vec<WorkspaceSnapshot>>> = Rc::new(RefCell::new(Vec::new())); 34 + let captured_clone = Rc::clone(&captured); 35 + let callback: WorkspaceWatcherCallback = 36 + Box::new(move |snap: &WorkspaceSnapshot| captured_clone.borrow_mut().push(snap.clone())); 37 + (captured, callback) 38 + } 39 + 40 + #[test] 41 + fn new_keeper_is_empty_and_unloaded() { 42 + let keeper = WorkspaceKeeper::new(); 43 + assert!(!keeper.is_loaded()); 44 + assert_eq!(keeper.entry_count(), 0); 45 + assert_eq!(keeper.watcher_count(), 0); 46 + } 47 + 48 + #[test] 49 + fn install_watcher_fires_initial_snapshot() { 50 + let mut keeper = WorkspaceKeeper::new(); 51 + let (captured, callback) = capture_snapshots(); 52 + let _handle = keeper.install_watcher(callback); 53 + 54 + let snaps = captured.borrow(); 55 + assert_eq!(snaps.len(), 1, "watcher fires once immediately on install"); 56 + assert!(!snaps[0].loaded); 57 + assert!(snaps[0].entries.is_empty()); 58 + } 59 + 60 + #[test] 61 + fn bootstrap_sets_loaded_and_notifies_watcher() { 62 + let mut keeper = WorkspaceKeeper::new(); 63 + let (captured, callback) = capture_snapshots(); 64 + keeper.install_watcher(callback); 65 + 66 + keeper.bootstrap(vec![ 67 + sample_entry("at://a/kr/1", 1), 68 + sample_entry("at://a/kr/2", 1), 69 + ]); 70 + 71 + let snaps = captured.borrow(); 72 + assert_eq!(snaps.len(), 2, "initial + post-bootstrap"); 73 + let last = &snaps[1]; 74 + assert!(last.loaded); 75 + assert_eq!(last.entries.len(), 2); 76 + } 77 + 78 + #[test] 79 + fn upsert_with_identical_entry_does_not_refire() { 80 + let mut keeper = WorkspaceKeeper::new(); 81 + keeper.bootstrap(vec![sample_entry("at://a/kr/1", 1)]); 82 + 83 + let (captured, callback) = capture_snapshots(); 84 + keeper.install_watcher(callback); 85 + 86 + // Re-apply the same entry — idempotent echo scenario. 87 + keeper.upsert(sample_entry("at://a/kr/1", 1)); 88 + 89 + let snaps = captured.borrow(); 90 + assert_eq!( 91 + snaps.len(), 92 + 1, 93 + "initial snapshot only — no re-fire on no-op upsert" 94 + ); 95 + } 96 + 97 + #[test] 98 + fn upsert_with_changed_entry_refires() { 99 + let mut keeper = WorkspaceKeeper::new(); 100 + keeper.bootstrap(vec![sample_entry("at://a/kr/1", 1)]); 101 + 102 + let (captured, callback) = capture_snapshots(); 103 + keeper.install_watcher(callback); 104 + 105 + // Rotation bump — fire. 106 + keeper.upsert(sample_entry("at://a/kr/1", 2)); 107 + 108 + let snaps = captured.borrow(); 109 + assert_eq!(snaps.len(), 2); 110 + assert_eq!(snaps[1].entries[0].rotation, 2); 111 + } 112 + 113 + #[test] 114 + fn delete_removes_entry_and_fires() { 115 + let mut keeper = WorkspaceKeeper::new(); 116 + keeper.bootstrap(vec![ 117 + sample_entry("at://a/kr/1", 1), 118 + sample_entry("at://a/kr/2", 1), 119 + ]); 120 + 121 + let (captured, callback) = capture_snapshots(); 122 + keeper.install_watcher(callback); 123 + 124 + keeper.delete("at://a/kr/1"); 125 + 126 + let snaps = captured.borrow(); 127 + assert_eq!(snaps.len(), 2); 128 + assert_eq!(snaps[1].entries.len(), 1); 129 + assert_eq!(snaps[1].entries[0].uri, "at://a/kr/2"); 130 + } 131 + 132 + #[test] 133 + fn delete_missing_uri_is_noop() { 134 + let mut keeper = WorkspaceKeeper::new(); 135 + keeper.bootstrap(vec![sample_entry("at://a/kr/1", 1)]); 136 + 137 + let (captured, callback) = capture_snapshots(); 138 + keeper.install_watcher(callback); 139 + 140 + keeper.delete("at://a/kr/nonexistent"); 141 + 142 + let snaps = captured.borrow(); 143 + assert_eq!(snaps.len(), 1, "no fire when URI wasn't tracked"); 144 + } 145 + 146 + #[test] 147 + fn apply_keyring_record_none_deletes() { 148 + let mut keeper = WorkspaceKeeper::new(); 149 + keeper.bootstrap(vec![sample_entry("at://a/kr/1", 1)]); 150 + 151 + let (captured, callback) = capture_snapshots(); 152 + keeper.install_watcher(callback); 153 + 154 + // "Record arrived but we're not a member anymore" → delete. 155 + keeper.apply_keyring_record("at://a/kr/1", None); 156 + 157 + let snaps = captured.borrow(); 158 + assert_eq!(snaps.len(), 2); 159 + assert!(snaps[1].entries.is_empty()); 160 + } 161 + 162 + #[test] 163 + fn unwatch_stops_notifications() { 164 + let mut keeper = WorkspaceKeeper::new(); 165 + let (captured, callback) = capture_snapshots(); 166 + let handle = keeper.install_watcher(callback); 167 + 168 + keeper.unwatch(handle); 169 + keeper.bootstrap(vec![sample_entry("at://a/kr/1", 1)]); 170 + 171 + let snaps = captured.borrow(); 172 + assert_eq!( 173 + snaps.len(), 174 + 1, 175 + "only the initial snapshot — unwatched before bootstrap" 176 + ); 177 + assert_eq!(keeper.watcher_count(), 0); 178 + } 179 + 180 + #[test] 181 + fn uninstall_all_clears_entries_watchers_and_loaded() { 182 + let mut keeper = WorkspaceKeeper::new(); 183 + keeper.bootstrap(vec![sample_entry("at://a/kr/1", 1)]); 184 + 185 + let (_captured, callback) = capture_snapshots(); 186 + keeper.install_watcher(callback); 187 + 188 + keeper.uninstall_all(); 189 + 190 + assert!(!keeper.is_loaded()); 191 + assert_eq!(keeper.entry_count(), 0); 192 + assert_eq!(keeper.watcher_count(), 0); 193 + } 194 + 195 + #[test] 196 + fn snapshot_entries_are_sorted_by_uri() { 197 + let mut keeper = WorkspaceKeeper::new(); 198 + keeper.bootstrap(vec![ 199 + sample_entry("at://a/kr/c", 1), 200 + sample_entry("at://a/kr/a", 1), 201 + sample_entry("at://a/kr/b", 1), 202 + ]); 203 + let snap = keeper.snapshot(); 204 + let uris: Vec<&str> = snap.entries.iter().map(|e| e.uri.as_str()).collect(); 205 + assert_eq!(uris, vec!["at://a/kr/a", "at://a/kr/b", "at://a/kr/c"]); 206 + } 207 + 208 + #[test] 209 + fn multiple_watchers_all_receive_updates() { 210 + let mut keeper = WorkspaceKeeper::new(); 211 + 212 + let (cap_a, cb_a) = capture_snapshots(); 213 + let (cap_b, cb_b) = capture_snapshots(); 214 + keeper.install_watcher(cb_a); 215 + keeper.install_watcher(cb_b); 216 + 217 + keeper.bootstrap(vec![sample_entry("at://a/kr/1", 1)]); 218 + 219 + assert_eq!(cap_a.borrow().len(), 2); 220 + assert_eq!(cap_b.borrow().len(), 2); 221 + } 222 + 223 + // --------------------------------------------------------------------------- 224 + // try_build_entry — unwrap-failure semantics (#2) 225 + // --------------------------------------------------------------------------- 226 + 227 + fn make_member_json( 228 + did: &str, 229 + role: crate::records::Role, 230 + rng: &mut (impl CryptoRng + RngCore), 231 + ) -> serde_json::Value { 232 + use crate::crypto::{generate_content_key, wrap_key, X25519DalekStaticSecret}; 233 + use crate::records::KeyringMember; 234 + 235 + let secret = X25519DalekStaticSecret::random_from_rng(&mut *rng); 236 + let public = crate::crypto::X25519DalekPublicKey::from(&secret); 237 + let gk = generate_content_key(rng); 238 + let wrapped = wrap_key(&gk, public.as_bytes(), did, rng).unwrap(); 239 + serde_json::to_value(KeyringMember { 240 + wrapped_key: wrapped, 241 + role, 242 + }) 243 + .unwrap() 244 + } 245 + 246 + /// A wrong private key causes unwrap to fail. The entry must still be 247 + /// returned (name = None), not silently deleted. The workspace stays 248 + /// visible in the sidebar and reconciles on the next SSE event. 249 + #[test] 250 + fn try_build_entry_unwrap_failure_returns_some_without_metadata() { 251 + use crate::crypto::{OsRng, X25519DalekStaticSecret}; 252 + 253 + let mut rng: OsRng = OsRng; 254 + let member_json = make_member_json("did:plc:alice", crate::records::Role::Manager, &mut rng); 255 + 256 + // Completely different private key — unwrap will fail. 257 + let wrong_key: X25519PrivateKey = X25519DalekStaticSecret::random_from_rng(rng).to_bytes(); 258 + 259 + let entry = try_build_entry( 260 + "at://did:plc:alice/app.opake.keyring/abc", 261 + "did:plc:alice", 262 + 1, 263 + &[member_json], 264 + None, 265 + None, 266 + "did:plc:alice", 267 + &wrong_key, 268 + ); 269 + 270 + let entry = entry.expect("unwrap failure must return Some, not None"); 271 + assert_eq!(entry.uri, "at://did:plc:alice/app.opake.keyring/abc"); 272 + assert!( 273 + entry.name.is_none(), 274 + "name should be None when unwrap fails" 275 + ); 276 + assert!( 277 + entry.description.is_none(), 278 + "description should be None when unwrap fails" 279 + ); 280 + assert_eq!( 281 + entry.my_role.as_deref(), 282 + Some("manager"), 283 + "role must be preserved even when unwrap fails" 284 + ); 285 + } 286 + 287 + /// DID absent from member list → None → keeper deletes the workspace. 288 + #[test] 289 + fn try_build_entry_non_member_returns_none() { 290 + use crate::crypto::OsRng; 291 + 292 + let mut rng: OsRng = OsRng; 293 + // alice is a member; bob is the caller. 294 + let member_json = make_member_json("did:plc:alice", crate::records::Role::Manager, &mut rng); 295 + let bobs_key: X25519PrivateKey = 296 + crate::crypto::X25519DalekStaticSecret::random_from_rng(rng).to_bytes(); 297 + 298 + let result = try_build_entry( 299 + "at://did:plc:alice/app.opake.keyring/abc", 300 + "did:plc:alice", 301 + 1, 302 + &[member_json], 303 + None, 304 + None, 305 + "did:plc:bob", 306 + &bobs_key, 307 + ); 308 + 309 + assert!(result.is_none(), "non-member DID must return None"); 310 + }
-3
crates/opake-wasm/Cargo.toml
··· 20 20 console_log = { version = "1", features = ["color"] } 21 21 console_error_panic_hook = "0.1" 22 22 23 - # Window + CustomEvent for SSE → JS notification dispatch. 24 - web-sys = { version = "0.3", features = ["Window", "CustomEvent", "CustomEventInit", "Event", "EventTarget"] } 25 - 26 23 # Async Mutex for the shared WasmOpake — RefCell panics on concurrent 27 24 # borrow across await points; Mutex queues instead. 28 25 futures-util = { workspace = true }
+130 -126
crates/opake-wasm/src/opake_wasm.rs
··· 24 24 25 25 use futures_util::lock::Mutex; 26 26 use opake_core::tree_keeper::TreeKeeper; 27 + use opake_core::workspace_keeper::WorkspaceKeeper; 27 28 use serde::Serialize; 28 29 use wasm_bindgen::prelude::*; 29 30 ··· 45 46 /// Mutex (separate from the Opake mutex) so SSE event application and 46 47 /// file operations don't block each other. 47 48 pub(crate) tree_keeper: Rc<Mutex<TreeKeeper>>, 49 + /// Live workspace-list state. Bootstrapped from `listWorkspaces` and 50 + /// patched incrementally from SSE `keyring:upsert` / `keyring:delete` 51 + /// events. JS subscribes via `watchWorkspaces`. Separate mutex from 52 + /// `tree_keeper` so directory watcher fires and workspace list fires 53 + /// don't block each other. 54 + pub(crate) workspace_keeper: Rc<Mutex<WorkspaceKeeper>>, 48 55 /// `true` while an SSE consumer task is alive. Doubles as both the 49 56 /// idempotency gate on `startSseConsumer` and the cancellation 50 57 /// signal read by the consumer loop — `stopSseConsumer` clears it, ··· 68 75 Ok(Self { 69 76 inner: Rc::new(Mutex::new(Some(opake))), 70 77 tree_keeper: Rc::new(Mutex::new(TreeKeeper::new(did_owned))), 78 + workspace_keeper: Rc::new(Mutex::new(WorkspaceKeeper::new())), 71 79 sse_started: Rc::new(std::cell::Cell::new(false)), 72 80 }) 73 81 } ··· 168 176 .await 169 177 .map_err(wasm_err)?; 170 178 179 + // Optimistic insert — the sidebar shows the new workspace immediately 180 + // rather than waiting 1–4s for the SSE echo to arrive. The echo 181 + // produces an equal entry and the keeper's dedup short-circuits. 182 + let optimistic = opake_core::workspace_keeper::WorkspaceEntry { 183 + uri: keyring_uri.clone(), 184 + owner_did: opake.did().to_string(), 185 + rotation: 1, 186 + member_count: 1, 187 + created_at: Some(opake.now()), 188 + name: Some(name.to_string()), 189 + description: description.clone(), 190 + icon: None, 191 + my_role: Some("manager".to_string()), 192 + }; 193 + drop(opake); 194 + 195 + { 196 + let mut keeper = self.workspace_keeper.lock().await; 197 + keeper.upsert(optimistic); 198 + } 199 + 171 200 #[derive(Serialize)] 172 201 struct R { 173 202 keyring_uri: String, ··· 183 212 } 184 213 185 214 /// List all keyrings the user is a member of, with decrypted metadata. 215 + /// 216 + /// Side effect: bootstraps the shared `WorkspaceKeeper` with the 217 + /// result. Any `watchWorkspaces` callers (current or future) receive 218 + /// a fresh snapshot with `loaded = true` as part of this call. Once 219 + /// bootstrapped, incremental SSE events keep the keeper in sync 220 + /// without further `listWorkspaces` round-trips. 186 221 #[wasm_bindgen(js_name = listWorkspaces)] 187 222 pub async fn list_workspaces( 188 223 &self, 189 224 default_appview_url: Option<String>, 190 225 ) -> Result<JsValue, JsError> { 191 - use opake_core::crypto::{self, KeyringMetadata}; 192 - use opake_core::records::{EncryptedMetadata, KeyringMember}; 193 - 194 226 let mut opake = self.opake().await?; 195 227 let identity = opake.require_identity().map_err(wasm_err)?; 196 228 let private_key = identity.private_key_bytes().map_err(wasm_err)?; ··· 200 232 .discover_member_keyrings(default_appview_url.as_deref()) 201 233 .await 202 234 .map_err(wasm_err)?; 203 - 204 - #[derive(Serialize)] 205 - struct WorkspaceEntry { 206 - uri: String, 207 - owner_did: String, 208 - rotation: u64, 209 - member_count: usize, 210 - created_at: Option<String>, 211 - name: Option<String>, 212 - description: Option<String>, 213 - icon: Option<String>, 214 - members: Vec<serde_json::Value>, 215 - } 216 - 217 - let mut entries = Vec::new(); 218 - for kr in keyrings { 219 - let members_parsed: Vec<KeyringMember> = kr 220 - .members 221 - .iter() 222 - .filter_map(|v| serde_json::from_value(v.clone()).ok()) 223 - .collect(); 235 + drop(opake); 224 236 225 - let (name, description, icon) = 226 - match WasmOpake::unwrap_workspace_key(&members_parsed, &did, &private_key) { 227 - Ok(group_key) => { 228 - let meta_result = kr.encrypted_metadata.as_ref().and_then(|em| { 229 - let em: EncryptedMetadata = serde_json::from_value(em.clone()).ok()?; 230 - let meta: KeyringMetadata = 231 - crypto::decrypt_metadata(&group_key, &em).ok()?; 232 - Some(meta) 233 - }); 234 - match meta_result { 235 - Some(meta) => (Some(meta.name), meta.description, meta.icon), 236 - None => (None, None, None), 237 - } 238 - } 239 - Err(_) => (None, None, None), 240 - }; 237 + // Build entries via the shared `workspace_keeper::try_build_entry` 238 + // helper so this path and the SSE event path produce identical 239 + // WorkspaceEntry values — any shape divergence between them 240 + // would cause spurious watcher re-fires after SSE echoes. 241 + let entries: Vec<opake_core::workspace_keeper::WorkspaceEntry> = keyrings 242 + .iter() 243 + .filter_map(|kr| { 244 + opake_core::workspace_keeper::try_build_entry_from_appview_keyring( 245 + kr, 246 + &did, 247 + &private_key, 248 + ) 249 + }) 250 + .collect(); 241 251 242 - entries.push(WorkspaceEntry { 243 - uri: kr.uri, 244 - owner_did: kr.owner_did, 245 - rotation: kr.rotation, 246 - member_count: kr.members.len(), 247 - created_at: kr.created_at, 248 - name, 249 - description, 250 - icon, 251 - members: kr.members, 252 - }); 252 + // Bootstrap the keeper before returning — subscribers see the 253 + // loaded state before any caller that awaits this method's 254 + // return value resumes. 255 + { 256 + let mut keeper = self.workspace_keeper.lock().await; 257 + keeper.bootstrap(entries.clone()); 253 258 } 254 259 260 + // Wire-format for backward compatibility with the existing Zod 261 + // schema — `{ keyrings: [...] }` with snake_case fields. 255 262 to_js(&serde_json::json!({ "keyrings": entries })) 256 263 } 257 264 265 + /// Add a member to a workspace. Resolves the keyring + group key 266 + /// internally so the key never crosses the WASM/JS boundary. 258 267 #[wasm_bindgen(js_name = addWorkspaceMember)] 259 268 pub async fn add_workspace_member( 260 269 &self, 261 270 keyring_uri: &str, 262 - key: &[u8], 263 271 member_did: &str, 264 272 member_public_key: &[u8], 265 273 role: &str, 266 274 ) -> Result<JsValue, JsError> { 267 275 let mut opake = self.opake().await?; 268 - let gk = crate::content_key_from_slice(key)?; 276 + let ws = opake 277 + .resolve_workspace_by_uri(keyring_uri) 278 + .await 279 + .map_err(wasm_err)?; 269 280 let pubkey = pub_key_from_slice(member_public_key)?; 270 281 let role = parse_role(role)?; 271 282 let outcome = opake 272 - .add_workspace_member(keyring_uri, &gk, member_did, &pubkey, role) 283 + .add_workspace_member(keyring_uri, &ws.key, member_did, &pubkey, role) 273 284 .await 274 285 .map_err(wasm_err)?; 275 286 to_js(&MutationResultDto { ··· 284 295 opake.leave_workspace(keyring_uri).await.map_err(wasm_err) 285 296 } 286 297 287 - /// Remove a member from a workspace. 288 - /// Owner: rotates key, returns `{ key, rotation, proposed: false }`. 289 - /// Non-owner: proposes, returns `{ proposed: true }`. 298 + /// Remove a member from a workspace. Resolves the keyring + group key 299 + /// internally so the key never crosses the WASM/JS boundary. 300 + /// 301 + /// Owner: rotates the group key in-place and re-wraps to remaining 302 + /// members — the new key stays inside WASM. Non-owner: writes a 303 + /// keyringUpdate proposal. In both cases the returned DTO carries 304 + /// only `{ proposed, rotation }`; the rotated key bytes are dropped 305 + /// (they'd be a boundary violation) and the next operation re-resolves 306 + /// via `resolve_workspace_by_uri`. 290 307 #[wasm_bindgen(js_name = removeWorkspaceMember)] 291 308 pub async fn remove_workspace_member( 292 309 &self, 293 310 keyring_uri: &str, 294 - key: &[u8], 295 311 member_did: &str, 296 312 ) -> Result<JsValue, JsError> { 297 313 let mut opake = self.opake().await?; 298 - let gk = crate::content_key_from_slice(key)?; 314 + let ws = opake 315 + .resolve_workspace_by_uri(keyring_uri) 316 + .await 317 + .map_err(wasm_err)?; 299 318 let (key_result, outcome) = opake 300 - .remove_workspace_member(keyring_uri, &gk, member_did) 319 + .remove_workspace_member(keyring_uri, &ws.key, member_did) 301 320 .await 302 321 .map_err(wasm_err)?; 303 322 304 323 #[derive(Serialize)] 305 324 struct R { 306 - #[serde( 307 - with = "crate::wasm_util::serde_bytes", 308 - skip_serializing_if = "Vec::is_empty" 309 - )] 310 - key: Vec<u8>, 311 325 #[serde(skip_serializing_if = "Option::is_none")] 312 326 rotation: Option<u64>, 313 327 proposed: bool, 314 328 } 315 - let (key_bytes, rotation) = match key_result { 316 - Some((k, r)) => (k.0.to_vec(), Some(r)), 317 - None => (Vec::new(), None), 318 - }; 319 329 serde_wasm_bindgen::to_value(&R { 320 - key: key_bytes, 321 - rotation, 330 + rotation: key_result.map(|(_, r)| r), 322 331 proposed: outcome.is_proposed(), 323 332 }) 324 333 .map_err(|e| JsError::new(&e.to_string())) 325 334 } 326 335 327 - /// Update workspace metadata (name, description). 336 + /// Update workspace metadata (name, description, icon). Resolves the 337 + /// keyring + group key internally so the key never crosses the WASM/JS 338 + /// boundary. 339 + /// 328 340 /// Owner: applies directly. Non-owner: creates a keyringUpdate proposal. 329 341 #[wasm_bindgen(js_name = updateWorkspaceMetadata)] 330 342 pub async fn update_workspace_metadata( 331 343 &self, 332 344 keyring_uri: &str, 333 - key: &[u8], 334 345 name: Option<String>, 335 346 description: Option<String>, 336 347 icon: Option<String>, 337 348 ) -> Result<JsValue, JsError> { 338 349 let mut opake = self.opake().await?; 339 - let gk = crate::content_key_from_slice(key)?; 350 + let ws = opake 351 + .resolve_workspace_by_uri(keyring_uri) 352 + .await 353 + .map_err(wasm_err)?; 340 354 let outcome = opake 341 355 .update_workspace_metadata( 342 356 keyring_uri, 343 - &gk, 357 + &ws.key, 344 358 name.as_deref(), 345 359 description.as_deref(), 346 360 icon.as_deref(), ··· 456 470 457 471 #[wasm_bindgen(js_name = downloadFromGrant)] 458 472 pub async fn download_from_grant(&self, grant_uri: &str) -> Result<JsValue, JsError> { 459 - let mut opake = self.opake().await?; 473 + let opake = self.opake().await?; 460 474 let (filename, plaintext) = opake 461 475 .download_from_grant(grant_uri) 462 476 .await ··· 561 575 })) 562 576 } 563 577 578 + /// Override the cached appview URL at runtime. 579 + /// 580 + /// Overrides the compile-time `DEFAULT_APPVIEW_URL` seeded during 581 + /// `for_account`. Callers use this at boot to inject a host-specific 582 + /// runtime default (e.g. web's `VITE_APPVIEW_URL`, which can't be 583 + /// baked in because one WASM binary serves multiple deployments). 584 + /// 585 + /// Subsequent writes to `accountConfig` on the PDS still override 586 + /// this value via `set_account_config` — so a user-configured 587 + /// appview (written via settings) wins over the host default. 588 + #[wasm_bindgen(js_name = setAppviewUrl)] 589 + pub async fn set_appview_url(&self, url: String) -> Result<(), JsError> { 590 + let mut guard = self.inner.lock().await; 591 + let opake = guard 592 + .as_mut() 593 + .ok_or_else(|| JsError::new("Opake context already consumed"))?; 594 + opake.set_appview_url(url); 595 + Ok(()) 596 + } 597 + 564 598 /// Verify the session is usable by touching the account config record. 565 599 /// 566 600 /// Reads the config, stamps `modifiedAt`, and writes it back. Throws on ··· 593 627 opake.set_account_config(&config).await.map_err(wasm_err) 594 628 } 595 629 630 + /// Read-merge-write the account config atomically. 631 + /// 632 + /// Accepts a partial updates object — fields omitted (`undefined`) 633 + /// are left untouched, fields set to `null` are cleared, and fields 634 + /// with a value replace the current one. Returns the freshly-written 635 + /// record. 636 + #[wasm_bindgen(js_name = updateAccountConfig)] 637 + pub async fn update_account_config(&self, updates_js: JsValue) -> Result<JsValue, JsError> { 638 + let updates: opake_core::records::AccountConfigUpdates = 639 + serde_wasm_bindgen::from_value(updates_js).map_err(|e| JsError::new(&e.to_string()))?; 640 + let mut opake = self.opake().await?; 641 + let record = opake 642 + .update_account_config(updates) 643 + .await 644 + .map_err(wasm_err)?; 645 + to_js(&record) 646 + } 647 + 596 648 /// Publish the caller's public key record on the PDS. 597 649 #[wasm_bindgen(js_name = publishPublicKey)] 598 650 pub async fn publish_public_key(&self) -> Result<String, JsError> { ··· 650 702 to_js(&grants) 651 703 } 652 704 653 - /// Resolve a workspace from a foreign PDS by keyring URI. 654 - #[wasm_bindgen(js_name = resolveForeignWorkspace)] 655 - pub async fn resolve_foreign_workspace(&self, keyring_uri: &str) -> Result<JsValue, JsError> { 656 - let mut opake = self.opake().await?; 657 - let workspace = opake 658 - .resolve_foreign_workspace(keyring_uri) 659 - .await 660 - .map_err(wasm_err)?; 661 - 662 - #[derive(Serialize)] 663 - struct R { 664 - uri: String, 665 - name: String, 666 - owner_did: String, 667 - rotation: u64, 668 - #[serde(with = "crate::wasm_util::serde_bytes")] 669 - group_key: Vec<u8>, 670 - } 671 - 672 - let result = R { 673 - uri: workspace.uri.clone(), 674 - name: workspace.name.clone(), 675 - owner_did: workspace.owner_did.clone(), 676 - rotation: workspace.rotation, 677 - group_key: workspace.key.0.to_vec(), 678 - }; 679 - to_js(&result) 680 - } 681 - 682 705 /// List pending pair requests on this account. 683 706 #[wasm_bindgen(js_name = listPairRequests)] 684 707 pub async fn list_pair_requests(&self) -> Result<JsValue, JsError> { ··· 732 755 /// Resolve another user's identity (DID, handle, public key). 733 756 #[wasm_bindgen(js_name = resolveIdentity)] 734 757 pub async fn resolve_identity(&self, handle_or_did: &str) -> Result<JsValue, JsError> { 735 - let mut opake = self.opake().await?; 758 + let opake = self.opake().await?; 736 759 let resolved = opake 737 760 .resolve_identity(handle_or_did) 738 761 .await ··· 760 783 /// Resolve grant metadata without downloading the blob. 761 784 #[wasm_bindgen(js_name = resolveGrantMetadata)] 762 785 pub async fn resolve_grant_metadata(&self, grant_uri: &str) -> Result<JsValue, JsError> { 763 - let mut opake = self.opake().await?; 786 + let opake = self.opake().await?; 764 787 let (name, metadata) = opake 765 788 .resolve_grant_metadata(grant_uri) 766 789 .await ··· 773 796 } 774 797 775 798 to_js(&R { name, metadata }) 776 - } 777 - 778 - /// Unwrap a workspace group key using the caller's identity (no raw key params). 779 - /// 780 - /// Reads the private key from the Opake's identity — the key never 781 - /// crosses the WASM/JS boundary. 782 - #[wasm_bindgen(js_name = unwrapGroupKey)] 783 - pub async fn unwrap_group_key(&self, members_js: JsValue) -> Result<Vec<u8>, JsError> { 784 - let guard = self.inner.lock().await; 785 - let opake = guard 786 - .as_ref() 787 - .ok_or_else(|| JsError::new("Opake context already consumed"))?; 788 - let identity = opake.require_identity().map_err(wasm_err)?; 789 - let private_key = identity.private_key_bytes().map_err(wasm_err)?; 790 - let members: Vec<opake_core::records::KeyringMember> = 791 - serde_wasm_bindgen::from_value(members_js).map_err(|e| JsError::new(&e.to_string()))?; 792 - let key = WasmOpake::unwrap_workspace_key(&members, opake.did(), &private_key) 793 - .map_err(wasm_err)?; 794 - Ok(key.0.to_vec()) 795 799 } 796 800 797 801 /// Proactively refresh the OAuth token if it's close to expiry.
+167 -53
crates/opake-wasm/src/sse_wasm.rs
··· 1 - // WASM bindings for the SSE consumer and tree watchers. 1 + // WASM bindings for the SSE consumer, tree watchers, and workspace-list 2 + // watchers. 2 3 // 3 4 // Exposes: 4 5 // - WasmOpakeHandle::startSseConsumer(appviewUrl) 5 6 // - WasmOpakeHandle::stopSseConsumer() 7 + // - WasmOpakeHandle::watchWorkspaces(callback) 6 8 // - WasmFileManagerHandle::watchDirectory(uri, callback) 7 9 // - WasmDirectoryWatcher::close() 10 + // - WasmWorkspaceWatcher::close() 8 11 // 9 12 // The consumer loop runs via wasm_bindgen_futures::spawn_local and pulls 10 - // events from the browser's EventSource (WasmSseTransport). Each event is 11 - // dispatched to the shared TreeKeeper, which patches the affected tree in 12 - // place and fires watcher callbacks with a fresh snapshot. 13 + // events from the browser's EventSource (WasmSseTransport). Record 14 + // events are dispatched to both the TreeKeeper (directory tree state) 15 + // and the WorkspaceKeeper (workspace-list state). Proposals flow through 16 + // the debounced sync scheduler as before. 13 17 14 18 use std::cell::RefCell; 15 19 use std::collections::HashMap; ··· 20 24 use opake_core::client::request_sse_token; 21 25 use opake_core::directories::DirectoryTree; 22 26 use opake_core::sse::consumer::{JitterRng, SleepFn, SseConsumer, TokenFetcher}; 27 + use opake_core::sse::events::SseEvent; 23 28 use opake_core::sse::wasm_connection::WasmSseTransport; 24 29 use opake_core::tree_keeper::{TreeKeeper, WatcherCallback, WatcherHandle}; 30 + use opake_core::workspace_keeper::{ 31 + self as wk, WorkspaceKeeper, WorkspaceSnapshot, WorkspaceWatcherCallback, 32 + WorkspaceWatcherHandle, 33 + }; 25 34 use serde::Serialize; 26 35 use wasm_bindgen::prelude::*; 27 36 use wasm_bindgen::JsCast; ··· 55 64 } 56 65 57 66 // --------------------------------------------------------------------------- 67 + // WasmWorkspaceWatcher — returned by watchWorkspaces, exposes close() 68 + // --------------------------------------------------------------------------- 69 + 70 + #[wasm_bindgen(js_name = WorkspaceWatcher)] 71 + pub struct WasmWorkspaceWatcher { 72 + workspace_keeper: Rc<Mutex<WorkspaceKeeper>>, 73 + handle: WorkspaceWatcherHandle, 74 + closed: Rc<std::cell::Cell<bool>>, 75 + } 76 + 77 + #[wasm_bindgen(js_class = WorkspaceWatcher)] 78 + impl WasmWorkspaceWatcher { 79 + /// Stop receiving notifications. Idempotent. 80 + pub async fn close(&self) { 81 + if self.closed.get() { 82 + return; 83 + } 84 + self.closed.set(true); 85 + let mut keeper = self.workspace_keeper.lock().await; 86 + keeper.unwatch(self.handle); 87 + } 88 + } 89 + 90 + // --------------------------------------------------------------------------- 91 + // WasmOpakeHandle::watchWorkspaces 92 + // --------------------------------------------------------------------------- 93 + 94 + #[wasm_bindgen(js_class = OpakeContext)] 95 + impl WasmOpakeHandle { 96 + /// Subscribe to live changes in the workspace list. 97 + /// 98 + /// Fires the callback once immediately with the current snapshot 99 + /// (which has `loaded = false` and an empty entry list if the 100 + /// keeper hasn't been bootstrapped yet), and again on every change. 101 + /// 102 + /// The callback receives a `WorkspaceSnapshot` with shape 103 + /// `{ entries: WorkspaceEntry[], loaded: bool }`. 104 + /// 105 + /// Returns a `WorkspaceWatcher` handle. Call `.close()` to 106 + /// unsubscribe (typically from a React useEffect cleanup). 107 + /// 108 + /// The keeper is populated by: 109 + /// - `listWorkspaces()` (bootstrap — replaces the entry set) 110 + /// - SSE `keyring:upsert` / `keyring:delete` events (incremental) 111 + /// 112 + /// If the keeper hasn't been bootstrapped, the initial snapshot has 113 + /// `loaded == false` — subscribers should treat this as "loading" 114 + /// state and show a placeholder until the next fire brings `loaded == true`. 115 + #[wasm_bindgen(js_name = watchWorkspaces)] 116 + pub async fn watch_workspaces( 117 + &self, 118 + callback: js_sys::Function, 119 + ) -> Result<WasmWorkspaceWatcher, JsError> { 120 + let cb = js_workspace_watcher_callback(callback); 121 + let mut keeper = self.workspace_keeper.lock().await; 122 + let handle = keeper.install_watcher(cb); 123 + Ok(WasmWorkspaceWatcher { 124 + workspace_keeper: Rc::clone(&self.workspace_keeper), 125 + handle, 126 + closed: Rc::new(std::cell::Cell::new(false)), 127 + }) 128 + } 129 + } 130 + 131 + // --------------------------------------------------------------------------- 58 132 // WasmFileManagerHandle::watchDirectory 59 133 // --------------------------------------------------------------------------- 60 134 ··· 160 234 let identity = opake 161 235 .identity() 162 236 .ok_or_else(|| JsError::new("no identity"))?; 163 - let private_key = identity.private_key_bytes().map_err(wasm_err)?; 237 + let private_key = *identity.private_key_bytes().map_err(wasm_err)?; 164 238 drop(guard); 165 239 166 240 let mut keeper = self.tree_keeper.lock().await; ··· 222 296 223 297 let opake_rc = Rc::clone(&self.inner); 224 298 let tree_keeper_rc = Rc::clone(&self.tree_keeper); 299 + let workspace_keeper_rc = Rc::clone(&self.workspace_keeper); 225 300 let started_flag = Rc::clone(&self.sse_started); 226 301 227 302 let token_fetcher = make_token_fetcher(Rc::clone(&opake_rc), resolved_url.clone()); ··· 278 353 } 279 354 } 280 355 281 - // Notify JS of keyring-record-level changes so stores 282 - // tracking workspace metadata (name, icon, members) 283 - // can re-fetch against the now-indexed appview. Fires 284 - // only for direct record events — proposals flow 285 - // through the owner's apply step, which emits a 286 - // subsequent KeyringUpsert. 287 - if is_keyring_record_event(&event) { 288 - if let Some(uri) = event.keyring_uri() { 289 - dispatch_workspace_updated(uri); 290 - } 291 - } 356 + // Workspace list updates: apply directly to the keeper 357 + // so subscribers see changes without an appview round- 358 + // trip. Idempotent upserts (same rotation + same data) 359 + // don't re-fire watchers — see `WorkspaceKeeper::upsert`. 360 + apply_keyring_to_workspace_keeper(&opake_rc, &workspace_keeper_rc, &event).await; 292 361 } 293 362 // Task exited — clear the flag in case we broke on a 294 363 // transport error rather than an explicit stop, so a ··· 300 369 Ok(()) 301 370 } 302 371 303 - /// Stop the SSE consumer and wipe decrypted tree state from memory. 372 + /// Stop the SSE consumer and wipe decrypted tree + workspace state. 304 373 /// 305 374 /// Synchronous from JS: React `useEffect` cleanup is sync, so the 306 - /// TreeKeeper drain (which needs an async lock) is fire-and-forget 307 - /// on the event loop. Setting `sse_started = false` is enough on 308 - /// its own to terminate the consumer loop on its next event — 309 - /// `uninstall_all` is what zeroes any cached `ContentKey`s. 375 + /// keeper drains (which need async locks) are fire-and-forget on 376 + /// the event loop. Setting `sse_started = false` is enough on its 377 + /// own to terminate the consumer loop on its next event — 378 + /// `uninstall_all` is what zeroes any cached `ContentKey`s and the 379 + /// workspace list. 310 380 #[wasm_bindgen(js_name = stopSseConsumer)] 311 381 pub fn stop_sse_consumer(&self) { 312 382 self.sse_started.set(false); 313 383 PROPOSAL_DEBOUNCE_GENERATIONS.with(|state| state.borrow_mut().clear()); 314 384 315 385 let tree_keeper = Rc::clone(&self.tree_keeper); 386 + let workspace_keeper = Rc::clone(&self.workspace_keeper); 316 387 wasm_bindgen_futures::spawn_local(async move { 317 - let mut keeper = tree_keeper.lock().await; 318 - keeper.uninstall_all(); 319 - log::debug!("[sse] tree_keeper drained on stopSseConsumer"); 388 + let mut tk = tree_keeper.lock().await; 389 + tk.uninstall_all(); 390 + drop(tk); 391 + let mut wk = workspace_keeper.lock().await; 392 + wk.uninstall_all(); 393 + log::debug!("[sse] tree_keeper + workspace_keeper drained on stopSseConsumer"); 320 394 }); 321 395 } 322 396 } ··· 449 523 let _ = wasm_bindgen_futures::JsFuture::from(promise).await; 450 524 } 451 525 452 - /// True for keyring-record-level events — i.e. direct writes/deletes of 453 - /// the keyring record, not proposal variants. These are the signals the 454 - /// workspace list wants (name/icon/member changes are visible on the 455 - /// keyring record itself). 456 - fn is_keyring_record_event(event: &opake_core::sse::events::SseEvent) -> bool { 457 - use opake_core::sse::events::SseEvent; 458 - matches!( 459 - event, 460 - SseEvent::KeyringUpsert(_) | SseEvent::KeyringDelete(_) 461 - ) 526 + /// Apply a keyring record event to the `WorkspaceKeeper`. 527 + /// 528 + /// `KeyringUpsert`: build an entry from the record using the caller's 529 + /// identity. `Some` → upsert; `None` (DID absent from member list, 530 + /// i.e. we were rotated out) → delete. `KeyringDelete`: delete by URI. 531 + /// Other events are no-ops. 532 + /// 533 + /// Acquires the opake lock **first** (for identity), then drops it 534 + /// before acquiring the workspace_keeper lock. Both callers — this 535 + /// function and `listWorkspaces` — release opake before taking keeper, 536 + /// so there is no concurrent double-holding and no deadlock risk. 537 + /// The real reason for the release order: the identity private key 538 + /// used to build the entry doesn't need to be held across the keeper 539 + /// apply, and holding both mutexes longer than necessary reduces SSE 540 + /// throughput. A narrow race window exists where a concurrent 541 + /// `listWorkspaces` bootstrap can land between our opake release and 542 + /// keeper acquire; this is benign — the next SSE event or the keeper's 543 + /// idempotent upsert self-corrects. 544 + async fn apply_keyring_to_workspace_keeper( 545 + opake_rc: &Rc<Mutex<Option<WasmOpake>>>, 546 + workspace_keeper_rc: &Rc<Mutex<WorkspaceKeeper>>, 547 + event: &SseEvent, 548 + ) { 549 + match event { 550 + SseEvent::KeyringUpsert(record) => { 551 + // Build the entry under the opake lock only. 552 + let maybe_entry = { 553 + let guard = opake_rc.lock().await; 554 + let Some(opake) = guard.as_ref() else { 555 + log::warn!("[sse] workspace upsert: opake unavailable"); 556 + return; 557 + }; 558 + let did = opake.did().to_string(); 559 + let Some(identity) = opake.identity() else { 560 + log::warn!("[sse] workspace upsert: no identity"); 561 + return; 562 + }; 563 + let private_key = match identity.private_key_bytes() { 564 + Ok(pk) => pk, 565 + Err(e) => { 566 + log::warn!("[sse] workspace upsert: private_key_bytes failed: {e}"); 567 + return; 568 + } 569 + }; 570 + wk::try_build_entry_from_sse_record(record, &did, &private_key) 571 + }; 572 + let mut keeper = workspace_keeper_rc.lock().await; 573 + keeper.apply_keyring_record(&record.uri, maybe_entry); 574 + } 575 + SseEvent::KeyringDelete(payload) => { 576 + if let Some(uri) = payload.best_uri() { 577 + let mut keeper = workspace_keeper_rc.lock().await; 578 + keeper.delete(uri); 579 + } 580 + } 581 + _ => {} 582 + } 462 583 } 463 584 464 - /// Dispatch `opake:workspace-updated` as a window CustomEvent so JS 465 - /// stores can reload the affected workspace. The detail payload is the 466 - /// keyring URI as a string. Soft-fails on any web-sys error — this 467 - /// notification is best-effort and losing one means users wait until 468 - /// the next full reload, which is acceptable. 469 - fn dispatch_workspace_updated(keyring_uri: &str) { 470 - let Some(window) = web_sys::window() else { 471 - return; 472 - }; 473 - let detail = JsValue::from_str(keyring_uri); 474 - let init = web_sys::CustomEventInit::new(); 475 - init.set_detail(&detail); 476 - let event = 477 - match web_sys::CustomEvent::new_with_event_init_dict("opake:workspace-updated", &init) { 478 - Ok(e) => e, 585 + /// Wrap a JS function as a [`WorkspaceWatcherCallback`] that serializes 586 + /// the snapshot to a JS object on each call. 587 + fn js_workspace_watcher_callback(callback: js_sys::Function) -> WorkspaceWatcherCallback { 588 + Box::new(move |snapshot: &WorkspaceSnapshot| { 589 + let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); 590 + let snapshot_js = match snapshot.serialize(&serializer) { 591 + Ok(v) => v, 479 592 Err(e) => { 480 - log::warn!("[sse] failed to construct CustomEvent: {e:?}"); 593 + log::warn!("[sse] workspace snapshot serialize failed: {e}"); 481 594 return; 482 595 } 483 596 }; 484 - if let Err(e) = window.dispatch_event(&event) { 485 - log::warn!("[sse] failed to dispatch workspace-updated: {e:?}"); 486 - } 597 + if let Err(e) = callback.call1(&JsValue::NULL, &snapshot_js) { 598 + log::warn!("[sse] workspace watcher callback threw: {e:?}"); 599 + } 600 + }) 487 601 } 488 602 489 603 /// Wrap a JS function as a `WatcherCallback` that builds a snapshot on
+6
docs/CRATE_STRUCTURE.md
··· 17 17 daemon.rs Background task registry (shared definitions for CLI + web). Daemon builds Opake per account per task iteration, auto-persists via signoff 18 18 error.rs Typed error hierarchy (thiserror) 19 19 test_utils.rs MockTransport + response queue (behind test-utils feature) 20 + tree_keeper/ 21 + mod.rs TreeKeeper — per-DID in-memory directory tree state. Bootstrapped from SSE initial snapshot, patched incrementally by document/directory events. `watchDirectory` installs typed snapshot callbacks. Separate Mutex from WorkspaceKeeper. 22 + tests.rs Unit tests 23 + workspace_keeper/ 24 + mod.rs WorkspaceKeeper — in-memory workspace list state. Bootstrapped by `listWorkspaces`, patched by `keyring:upsert` / `keyring:delete` SSE events. `watchWorkspaces` installs snapshot callbacks. Parallel design to TreeKeeper. 25 + tests.rs Unit tests 20 26 manager/ 21 27 mod.rs FileManager<'a, T, R, S> struct (borrows &mut Opake + &FileContext), create_record passthrough 22 28 types.rs UploadRequest, UploadResult, DownloadResult, MutationOutcome, FileContext
+32
docs/FLOWS.md
··· 20 20 | [flows/revisions.md](flows/revisions.md) | Collaborative editing via revision records (planned) | 21 21 | [flows/pairing.md](flows/pairing.md) | Device-to-device identity transfer via PDS relay | 22 22 | [flows/seed-phrase-recovery.md](flows/seed-phrase-recovery.md) | Seed phrase derivation, identity recovery | 23 + 24 + ## Workspace live updates 25 + 26 + The workspace list is kept current without polling via the SSE consumer and `WorkspaceKeeper`. 27 + 28 + **Cold start (bootstrap)** 29 + 30 + 1. `listWorkspaces` calls `discover_member_keyrings` → fetches all keyrings from the appview. 31 + 2. Each keyring is run through `try_build_entry` (identity key material from `Identity::private_key_bytes`) to produce a `WorkspaceEntry` with decrypted name/description. 32 + 3. `WorkspaceKeeper::bootstrap` replaces the entry set and flips `loaded = true`. All registered `watchWorkspaces` callbacks receive an updated snapshot immediately. 33 + 34 + **Incremental updates (SSE)** 35 + 36 + SSE `keyring:upsert` events route to `apply_keyring_to_workspace_keeper`: 37 + 38 + 1. Acquire the `Opake` mutex to read the caller's DID and identity private key. 39 + 2. Release `Opake` mutex. 40 + 3. Call `try_build_entry_from_sse_record` — returns `Some(entry)` if the caller is a member, `None` if the DID is absent (rotated out), or `Some(entry with name=None)` if the key-unwrap transiently fails. 41 + 4. Acquire the `WorkspaceKeeper` mutex and call `apply_keyring_record` (upsert or delete). 42 + 5. `WorkspaceKeeper` deduplicates: if the new entry equals the existing one (SSE echo after a local write), no callbacks fire. 43 + 44 + SSE `keyring:delete` events skip step 1–3 and call `keeper.delete(uri)` directly. 45 + 46 + **Optimistic insert** 47 + 48 + After `createWorkspace` succeeds, `opake_wasm.rs` synthesizes a `WorkspaceEntry` from the known-fresh data and calls `keeper.upsert` immediately. The sidebar reflects the new workspace within the current render cycle rather than waiting 1–4 s for the appview cursor lag. The later SSE echo is a no-op (dedup short-circuits). 49 + 50 + **Watcher teardown** 51 + 52 + `stopSseConsumer` → `WorkspaceKeeper::uninstall_all` (clears entries, watchers, resets `loaded`). `detachWatcher` (store-level) closes the JS watcher handle and clears the bootstrap promise without touching store state, so a remount reinstalls a fresh watcher without a flash-to-empty. 53 + 54 + See `WorkspaceKeeper` in `crates/opake-core/src/workspace_keeper/` and `apply_keyring_to_workspace_keeper` in `crates/opake-wasm/src/sse_wasm.rs`.
+8 -1
package.json
··· 1 1 { 2 2 "private": true, 3 - "workspaces": ["apps/web", "packages/*", "tests"], 3 + "workspaces": [ 4 + "apps/web", 5 + "packages/*", 6 + "tests" 7 + ], 4 8 "scripts": { 5 9 "lint": "bun run --filter '@opake/sdk' --filter '@opake/daemon' --filter '@opake/react' --filter opake-web lint", 6 10 "format": "bun run --filter '@opake/sdk' --filter '@opake/daemon' --filter '@opake/react' --filter opake-web format", ··· 18 22 "prettier": "^3.8.1", 19 23 "typescript": "^5.7.0", 20 24 "typescript-eslint": "^8.56.1" 25 + }, 26 + "volta": { 27 + "node": "24.15.0" 21 28 } 22 29 }
+3 -8
packages/opake-react/src/hooks/use-create-workspace.ts
··· 1 - import { useMutation, useQueryClient } from "@tanstack/react-query"; 1 + import { useMutation } from "@tanstack/react-query"; 2 2 import { useOpake } from "../provider"; 3 - import { opakeKeys } from "../keys"; 4 3 5 4 interface CreateWorkspaceInput { 6 5 readonly name: string; ··· 10 9 /** 11 10 * Create a new workspace. 12 11 * 13 - * Invalidates the workspace list query on success. 12 + * The new entry appears in `useWorkspaces()` automatically via the SSE 13 + * `keyring:upsert` echo — no cache invalidation needed. 14 14 * 15 15 * @example 16 16 * ```tsx ··· 20 20 */ 21 21 export function useCreateWorkspace() { 22 22 const opake = useOpake(); 23 - const queryClient = useQueryClient(); 24 23 25 24 return useMutation({ 26 25 mutationFn: (input: CreateWorkspaceInput) => 27 26 opake.createWorkspace(input.name, input.description), 28 - 29 - onSettled: () => { 30 - void queryClient.invalidateQueries({ queryKey: opakeKeys.workspaces() }); 31 - }, 32 27 }); 33 28 }
+89 -14
packages/opake-react/src/hooks/use-workspaces.ts
··· 1 - import { useQuery } from "@tanstack/react-query"; 2 - import type { WorkspaceEntry } from "@opake/sdk"; 1 + "use client"; 2 + 3 + // useWorkspaces — subscription-based workspace-list hook backed by 4 + // `opake.watchWorkspaces`. Replaces the old React Query variant. 5 + // 6 + // Lifecycle on mount: 7 + // 1. Install a watcher via `opake.watchWorkspaces(handler)` 8 + // 2. Handler fires once immediately with the current snapshot 9 + // (which may be `loaded: false` + empty entries if the keeper 10 + // hasn't been bootstrapped yet — `listWorkspaces` triggers the 11 + // bootstrap as a side effect) 12 + // 3. Handler fires again on every SSE `keyring:upsert` / 13 + // `keyring:delete` event that mutates the list 14 + // 15 + // Lifecycle on unmount: watcher.close() 16 + // 17 + // Under the hood: the WASM WorkspaceKeeper is the source of truth; 18 + // this hook just mirrors whatever snapshots it emits. No manual 19 + // invalidation, no refetch — remote changes appear automatically 20 + // within a firehose round-trip (typically <1s). 21 + 22 + import { useEffect, useState } from "react"; 23 + import type { WorkspaceEntry, WorkspaceSnapshot } from "@opake/sdk"; 3 24 import { useOpake } from "../provider"; 4 - import { opakeKeys } from "../keys"; 25 + 26 + // Module-level dedup guard. Once one mount kicks off `listWorkspaces`, 27 + // all other concurrent mounts (e.g. StrictMode double-mount, multiple 28 + // components using this hook) share the same in-flight promise instead 29 + // of each issuing a separate round-trip. 30 + let bootstrapPromise: Promise<unknown> | null = null; 31 + 32 + interface UseWorkspacesResult { 33 + /** The current workspace list, or an empty array before bootstrap. */ 34 + readonly data: readonly WorkspaceEntry[]; 35 + /** 36 + * `true` until the keeper has been bootstrapped at least once. After 37 + * that, `data` is authoritative even if it happens to be empty. 38 + */ 39 + readonly isLoading: boolean; 40 + /** The raw snapshot, if consumers need the `loaded` flag directly. */ 41 + readonly snapshot: WorkspaceSnapshot | null; 42 + } 5 43 6 44 /** 7 - * List all workspaces the current user is a member of. 45 + * Subscribe to live updates of the current user's workspace list. 8 46 * 9 - * Also populates the Opake instance's group key cache for subsequent 10 - * `workspaceFromKey()` calls. 47 + * Requires an `OpakeProvider` ancestor. Also requires an active SSE 48 + * consumer for real-time updates — `OpakeProvider` starts one by 49 + * default; pass `disableSseAutoStart` to opt out. 11 50 * 12 51 * @example 13 52 * ```tsx 14 - * const { data: workspaces } = useWorkspaces(); 15 - * for (const ws of workspaces ?? []) { 16 - * console.log(ws.name, ws.role); 53 + * function Sidebar() { 54 + * const { data: workspaces, isLoading } = useWorkspaces(); 55 + * if (isLoading) return <Spinner />; 56 + * return ( 57 + * <ul> 58 + * {workspaces.map((ws) => ( 59 + * <li key={ws.uri}>{ws.name}</li> 60 + * ))} 61 + * </ul> 62 + * ); 17 63 * } 18 64 * ``` 19 65 */ 20 - export function useWorkspaces() { 66 + export function useWorkspaces(): UseWorkspacesResult { 21 67 const opake = useOpake(); 68 + const [snapshot, setSnapshot] = useState<WorkspaceSnapshot | null>(null); 22 69 23 - return useQuery<readonly WorkspaceEntry[]>({ 24 - queryKey: opakeKeys.workspaces(), 25 - queryFn: () => opake.listWorkspaces(), 26 - }); 70 + useEffect(() => { 71 + let handledFirstFire = false; 72 + 73 + const watcher = opake.watchWorkspaces((snap) => { 74 + setSnapshot(snap); 75 + 76 + // Only bootstrap once per mount, and only when the keeper isn't 77 + // already loaded. The module-level guard ensures N concurrent 78 + // hook consumers share one in-flight fetch rather than N. 79 + if (!handledFirstFire) { 80 + handledFirstFire = true; 81 + if (!snap.loaded && !bootstrapPromise) { 82 + bootstrapPromise = opake 83 + .listWorkspaces() 84 + .catch((err: unknown) => { 85 + console.warn("[opake-react] listWorkspaces bootstrap failed:", err); 86 + }) 87 + .finally(() => { 88 + bootstrapPromise = null; 89 + }); 90 + } 91 + } 92 + }); 93 + 94 + return () => watcher.close(); 95 + }, [opake]); 96 + 97 + return { 98 + data: snapshot?.entries ?? [], 99 + isLoading: !snapshot?.loaded, 100 + snapshot, 101 + }; 27 102 }
+3 -2
packages/opake-react/src/keys.ts
··· 7 7 /** All opake queries — invalidate everything. */ 8 8 all: () => ["opake"] as const, 9 9 10 - /** Workspace list (all workspaces the user is a member of). */ 11 - workspaces: () => ["opake", "workspaces"] as const, 10 + // `workspaces` key removed — the workspace list is now driven by the 11 + // WASM WorkspaceKeeper + `opake.watchWorkspaces` subscription, not 12 + // by a React Query cache. See `useWorkspaces`. 12 13 13 14 /** Cabinet directory tree. */ 14 15 cabinetTree: () => ["opake", "cabinet", "tree"] as const,
+31 -5
packages/opake-sdk/docs/workspaces.md
··· 26 26 } 27 27 ``` 28 28 29 + ## Live updates 30 + 31 + `listWorkspaces` bootstraps the in-memory `WorkspaceKeeper`. Once bootstrapped, 32 + subscribe to live changes with `watchWorkspaces`: 33 + 34 + ```typescript 35 + // Returns a handle — call .close() to unsubscribe. 36 + const watcher = opake.watchWorkspaces((snapshot) => { 37 + // snapshot.entries — current workspace list (decrypted names + roles) 38 + // snapshot.loaded — false on the first fire before bootstrap completes 39 + console.log("workspaces:", snapshot.entries); 40 + }); 41 + 42 + // Later, on cleanup: 43 + await watcher.close(); 44 + ``` 45 + 46 + The callback fires once immediately with the current snapshot, then again 47 + on every `keyring:upsert` / `keyring:delete` SSE event. This requires an 48 + active SSE consumer — call `opake.startSseConsumer()` once after login. 49 + New workspaces created via `createWorkspace` appear optimistically in the 50 + snapshot before the SSE echo arrives. 51 + 29 52 ## File Operations 30 53 31 54 Get a FileManager from a workspace URI, then use it exactly like a cabinet: ··· 98 121 Member operations are on the `Opake` instance directly, not on the 99 122 FileManager: 100 123 124 + The workspace group key never leaves WASM — every mutation resolves the 125 + keyring from its URI and unwraps internally. 126 + 101 127 ```typescript 102 128 // Add a member 103 129 const recipient = await opake.resolveIdentity("bob.bsky.social"); 104 - await opake.addWorkspaceMember(keyringUri, key, recipient.did, recipient.publicKey, "editor"); 130 + await opake.addWorkspaceMember(keyringUri, recipient.did, recipient.publicKey, "editor"); 105 131 106 132 // Remove a member (triggers key rotation for owners) 107 - const result = await opake.removeWorkspaceMember(keyringUri, key, memberDid); 108 - if (result.key) { 109 - // Owner: key was rotated — store the new key and rotation 133 + const result = await opake.removeWorkspaceMember(keyringUri, memberDid); 134 + if (result.rotation !== undefined) { 135 + // Owner: key was rotated inside WASM; `rotation` is the new counter 110 136 console.log("New rotation:", result.rotation); 111 137 } 112 138 ··· 114 140 await opake.updateMemberRole(keyringUri, memberDid, "viewer"); 115 141 116 142 // Update workspace name/description 117 - await opake.updateWorkspaceMetadata(keyringUri, key, { 143 + await opake.updateWorkspaceMetadata(keyringUri, { 118 144 name: "Renamed Project", 119 145 description: "Updated description", 120 146 });
+10 -4
packages/opake-sdk/src/index.ts
··· 1 1 // @opake/sdk — public API 2 2 3 3 // Main entry point 4 - export { Opake } from "./opake"; 4 + export { Opake, type WorkspaceWatcher } from "./opake"; 5 5 export { FileManager, type DirectoryWatcher } from "./file-manager"; 6 + 7 + // Schema-driven derived types 8 + export { type WorkspaceSnapshot } from "./schemas"; 6 9 7 10 // Errors 8 11 export { OpakeError, type OpakeErrorKind } from "./errors"; ··· 35 38 export { 36 39 type OpakeInitOptions, 37 40 type AccountConfig, 41 + type AccountConfigPatch, 38 42 type MutationResult, 39 43 type UploadResult, 40 44 type DownloadResult, ··· 56 60 type TaskDef, 57 61 } from "./types"; 58 62 59 - // Real-time event streaming is now WASM-owned. Subscribe via 60 - // `opake.startSseConsumer(appviewUrl)` + `fileManager.watchDirectory(uri, handler)` 61 - // which returns a `DirectoryWatcher` handle (exported above). 63 + // Real-time event streaming is WASM-owned: 64 + // - Start the consumer: `opake.startSseConsumer(appviewUrl?)` 65 + // - Directory tree updates: `fileManager.watchDirectory(uri, handler)` → `DirectoryWatcher` 66 + // - Workspace list updates: `opake.watchWorkspaces(handler)` → `WorkspaceWatcher` 67 + // Both watcher handles are exported above.
+159 -51
packages/opake-sdk/src/opake.ts
··· 12 12 import type { Storage } from "./storage"; 13 13 import type { 14 14 AccountConfig, 15 + AccountConfigPatch, 15 16 MutationResult, 16 17 OpakeInitOptions, 17 18 ResolvedIdentity, ··· 27 28 createWorkspaceResultSchema, 28 29 listWorkspacesResultSchema, 29 30 syncSingleResultSchema, 31 + workspaceSnapshotSchema, 32 + type WorkspaceSnapshot, 30 33 } from "./schemas"; 31 34 import { initWasm } from "./wasm"; 32 35 import { FileManager } from "./file-manager"; ··· 46 49 // The WASM module types. We import dynamically after init. 47 50 type WasmModule = typeof import("../wasm/opake.js"); 48 51 type WasmOpakeContext = import("../wasm/opake.js").OpakeContext; 52 + 53 + /** Internal shape of the WASM `WorkspaceWatcher` object. */ 54 + type WasmWorkspaceWatcherHandle = { 55 + close(): Promise<void>; 56 + free(): void; 57 + }; 58 + 59 + /** 60 + * Handle returned by `Opake.watchWorkspaces`. Call `.close()` to 61 + * unsubscribe — typically from a React useEffect cleanup. 62 + */ 63 + export interface WorkspaceWatcher { 64 + /** Stop receiving notifications. Idempotent. */ 65 + close(): void; 66 + } 49 67 50 68 // --------------------------------------------------------------------------- 51 69 // Token guard decorator ··· 373 391 // --------------------------------------------------------------------------- 374 392 375 393 /** 394 + * Override the cached appview URL at runtime. 395 + * 396 + * `Opake.init` seeds the instance with the compile-time 397 + * `DEFAULT_APPVIEW_URL` baked into the WASM binary. Call this at boot 398 + * to inject a host-specific runtime default (e.g. the web app's 399 + * `VITE_APPVIEW_URL`, which can't be baked in because one WASM binary 400 + * is shipped to multiple deployments). 401 + * 402 + * After this call, methods that resolve the appview URL internally 403 + * (`listWorkspaces`, `startSseConsumer`, etc.) pick up the new value 404 + * automatically — JS callers don't pass the URL at the call site. 405 + * 406 + * Writes to `accountConfig` on the PDS still override this value, so 407 + * a user-configured appview (from settings) wins over the host default. 408 + */ 409 + @wrapWasmErrors 410 + async setAppviewUrl(url: string): Promise<void> { 411 + await this.requireContext().setAppviewUrl(url); 412 + } 413 + 414 + /** 376 415 * Verify the session is usable by touching the account config record. 377 416 * 378 417 * Reads the config, stamps `modifiedAt` with the current time, and ··· 526 565 /** 527 566 * List all workspaces the current user is a member of. 528 567 * 529 - * Also populates the in-memory group key cache for subsequent 530 - * `workspaceFromKey()` calls. 568 + * Also bootstraps the in-memory `WorkspaceKeeper` — `watchWorkspaces` 569 + * callers see a fresh snapshot with `loaded = true` as a side effect. 570 + * 571 + * The appview URL is resolved internally from the stored config 572 + * (set during `init` and overridable via `setAppviewUrl` or 573 + * by writing an `accountConfig` record). Callers do not pass it. 531 574 * 532 575 * @returns Array of workspace entries with decrypted names and roles. 533 576 */ 534 577 @wrapWasmErrors 535 578 @withTokenGuard 536 - listWorkspaces(appviewUrl?: string): Promise<readonly WorkspaceEntry[]> { 579 + listWorkspaces(): Promise<readonly WorkspaceEntry[]> { 537 580 return this.requireContext() 538 - .listWorkspaces(appviewUrl ?? null) 581 + .listWorkspaces(null) 539 582 .then(listWorkspacesResultSchema.parse); 540 583 } 541 584 ··· 554 597 } 555 598 556 599 /** 557 - * Unwrap the group (content) key for a workspace using the current 558 - * identity's private key. Required before calling any member-management 559 - * method that takes a `key: Uint8Array` parameter. 560 - * 561 - * Note: returning the key to JS is a pragmatic escape hatch for the 562 - * web management UI — the proper path keeps the key inside WASM. Do 563 - * not persist, log, or transmit the returned bytes. 564 - * 565 - * @param members - Raw keyring member records (from `listWorkspaceMembers`). 566 - * @returns The 32-byte group key as a Uint8Array. 567 - */ 568 - @wrapWasmErrors 569 - @withTokenGuard 570 - unwrapGroupKey(members: readonly WorkspaceMember[]): Promise<Uint8Array> { 571 - return this.requireContext().unwrapGroupKey(members); 572 - } 573 - 574 - /** 575 - * Add a member to a workspace. 600 + * Add a member to a workspace. The WASM binding resolves the keyring 601 + * and unwraps the group key internally — the key never crosses into JS. 576 602 */ 577 603 @wrapWasmErrors 578 604 @withTokenGuard 579 605 addWorkspaceMember( 580 606 keyringUri: string, 581 - key: Uint8Array, 582 607 memberDid: string, 583 608 memberPublicKey: Uint8Array, 584 609 role: WorkspaceRole, 585 610 ): Promise<MutationResult> { 586 611 return this.requireContext().addWorkspaceMember( 587 612 keyringUri, 588 - key, 589 613 memberDid, 590 614 memberPublicKey, 591 615 role, ··· 595 619 /** 596 620 * Remove a member from a workspace. 597 621 * 598 - * For owners: rotates the group key and returns the new key + rotation. 599 - * For non-owners: creates a proposal. 622 + * For owners: rotates the group key in-place inside WASM and returns 623 + * the new `rotation` number. For non-owners: creates a proposal. The 624 + * rotated key bytes never cross into JS — the next workspace operation 625 + * re-resolves via the keyring URI. 600 626 */ 601 627 @wrapWasmErrors 602 628 @withTokenGuard 603 629 removeWorkspaceMember( 604 630 keyringUri: string, 605 - key: Uint8Array, 606 631 memberDid: string, 607 - ): Promise<{ key?: Uint8Array; rotation?: number; proposed: boolean }> { 608 - return this.requireContext().removeWorkspaceMember(keyringUri, key, memberDid) as Promise<{ 609 - key?: Uint8Array; 632 + ): Promise<{ rotation?: number; proposed: boolean }> { 633 + return this.requireContext().removeWorkspaceMember(keyringUri, memberDid) as Promise<{ 610 634 rotation?: number; 611 635 proposed: boolean; 612 636 }>; ··· 619 643 return this.requireContext().leaveWorkspace(keyringUri); 620 644 } 621 645 622 - /** Update workspace metadata (name, description, icon). */ 646 + /** 647 + * Update workspace metadata (name, description, icon). The WASM binding 648 + * resolves the keyring and unwraps the group key internally — the key 649 + * never crosses into JS. 650 + */ 623 651 @wrapWasmErrors 624 652 @withTokenGuard 625 653 updateWorkspaceMetadata( 626 654 keyringUri: string, 627 - key: Uint8Array, 628 655 updates: { name?: string; description?: string; icon?: string }, 629 656 ): Promise<MutationResult> { 630 657 return this.requireContext().updateWorkspaceMetadata( 631 658 keyringUri, 632 - key, 633 659 updates.name ?? null, 634 660 updates.description ?? null, 635 661 updates.icon ?? null, ··· 694 720 } 695 721 696 722 /** 697 - * Write (upsert) the account config record. Merges with whatever the 698 - * caller passes — if a field is omitted from `updates`, the current 699 - * stored value is preserved. 723 + * Patch the account config record. Read-merge-write happens in core 724 + * under a single mutex — concurrent calls serialize rather than 725 + * clobbering each other. 700 726 * 701 - * @returns The updated config. 727 + * Tri-state semantics (see `AccountConfigPatch`): 728 + * - absent key / `undefined` → field unchanged on the PDS. 729 + * - `null` (`appviewUrl` only) → field cleared on the PDS. 730 + * - concrete value → field updated to that value. 731 + * 732 + * @returns The freshly-written record. 702 733 */ 703 734 @wrapWasmErrors 704 735 @withTokenGuard 705 - async updateAccountConfig(updates: Partial<AccountConfig>): Promise<AccountConfig> { 736 + async updateAccountConfig(updates: AccountConfigPatch): Promise<AccountConfig> { 706 737 const ctx = this.requireContext(); 707 - const current = ((await ctx.getAccountConfig()) as AccountConfig | null) ?? { 708 - opakeVersion: 1, 709 - telemetryEnabled: false, 710 - modifiedAt: new Date().toISOString(), 711 - }; 712 - const next: AccountConfig = { 713 - ...current, 714 - ...updates, 715 - modifiedAt: new Date().toISOString(), 716 - }; 717 - await ctx.setAccountConfig(next); 718 - return next; 738 + // WASM AccountConfigUpdates uses `double_option` serde semantics: 739 + // absent = leave alone, explicit null = clear, value = set. 740 + // Only include keys the caller explicitly provided. 741 + const patch: Record<string, unknown> = {}; 742 + if (updates.telemetryEnabled !== undefined) { 743 + patch.telemetryEnabled = updates.telemetryEnabled; 744 + } 745 + if (updates.appviewUrl !== undefined) { 746 + // string or explicit null — both forwarded; Rust interprets null as clear. 747 + patch.appviewUrl = updates.appviewUrl; 748 + } 749 + const record = await ctx.updateAccountConfig(patch); 750 + return record as AccountConfig; 719 751 } 720 752 721 753 // --------------------------------------------------------------------------- ··· 747 779 /** 748 780 * Stop the WASM SSE consumer. Clears the internal running flag so a 749 781 * subsequent `startSseConsumer` call can spawn a fresh consumer. 782 + * Also drains the WASM-side WorkspaceKeeper so account switches don't 783 + * leak the previous user's workspace list. 750 784 */ 751 785 stopSseConsumer(): void { 752 786 const ctx = this.ctx; 753 787 if (ctx) ctx.stopSseConsumer(); 788 + } 789 + 790 + /** 791 + * Subscribe to live changes in the workspace list. 792 + * 793 + * Fires the handler once immediately with the current snapshot 794 + * (`loaded: false` with an empty `entries` list if the keeper hasn't 795 + * been bootstrapped yet), and again on every subsequent mutation — 796 + * the initial `listWorkspaces` call populates the keeper, and SSE 797 + * `keyring:upsert` / `keyring:delete` events patch it incrementally. 798 + * 799 + * The returned handle is synchronous — registration is kicked off 800 + * eagerly and `close()` chains onto the pending Promise. This 801 + * mirrors `FileManager.watchDirectory`, letting React effects use 802 + * the result without an intermediate Promise. 803 + * 804 + * @example 805 + * ```typescript 806 + * useEffect(() => { 807 + * const watcher = opake.watchWorkspaces((snapshot) => { 808 + * setWorkspaces(snapshot.entries); 809 + * setLoaded(snapshot.loaded); 810 + * }); 811 + * return () => watcher.close(); 812 + * }, [opake]); 813 + * ``` 814 + */ 815 + watchWorkspaces(handler: (snapshot: WorkspaceSnapshot) => void): WorkspaceWatcher { 816 + // WASM calls back with the raw snapshot object — validate + transform 817 + // via the Zod schema so consumers never see snake_case or untyped values. 818 + const adapter = (raw: unknown) => { 819 + let snapshot: WorkspaceSnapshot; 820 + try { 821 + snapshot = workspaceSnapshotSchema.parse(raw); 822 + } catch (err) { 823 + console.warn("[opake-sdk] watchWorkspaces snapshot parse failed:", err); 824 + return; 825 + } 826 + try { 827 + handler(snapshot); 828 + } catch (err) { 829 + // One broken handler shouldn't break the event loop. 830 + console.warn("[opake-sdk] watchWorkspaces handler threw:", err); 831 + } 832 + }; 833 + 834 + const pending = this.requireContext().watchWorkspaces(adapter); 835 + let closed = false; 836 + let wasmWatcher: WasmWorkspaceWatcherHandle | null = null; 837 + 838 + pending.then( 839 + (w) => { 840 + if (closed) { 841 + // close() fired before the handle resolved — clean up now. 842 + void w.close(); 843 + return; 844 + } 845 + wasmWatcher = w as WasmWorkspaceWatcherHandle; 846 + }, 847 + (err: unknown) => { 848 + console.warn("[opake-sdk] watchWorkspaces registration failed:", err); 849 + }, 850 + ); 851 + 852 + return { 853 + close: () => { 854 + if (closed) return; 855 + closed = true; 856 + if (wasmWatcher) { 857 + void wasmWatcher.close(); 858 + wasmWatcher = null; 859 + } 860 + }, 861 + }; 754 862 } 755 863 756 864 // ---------------------------------------------------------------------------
+18
packages/opake-sdk/src/schemas.ts
··· 75 75 }) 76 76 .transform((r) => r.keyrings); 77 77 78 + /** 79 + * Snapshot of the workspace list emitted by `watchWorkspaces`. Fires 80 + * once on install with `loaded = false` (empty entries while the 81 + * keeper bootstraps) and again on every SSE keyring event that mutates 82 + * the list. 83 + */ 84 + export const workspaceSnapshotSchema = z 85 + .object({ 86 + entries: z.array(workspaceEntrySchema), 87 + loaded: z.boolean(), 88 + }) 89 + .transform((r) => ({ 90 + entries: r.entries, 91 + loaded: r.loaded, 92 + })); 93 + 94 + export type WorkspaceSnapshot = z.output<typeof workspaceSnapshotSchema>; 95 + 78 96 // --------------------------------------------------------------------------- 79 97 // Workspace sync 80 98 // ---------------------------------------------------------------------------
+24 -1
packages/opake-sdk/src/types.ts
··· 12 12 export interface AccountConfig { 13 13 readonly opakeVersion: number; 14 14 readonly telemetryEnabled: boolean; 15 - /** Override the default appview. Leave undefined to use the built-in default. */ 15 + /** Override the default appview. Absent means the built-in default is active. */ 16 16 readonly appviewUrl?: string; 17 17 /** ISO-8601 timestamp of last write. */ 18 18 readonly modifiedAt: string; 19 + } 20 + 21 + /** 22 + * Patch payload for `updateAccountConfig`. 23 + * 24 + * Tri-state semantics per field: 25 + * - Key absent / `undefined`: field is unchanged on the PDS. 26 + * - Explicit `null` (for `appviewUrl`): field is cleared on the PDS. 27 + * - Concrete value: field is updated to that value. 28 + * 29 + * This avoids the footgun in `Partial<AccountConfig>` where 30 + * `{ appviewUrl: undefined }` is indistinguishable from an absent key 31 + * at runtime, so passing `undefined` would silently clear the stored URL. 32 + */ 33 + export interface AccountConfigPatch { 34 + /** Set or leave `telemetryEnabled` unchanged. */ 35 + readonly telemetryEnabled?: boolean; 36 + /** 37 + * `string` — set a new appview URL. 38 + * `null` — explicitly clear the stored override (use the built-in default). 39 + * absent — leave the current value untouched. 40 + */ 41 + readonly appviewUrl?: string | null; 19 42 } 20 43 21 44 /** Result of a mutation that may be applied directly or proposed for owner approval. */