···11-// Stubbed — may be replaced by @opake/sdk or kept as app-level utility
22-throw new Error("unimplemented");
11+// Best-effort resolution of a DID → display profile (handle + avatar).
22+//
33+// Uses Bluesky's public appview so we don't pay DPoP setup just to render
44+// a member list. If the account isn't on bsky, or the fetch fails for
55+// any reason, we degrade gracefully to a null profile and callers fall
66+// back to the raw DID as display text.
77+//
88+// Results are memoized per DID for the lifetime of the page — the bsky
99+// appview is already cached at the CDN, but coalescing local duplicates
1010+// avoids N parallel fetches when a workspace has many members.
1111+1212+const PUBLIC_API = "https://public.api.bsky.app";
1313+1414+export interface MemberProfile {
1515+ readonly handle: string | null;
1616+ readonly avatarUrl: string | null;
1717+}
1818+1919+interface RawProfile {
2020+ readonly handle?: string;
2121+ readonly avatar?: string;
2222+}
2323+2424+const cache = new Map<string, Promise<MemberProfile | null>>();
2525+2626+/** Only accept avatar URLs from known Bluesky CDN origins. */
2727+function isSafeCdnUrl(url: string): boolean {
2828+ try {
2929+ const parsed = new URL(url);
3030+ return parsed.protocol === "https:" && parsed.hostname.endsWith(".bsky.app");
3131+ } catch {
3232+ return false;
3333+ }
3434+}
3535+3636+async function fetchProfile(did: string): Promise<MemberProfile | null> {
3737+ try {
3838+ const response = await fetch(
3939+ `${PUBLIC_API}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`,
4040+ );
4141+ if (!response.ok) return null;
4242+ const raw = (await response.json()) as RawProfile;
4343+ return {
4444+ handle: raw.handle ?? null,
4545+ avatarUrl: raw.avatar && isSafeCdnUrl(raw.avatar) ? raw.avatar : null,
4646+ };
4747+ } catch {
4848+ return null;
4949+ }
5050+}
5151+5252+/**
5353+ * Resolve a DID's display profile. Memoized for the page lifetime.
5454+ *
5555+ * @param did - The member DID to look up.
5656+ * @returns Profile with handle + avatar, or `null` if resolution failed.
5757+ */
5858+export function resolveMemberProfile(did: string): Promise<MemberProfile | null> {
5959+ const cached = cache.get(did);
6060+ if (cached) return cached;
6161+ const pending = fetchProfile(did);
6262+ // Module-local cache — the immutability rule misreads this as a domain
6363+ // concern. It's a fetch-dedup singleton with page lifetime.
6464+ // eslint-disable-next-line functional/immutable-data
6565+ cache.set(did, pending);
6666+ return pending;
6767+}
+24-2
apps/web/src/lib/workspaceSchemas.ts
···11-// Stubbed — replaced by @opake/sdk
22-throw new Error("unimplemented");
11+// Web-side view models for workspace member management.
22+//
33+// The SDK's `WorkspaceMember` type carries the full wrapped-key structure
44+// required for crypto operations. UI components only need a flat
55+// `{ did, role }` view — this module bridges the two and re-exports
66+// `WorkspaceRole` so components don't reach across the SDK boundary.
77+88+import type { WorkspaceMember, WorkspaceRole } from "@opake/sdk";
99+1010+export type { WorkspaceRole };
1111+1212+/** Flat member shape for UI rendering — just DID and role, no crypto material. */
1313+export interface KeyringMemberEntry {
1414+ readonly did: string;
1515+ readonly role: WorkspaceRole;
1616+}
1717+1818+/** Project a raw `WorkspaceMember` onto the UI-facing `KeyringMemberEntry`. */
1919+export function toMemberEntry(member: WorkspaceMember): KeyringMemberEntry {
2020+ return {
2121+ did: member.wrappedKey.did,
2222+ role: member.role,
2323+ };
2424+}
···22//
33// Module-level promise dedup prevents StrictMode double-effect from
44// sending concurrent `&mut self` borrows into WASM (RefCell panic).
55+//
66+// Real-time refresh: the WASM SSE consumer dispatches an
77+// `opake:workspace-updated` CustomEvent on the `window` whenever the
88+// appview broadcasts a keyring record change (this device's writes,
99+// peer writes, other-device writes). We listen and re-fetch. A
1010+// visibility listener catches edge cases where SSE is disconnected or
1111+// the page was hidden across events.
512613import { create } from "zustand";
714import { immer } from "zustand/middleware/immer";
···134141 },
135142 })),
136143);
144144+145145+// SSE-driven refresh: the WASM consumer fires this CustomEvent on any
146146+// keyring-record-level change. Only relevant after the initial load
147147+// (so we don't fetch before login). `loadWorkspaces` dedups concurrent
148148+// calls, so event bursts coalesce naturally.
149149+if (typeof window !== "undefined") {
150150+ window.addEventListener("opake:workspace-updated", () => {
151151+ const state = useWorkspaceStore.getState();
152152+ if (!state.loaded) return;
153153+ void state.loadWorkspaces();
154154+ });
155155+}
156156+157157+// Visibility fallback: covers the case where SSE was disconnected
158158+// while the page was hidden (browser backgrounding, sleep, etc).
159159+// Same dedup semantics as the SSE path.
160160+if (typeof document !== "undefined") {
161161+ document.addEventListener("visibilitychange", () => {
162162+ if (document.visibilityState !== "visible") return;
163163+ const state = useWorkspaceStore.getState();
164164+ if (!state.loaded) return;
165165+ void state.loadWorkspaces();
166166+ });
167167+}
+3
crates/opake-wasm/Cargo.toml
···2020console_log = { version = "1", features = ["color"] }
2121console_error_panic_hook = "0.1"
22222323+# Window + CustomEvent for SSE → JS notification dispatch.
2424+web-sys = { version = "0.3", features = ["Window", "CustomEvent", "CustomEventInit", "Event", "EventTarget"] }
2525+2326# Async Mutex for the shared WasmOpake — RefCell panics on concurrent
2427# borrow across await points; Mutex queues instead.
2528futures-util = { workspace = true }
+49
crates/opake-wasm/src/sse_wasm.rs
···277277 log::warn!("[sse] tree_keeper apply failed: {e}");
278278 }
279279 }
280280+281281+ // Notify JS of keyring-record-level changes so stores
282282+ // tracking workspace metadata (name, icon, members)
283283+ // can re-fetch against the now-indexed appview. Fires
284284+ // only for direct record events — proposals flow
285285+ // through the owner's apply step, which emits a
286286+ // subsequent KeyringUpsert.
287287+ if is_keyring_record_event(&event) {
288288+ if let Some(uri) = event.keyring_uri() {
289289+ dispatch_workspace_updated(uri);
290290+ }
291291+ }
280292 }
281293 // Task exited — clear the flag in case we broke on a
282294 // transport error rather than an explicit stop, so a
···435447 }
436448 });
437449 let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
450450+}
451451+452452+/// True for keyring-record-level events — i.e. direct writes/deletes of
453453+/// the keyring record, not proposal variants. These are the signals the
454454+/// workspace list wants (name/icon/member changes are visible on the
455455+/// keyring record itself).
456456+fn is_keyring_record_event(event: &opake_core::sse::events::SseEvent) -> bool {
457457+ use opake_core::sse::events::SseEvent;
458458+ matches!(
459459+ event,
460460+ SseEvent::KeyringUpsert(_) | SseEvent::KeyringDelete(_)
461461+ )
462462+}
463463+464464+/// Dispatch `opake:workspace-updated` as a window CustomEvent so JS
465465+/// stores can reload the affected workspace. The detail payload is the
466466+/// keyring URI as a string. Soft-fails on any web-sys error — this
467467+/// notification is best-effort and losing one means users wait until
468468+/// the next full reload, which is acceptable.
469469+fn dispatch_workspace_updated(keyring_uri: &str) {
470470+ let Some(window) = web_sys::window() else {
471471+ return;
472472+ };
473473+ let detail = JsValue::from_str(keyring_uri);
474474+ let init = web_sys::CustomEventInit::new();
475475+ init.set_detail(&detail);
476476+ let event =
477477+ match web_sys::CustomEvent::new_with_event_init_dict("opake:workspace-updated", &init) {
478478+ Ok(e) => e,
479479+ Err(e) => {
480480+ log::warn!("[sse] failed to construct CustomEvent: {e:?}");
481481+ return;
482482+ }
483483+ };
484484+ if let Err(e) = window.dispatch_event(&event) {
485485+ log::warn!("[sse] failed to dispatch workspace-updated: {e:?}");
486486+ }
438487}
439488440489/// Wrap a JS function as a `WatcherCallback` that builds a snapshot on
+18
packages/opake-sdk/src/opake.ts
···553553 }
554554555555 /**
556556+ * Unwrap the group (content) key for a workspace using the current
557557+ * identity's private key. Required before calling any member-management
558558+ * method that takes a `key: Uint8Array` parameter.
559559+ *
560560+ * Note: returning the key to JS is a pragmatic escape hatch for the
561561+ * web management UI — the proper path keeps the key inside WASM. Do
562562+ * not persist, log, or transmit the returned bytes.
563563+ *
564564+ * @param members - Raw keyring member records (from `listWorkspaceMembers`).
565565+ * @returns The 32-byte group key as a Uint8Array.
566566+ */
567567+ @wrapWasmErrors
568568+ @withTokenGuard
569569+ unwrapGroupKey(members: readonly WorkspaceMember[]): Promise<Uint8Array> {
570570+ return this.requireContext().unwrapGroupKey(members);
571571+ }
572572+573573+ /**
556574 * Add a member to a workspace.
557575 */
558576 @wrapWasmErrors