this repo has no description
1
fork

Configure Feed

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

Implement sharing phase 2+3: InboxKeeper, pending shares, grant fan-out

Introduces the full sharing lifecycle beyond the initial create-grant happy path:

- Error::RecipientNotReady distinguishes "valid DID, no Opake key" from NotFound
(typo'd handle). resolve_identity maps step-5 NotFound → RecipientNotReady.
retry_pending_shares now treats both NotFound and RecipientNotReady as
still_pending rather than failed (bug fix).

- Pending share queue: create_pending_share enqueues when recipient not ready;
retry_pending_shares completes or expires them. Unit tests cover TTL expiry,
still_pending paths, transient-failure non-caching, and empty queue.

- InboxKeeper: mirrors WorkspaceKeeper for incoming grants. Bootstrap via
listInbox, SSE-patched via grant:upsert / grant:delete. Appview now fetches
{owner_did, recipient_did} before deleting grant rows so the broadcaster can
fan out to both parties on delete.

- SDK: listInbox, watchInbox, resolveGrantMetadata, downloadFromGrant,
createPendingShare, listPendingShares, cancelPendingShare wired through WASM.
InboxGrant / ResolvedGrantMetadata types exported.

- @opake/react: useInbox, usePendingShares, useShareMutations, useShares hooks.

- Web: SharedWithMe page rewritten to use InboxKeeper watcher with batched
owner-handle resolution, retry button on metadata errors. Share button hidden
in workspace context (allowSharing prop on PanelContent). ShareDialog includes
TTL in the pending-share toast. Stale E2E snapshot and outdated copy updated.

+2252 -102
+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. 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. 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. Incoming shares are tracked in `InboxKeeper` — bootstrapped by `listInbox`, patched by SSE `grant:upsert` / `grant:delete` events (appview fans both out to owner and recipient personal topics). 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.
+10 -1
apps/appview/lib/opake_appview/indexer.ex
··· 140 140 141 141 defp dispatch({:delete_grant, %{uri: uri}}, _time_us, _now) do 142 142 Logger.info("[Indexer] grant delete: #{uri}") 143 + # Fetch parties before deleting — the firehose delete payload carries only 144 + # the URI. We need owner_did + recipient_did to fan out SSE deletes to both 145 + # personal topics. If the row is already gone (idempotent replay), parties 146 + # is nil and the broadcaster falls back to owner-only via the uri attrs. 147 + parties = GrantQueries.grant_parties(uri) 143 148 GrantQueries.delete_grant(uri) 144 149 emit_event_telemetry("app.opake.grant", :delete, :ok) 145 - Broadcaster.broadcast_grant(%{uri: uri}, :delete) 150 + attrs = case parties do 151 + {owner_did, recipient_did} -> %{uri: uri, owner_did: owner_did, recipient_did: recipient_did} 152 + nil -> %{uri: uri} 153 + end 154 + Broadcaster.broadcast_grant(attrs, :delete) 146 155 end 147 156 148 157 defp dispatch({:upsert_keyring, attrs}, _time_us, _now) do
+10
apps/appview/lib/opake_appview/queries/grant_queries.ex
··· 27 27 |> Repo.delete_all() 28 28 end 29 29 30 + @doc """ 31 + Returns `{owner_did, recipient_did}` for a grant URI, or `nil` if not found. 32 + Used by the broadcaster to fan out delete events before the row is removed. 33 + """ 34 + @spec grant_parties(String.t()) :: {String.t(), String.t()} | nil 35 + def grant_parties(uri) do 36 + from(g in Grant, where: g.uri == ^uri, select: {g.owner_did, g.recipient_did}) 37 + |> Repo.one() 38 + end 39 + 30 40 @spec list_inbox(String.t(), keyword()) :: {[Grant.t()], String.t() | nil} 31 41 def list_inbox(recipient_did, opts \\ []) do 32 42 limit = Keyword.get(opts, :limit, 50)
+5 -3
apps/appview/lib/opake_appview/sse/broadcaster.ex
··· 65 65 end 66 66 67 67 if owner = get(attrs, :owner_did), do: broadcast(Topics.personal(owner), "grant:#{action}", payload) 68 - if action == :upsert, do: maybe_broadcast_recipient(attrs, payload) 68 + maybe_broadcast_recipient(attrs, payload, action) 69 69 rescue 70 70 e -> Logger.warning("[Broadcaster] grant broadcast failed: #{inspect(e)}") 71 71 end ··· 144 144 Phoenix.PubSub.broadcast(@pubsub, topic, {:sse_event, event_type, payload}) 145 145 end 146 146 147 - defp maybe_broadcast_recipient(attrs, payload) do 148 - if recipient = get(attrs, :recipient_did), do: broadcast(Topics.personal(recipient), "grant:upsert", payload) 147 + defp maybe_broadcast_recipient(attrs, payload, action) do 148 + if recipient = get(attrs, :recipient_did) do 149 + broadcast(Topics.personal(recipient), "grant:#{action}", payload) 150 + end 149 151 end 150 152 151 153 defp get(attrs, key), do: attrs[key]
+2 -19
apps/cli/src/commands/share.rs
··· 1 1 use anyhow::Result; 2 2 use clap::Args; 3 3 use opake_core::client::Session; 4 - use opake_core::crypto::{self, GrantMetadata, OsRng}; 5 4 use opake_core::error::Error; 6 - use opake_core::records::{PendingShare, PENDING_SHARE_COLLECTION}; 7 5 use opake_core::resolve; 8 6 9 7 use crate::commands::Execute; 10 - use crate::session::{self, CommandContext}; 8 + use crate::session::CommandContext; 11 9 use opake_core::client::ReqwestTransport; 12 10 13 11 #[derive(Args)] ··· 57 55 } 58 56 Err(Error::NotFound(_)) => { 59 57 // Recipient hasn't set up Opake — queue pending share. 60 - // This path uses fetch_content_key from FileManager, then 61 - // falls back to raw client for the pending share record. 62 - let content_key = mgr.fetch_content_key(&uri).await?; 63 - 64 - let metadata = GrantMetadata { 65 - permissions: Some("read".to_string()), 66 - note: self.note.clone(), 67 - }; 68 - let encrypted_metadata = 69 - crypto::encrypt_metadata(&content_key, &metadata, &mut OsRng)?; 70 - 71 - let now = session::chrono_now(); 72 - let pending = 73 - PendingShare::new(uri, self.recipient.clone(), encrypted_metadata, now); 74 - 75 - mgr.create_record(PENDING_SHARE_COLLECTION, &pending) 58 + mgr.create_pending_share(&uri, &self.recipient, "read", self.note.as_deref()) 76 59 .await?; 77 60 78 61 println!(
+11
apps/web/src/components/cabinet/FileActionMenu.tsx
··· 6 6 NotePencilIcon, 7 7 PencilSimpleIcon, 8 8 ShareNetworkIcon, 9 + SlidersHorizontalIcon, 9 10 TrashIcon, 10 11 } from "@phosphor-icons/react"; 11 12 import { DropdownMenu } from "@/components/DropdownMenu"; ··· 20 21 readonly onRename?: () => void; 21 22 readonly onMove?: () => void; 22 23 readonly onShare?: () => void; 24 + readonly onManageSharing?: () => void; 23 25 readonly onDownload?: () => void; 24 26 readonly onDelete?: () => void; 25 27 readonly onDeleteFolder?: () => void; ··· 54 56 ...(props.onPreview ? [{ icon: EyeIcon, label: "Preview", onClick: props.onPreview }] : []), 55 57 { icon: PencilSimpleIcon, label: "Edit details", onClick: props.onEditMetadata }, 56 58 { icon: ShareNetworkIcon, label: "Share\u2026", onClick: props.onShare }, 59 + ...(props.onManageSharing 60 + ? [ 61 + { 62 + icon: SlidersHorizontalIcon, 63 + label: "Manage sharing\u2026", 64 + onClick: props.onManageSharing, 65 + }, 66 + ] 67 + : []), 57 68 { icon: ArrowBendUpRightIcon, label: "Move to\u2026", onClick: props.onMove }, 58 69 { icon: DownloadSimpleIcon, label: "Download", onClick: props.onDownload }, 59 70 { icon: TrashIcon, label: "Delete", onClick: props.onDelete },
+3
apps/web/src/components/cabinet/FileGridCard.tsx
··· 18 18 readonly onRename?: () => void; 19 19 readonly onMove?: () => void; 20 20 readonly onShare?: () => void; 21 + readonly onManageSharing?: () => void; 21 22 readonly onDownload?: () => void; 22 23 readonly onDelete?: () => void; 23 24 readonly onDeleteFolder?: () => void; ··· 37 38 onRename, 38 39 onMove, 39 40 onShare, 41 + onManageSharing, 40 42 onDownload, 41 43 onDelete, 42 44 onDeleteFolder, ··· 93 95 onRename={onRename} 94 96 onMove={onMove} 95 97 onShare={onShare} 98 + onManageSharing={onManageSharing} 96 99 onDownload={onDownload} 97 100 onDelete={onDelete} 98 101 onDeleteFolder={onDeleteFolder}
+3
apps/web/src/components/cabinet/FileListRow.tsx
··· 18 18 readonly onRename?: () => void; 19 19 readonly onMove?: () => void; 20 20 readonly onShare?: () => void; 21 + readonly onManageSharing?: () => void; 21 22 readonly onDownload?: () => void; 22 23 readonly onDelete?: () => void; 23 24 readonly onDeleteFolder?: () => void; ··· 37 38 onRename, 38 39 onMove, 39 40 onShare, 41 + onManageSharing, 40 42 onDownload, 41 43 onDelete, 42 44 onDeleteFolder, ··· 90 92 onRename={onRename} 91 93 onMove={onMove} 92 94 onShare={onShare} 95 + onManageSharing={onManageSharing} 93 96 onDownload={onDownload} 94 97 onDelete={onDelete} 95 98 onDeleteFolder={onDeleteFolder}
+1
apps/web/src/components/cabinet/FileView.tsx
··· 341 341 onMoveEntry={handleMoveEntry} 342 342 onRenameDirectory={handleRenameDirectory} 343 343 rootLabel={rootLabel} 344 + allowSharing={context.kind === "cabinet"} 344 345 /> 345 346 )} 346 347 </PanelShell>
+54 -34
apps/web/src/components/cabinet/PanelContent.tsx
··· 1 1 import { Suspense, useRef } from "react"; 2 + import type { DirectoryTreeSnapshot } from "@opake/sdk"; 2 3 import { FolderIcon } from "@phosphor-icons/react"; 3 4 import { FileListRow } from "./FileListRow"; 4 5 import { FileGridCard } from "./FileGridCard"; ··· 18 19 import { MoveDialog, type MoveDialogHandle } from "./MoveDialog"; 19 20 import { RenameDialog, type RenameDialogHandle } from "./RenameDialog"; 20 21 import { ShareDialog, type ShareDialogHandle } from "./ShareDialog"; 22 + import { ShareManagementDialog, type ShareManagementDialogHandle } from "./ShareManagementDialog"; 21 23 import { isPreviewable, isEditable, type FileItem } from "./types"; 24 + 25 + /** Recursively count document and directory descendants in a tree snapshot. */ 26 + function countDescendants( 27 + snapshot: DirectoryTreeSnapshot, 28 + uri: string, 29 + ): { documents: number; directories: number } { 30 + const dir = snapshot.directories[uri]; 31 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 32 + if (!dir) return { documents: 0, directories: 0 }; 33 + return dir.entries.reduce( 34 + (acc, entry) => { 35 + if (entry.type === "directory") { 36 + const sub = countDescendants(snapshot, entry.uri); 37 + return { 38 + documents: acc.documents + sub.documents, 39 + directories: acc.directories + 1 + sub.directories, 40 + }; 41 + } 42 + return { documents: acc.documents + 1, directories: acc.directories }; 43 + }, 44 + { documents: 0, directories: 0 }, 45 + ); 46 + } 47 + 48 + /** Collect all descendant URIs (entries of subdirectories) recursively. */ 49 + function collectDescendantUris(snapshot: DirectoryTreeSnapshot | null, uri: string): string[] { 50 + const dir = snapshot?.directories[uri]; 51 + if (!dir) return []; 52 + return dir.entries.flatMap((e) => [e.uri, ...collectDescendantUris(snapshot, e.uri)]); 53 + } 22 54 import { useTreeSnapshot } from "./TreeSnapshotContext"; 23 55 24 56 interface PanelContentProps { ··· 35 67 readonly onMoveEntry?: (entryUri: string, targetDirectoryUri: string | null) => void; 36 68 readonly onRenameDirectory?: (directoryUri: string, newName: string) => void; 37 69 readonly rootLabel: string; 70 + /** Sharing is only supported from the cabinet — hide share actions in workspace context. */ 71 + readonly allowSharing?: boolean; 38 72 } 39 73 40 74 export function PanelContent({ ··· 51 85 onMoveEntry, 52 86 onRenameDirectory, 53 87 rootLabel, 88 + allowSharing = true, 54 89 }: PanelContentProps) { 55 90 const deleteDialogRef = useRef<ConfirmDialogHandle>(null); 56 91 const deleteFolderDialogRef = useRef<DeleteFolderDialogHandle>(null); ··· 58 93 const moveDialogRef = useRef<MoveDialogHandle>(null); 59 94 const renameDialogRef = useRef<RenameDialogHandle>(null); 60 95 const shareDialogRef = useRef<ShareDialogHandle>(null); 96 + const manageSharingDialogRef = useRef<ShareManagementDialogHandle>(null); 61 97 const treeSnapshot = useTreeSnapshot(); 62 98 63 99 const handleDeleteFolderClick = (item: FileItem) => { 64 - const snapshot = treeSnapshot; 65 - if (!snapshot) return; 66 - // Count descendants from the snapshot 67 - const countDescendants = (uri: string): { documents: number; directories: number } => { 68 - const dir = snapshot.directories[uri]; 69 - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 70 - if (!dir) return { documents: 0, directories: 0 }; 71 - return dir.entries.reduce( 72 - (acc, entry) => { 73 - if (entry.type === "directory") { 74 - const sub = countDescendants(entry.uri); 75 - return { 76 - documents: acc.documents + sub.documents, 77 - directories: acc.directories + 1 + sub.directories, 78 - }; 79 - } 80 - return { documents: acc.documents + 1, directories: acc.directories }; 81 - }, 82 - { documents: 0, directories: 0 }, 83 - ); 84 - }; 85 - const counts = countDescendants(item.uri); 100 + if (!treeSnapshot) return; 101 + const counts = countDescendants(treeSnapshot, item.uri); 86 102 deleteFolderDialogRef.current?.show(item.uri, item.name, counts.documents, counts.directories); 87 103 }; 88 104 89 105 const handleMoveClick = (item: FileItem) => { 90 - const snapshot = treeSnapshot; 91 - const currentParent = snapshot ? findParentUri(snapshot, item.uri) : null; 92 - 93 - const collectDescendantUris = (uri: string): string[] => { 94 - const dir = snapshot?.directories[uri]; 95 - if (!dir) return []; 96 - return dir.entries.flatMap((e) => [e.uri, ...collectDescendantUris(e.uri)]); 97 - }; 98 - 106 + const currentParent = treeSnapshot ? findParentUri(treeSnapshot, item.uri) : null; 99 107 const disabled: ReadonlySet<string> = 100 - item.kind === "folder" ? new Set([item.uri, ...collectDescendantUris(item.uri)]) : new Set(); 101 - 108 + item.kind === "folder" 109 + ? new Set([item.uri, ...collectDescendantUris(treeSnapshot, item.uri)]) 110 + : new Set(); 102 111 moveDialogRef.current?.show(item.uri, item.name, item.kind, currentParent, disabled); 103 112 }; 104 113 ··· 133 142 onEdit && isEditable(item) ? () => onEdit(item) : undefined; 134 143 135 144 const FileListComponent = viewMode === "list" ? FileListRow : FileGridCard; 145 + // eslint-disable-next-line sonarjs/cognitive-complexity -- many conditional props for a multi-action file panel 136 146 const fileList = items.map((item) => ( 137 147 <FileListComponent 138 148 key={item.id} ··· 147 157 onRenameDirectory ? () => renameDialogRef.current?.show(item.uri, item.name) : undefined 148 158 } 149 159 onMove={onMoveEntry ? () => handleMoveClick(item) : undefined} 150 - onShare={() => shareDialogRef.current?.show(item.uri, item.name)} 160 + onShare={ 161 + allowSharing && item.kind === "file" 162 + ? () => shareDialogRef.current?.show(item.uri, item.name) 163 + : undefined 164 + } 165 + onManageSharing={ 166 + allowSharing && item.kind === "file" 167 + ? () => manageSharingDialogRef.current?.show(item.uri, item.name) 168 + : undefined 169 + } 151 170 onDownload={() => onDownload(item.uri)} 152 171 onDelete={() => deleteDialogRef.current?.show(item.uri, item.name)} 153 172 onDeleteFolder={onDeleteFolder ? () => handleDeleteFolderClick(item) : undefined} ··· 178 197 {onMoveEntry && <MoveDialog ref={moveDialogRef} onMove={onMoveEntry} rootLabel={rootLabel} />} 179 198 {onRenameDirectory && <RenameDialog ref={renameDialogRef} onSave={onRenameDirectory} />} 180 199 <ShareDialog ref={shareDialogRef} /> 200 + <ShareManagementDialog ref={manageSharingDialogRef} /> 181 201 </div> 182 202 ); 183 203 }
+2 -2
apps/web/src/components/cabinet/ShareDialog.tsx
··· 70 70 await getActiveFileManager().share(documentUri, recipient.did, recipient.publicKey, "read"); 71 71 } catch (resolveError) { 72 72 if (resolveError instanceof RecipientNotReadyError) { 73 - // REMOVE: pending share needs core domain method (Opake::create_pending_share) 73 + await getActiveFileManager().createPendingShare(documentUri, handle, "read", null); 74 74 setStatus("done"); 75 75 toastSuccess( 76 - `${handle} hasn't set up Opake yet. Share queued — it will complete automatically once they log in on any device.`, 76 + `${handle} hasn't set up Opake yet. Share queued — completes automatically once they log in (expires in 7 days).`, 77 77 ); 78 78 dismiss(); 79 79 return;
+142
apps/web/src/components/cabinet/ShareManagementDialog.tsx
··· 1 + import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; 2 + import { ProhibitIcon, ShareNetworkIcon } from "@phosphor-icons/react"; 3 + import type { GrantEntry } from "@opake/sdk"; 4 + import { MODAL_TRANSITION_MS } from "@/components/ConfirmDialog"; 5 + import { getActiveFileManager } from "@/stores/documents/store"; 6 + import { toastError, toastSuccess } from "@/stores/toast"; 7 + 8 + export interface ShareManagementDialogHandle { 9 + readonly show: (documentUri: string, documentName: string) => void; 10 + } 11 + 12 + export const ShareManagementDialog = forwardRef<ShareManagementDialogHandle, object>( 13 + function ShareManagementDialog(_props, ref) { 14 + const dialogRef = useRef<HTMLDialogElement>(null); 15 + const [documentUri, setDocumentUri] = useState<string | null>(null); 16 + const [documentName, setDocumentName] = useState(""); 17 + const [shares, setShares] = useState<readonly GrantEntry[]>([]); 18 + const [loading, setLoading] = useState(false); 19 + const [revoking, setRevoking] = useState<string | null>(null); 20 + 21 + const dismiss = useCallback(() => { 22 + dialogRef.current?.close(); 23 + setTimeout(() => { 24 + setDocumentUri(null); 25 + setDocumentName(""); 26 + setShares([]); 27 + }, MODAL_TRANSITION_MS); 28 + }, []); 29 + 30 + const loadShares = useCallback(async (uri: string) => { 31 + setLoading(true); 32 + try { 33 + const all = await getActiveFileManager().listShares(); 34 + setShares(all.filter((g) => g.document === uri)); 35 + } catch (err) { 36 + toastError(`Failed to load shares: ${err instanceof Error ? err.message : String(err)}`); 37 + } finally { 38 + setLoading(false); 39 + } 40 + }, []); 41 + 42 + useEffect(() => { 43 + if (documentUri) void loadShares(documentUri); 44 + }, [documentUri, loadShares]); 45 + 46 + const handleRevoke = useCallback(async (grantUri: string) => { 47 + setRevoking(grantUri); 48 + try { 49 + await getActiveFileManager().revokeShare(grantUri); 50 + // Optimistic remove — SSE `grant:delete` will arrive shortly and 51 + // reconcile via the InboxKeeper on peer devices, but the current 52 + // user's dialog needs the entry gone now. 53 + setShares((prev) => prev.filter((g) => g.uri !== grantUri)); 54 + toastSuccess("Access revoked"); 55 + } catch (err) { 56 + toastError(`Failed to revoke: ${err instanceof Error ? err.message : String(err)}`); 57 + } finally { 58 + setRevoking(null); 59 + } 60 + }, []); 61 + 62 + useImperativeHandle(ref, () => ({ 63 + show: (uri: string, name: string) => { 64 + setDocumentUri(uri); 65 + setDocumentName(name); 66 + setShares([]); 67 + dialogRef.current?.showModal(); 68 + }, 69 + })); 70 + 71 + return ( 72 + <dialog ref={dialogRef} className="modal" aria-label="Manage sharing"> 73 + <div className="modal-box max-w-sm"> 74 + <div className="flex flex-col items-center gap-3 text-center"> 75 + <div className="bg-primary/10 flex size-11 items-center justify-center rounded-full"> 76 + <ShareNetworkIcon size={20} className="text-primary" /> 77 + </div> 78 + <h3 className="text-base-content text-sm font-semibold">Manage sharing</h3> 79 + <p className="text-text-muted text-xs"> 80 + Who has access to{" "} 81 + <span className="text-base-content font-medium">{documentName}</span> 82 + </p> 83 + </div> 84 + 85 + <div className="mt-4 flex flex-col gap-1"> 86 + {loading && ( 87 + <span className="text-caption text-text-faint py-2 text-center">Loading…</span> 88 + )} 89 + {!loading && shares.length === 0 && ( 90 + <span className="text-caption text-text-faint py-2 text-center"> 91 + Not shared with anyone 92 + </span> 93 + )} 94 + {shares.map((grant) => { 95 + const isRevoking = revoking === grant.uri; 96 + return ( 97 + <div key={grant.uri} className="flex items-center gap-2 rounded-lg px-2.5 py-1.5"> 98 + <div className="flex min-w-0 flex-1 flex-col"> 99 + <span className="text-ui text-base-content truncate">{grant.recipient}</span> 100 + <span className="text-caption text-text-faint"> 101 + shared {formatDate(grant.createdAt)} 102 + </span> 103 + </div> 104 + <button 105 + onClick={() => void handleRevoke(grant.uri)} 106 + disabled={isRevoking} 107 + className="btn btn-ghost btn-xs gap-1 rounded-lg" 108 + title="Revoke access" 109 + aria-label={`Revoke access for ${grant.recipient}`} 110 + > 111 + {isRevoking ? ( 112 + <span className="loading loading-spinner loading-xs" /> 113 + ) : ( 114 + <ProhibitIcon size={12} /> 115 + )} 116 + <span className="text-[11px]">Revoke</span> 117 + </button> 118 + </div> 119 + ); 120 + })} 121 + </div> 122 + 123 + <div className="modal-action justify-center"> 124 + <button onClick={dismiss} className="btn btn-ghost btn-sm rounded-lg text-xs"> 125 + Close 126 + </button> 127 + </div> 128 + </div> 129 + <form method="dialog" className="modal-backdrop"> 130 + <button aria-label="Close">close</button> 131 + </form> 132 + </dialog> 133 + ); 134 + }, 135 + ); 136 + 137 + function formatDate(iso: string): string { 138 + if (!iso) return "recently"; 139 + const d = new Date(iso); 140 + if (Number.isNaN(d.getTime())) return "recently"; 141 + return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); 142 + }
+12 -17
apps/web/src/lib/sharing.ts
··· 5 5 // via a pending-share queue instead of blocking with a raw error. 6 6 7 7 import { getOpake } from "@/stores/auth"; 8 - import type { ResolvedIdentity } from "@opake/sdk"; 8 + import { OpakeError, type ResolvedIdentity } from "@opake/sdk"; 9 9 10 10 /** 11 11 * Thrown when a recipient has a valid handle/DID but hasn't published 12 12 * an X25519 public key yet (no identity record on their PDS). The 13 13 * caller should enqueue a pending share in this case rather than 14 14 * failing the whole flow. 15 + * 16 + * Distinct from a generic `OpakeError { kind: "NotFound" }` which 17 + * covers handle/DID resolution failures (i.e. the handle doesn't exist 18 + * at all — likely a typo). Core emits `RecipientNotReady` only after 19 + * successfully resolving the DID document but finding no publicKey record. 15 20 */ 16 21 export class RecipientNotReadyError extends Error { 17 22 constructor(message: string) { ··· 23 28 /** 24 29 * Resolve a recipient handle or DID to their identity (DID + public key). 25 30 * 26 - * @throws RecipientNotReadyError if the recipient has no published identity. 27 - * @throws Error (generic) on network or resolution failure. 31 + * @throws RecipientNotReadyError if the recipient exists but hasn't set up Opake. 32 + * @throws OpakeError { kind: "NotFound" } if the handle/DID doesn't exist. 33 + * @throws Error on network or other failure. 28 34 */ 29 35 export async function resolveRecipient(handle: string): Promise<ResolvedIdentity> { 30 - const identity = await getOpake() 36 + return getOpake() 31 37 .resolveIdentity(handle) 32 38 .catch((err: unknown) => { 33 - const message = err instanceof Error ? err.message : String(err); 34 - // The SDK throws a generic error when the identity record is absent — 35 - // sniff the message to decide whether to raise the "not ready" error. 36 - if (/publicKey|public key|not found|no identity/i.test(message)) { 37 - throw new RecipientNotReadyError(message); 39 + if (err instanceof OpakeError && err.kind === "RecipientNotReady") { 40 + throw new RecipientNotReadyError(err.message); 38 41 } 39 42 throw err; 40 43 }); 41 - 42 - if (identity.publicKey.length === 0) { 43 - throw new RecipientNotReadyError( 44 - `${handle} has an account but hasn't published a public key yet.`, 45 - ); 46 - } 47 - 48 - return identity; 49 44 }
+276 -5
apps/web/src/routes/cabinet/shared.lazy.tsx
··· 1 1 import { createLazyFileRoute } from "@tanstack/react-router"; 2 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 3 + import { ArrowClockwiseIcon, DownloadSimpleIcon, ShareNetworkIcon } from "@phosphor-icons/react"; 4 + import type { InboxGrant, ResolvedGrantMetadata } from "@opake/sdk"; 5 + import { PanelShell } from "@/components/cabinet/PanelShell"; 6 + import { getOpake } from "@/stores/auth"; 7 + import { toastError, toastSuccess } from "@/stores/toast"; 8 + import { triggerBrowserDownload } from "@/lib/download"; 2 9 3 - export const Route = createLazyFileRoute("/cabinet/shared")({ 4 - component: RouteComponent, 5 - }); 10 + const METADATA_BATCH_SIZE = 5; 11 + 12 + interface ResolvedEntry { 13 + readonly grant: InboxGrant; 14 + readonly metadata: ResolvedGrantMetadata | null; 15 + readonly ownerHandle: string | null; 16 + readonly status: "resolving" | "resolved" | "error"; 17 + readonly error?: string; 18 + } 19 + 20 + function SharedWithMePage() { 21 + const [grants, setGrants] = useState<readonly InboxGrant[] | null>(null); 22 + const [metadataByUri, setMetadataByUri] = useState< 23 + Readonly<Partial<Record<string, ResolvedGrantMetadata>>> 24 + >({}); 25 + const [handleByDid, setHandleByDid] = useState<Readonly<Record<string, string>>>({}); 26 + const [failedByUri, setFailedByUri] = useState<Readonly<Record<string, string>>>({}); 27 + const [downloading, setDownloading] = useState<string | null>(null); 28 + 29 + // Track which grant URIs we've already kicked off a resolve for, so the 30 + // effect stays idempotent under StrictMode and doesn't re-fetch on every 31 + // render when only `metadataByUri` updates. 32 + const resolutionKickedRef = useRef(new Set<string>()); 33 + 34 + useEffect(() => { 35 + const watcher = getOpake().watchInbox((snap) => { 36 + setGrants(snap.loaded ? snap.entries : null); 37 + }); 38 + void getOpake() 39 + .listInbox() 40 + .catch((err: unknown) => { 41 + console.warn("[shared] listInbox failed:", err); 42 + }); 43 + return () => watcher.close(); 44 + }, []); 45 + 46 + // Resolve metadata + owner handles for new grants in bounded batches. 47 + // Batches run serially (first 5, then next 5, etc.) to avoid opening 50 48 + // cross-PDS fetches simultaneously on a heavy inbox. 49 + // 50 + // Failures are NOT permanently kicked — the retry button clears failedByUri 51 + // for a URI and removes it from resolutionKickedRef so the effect picks it up 52 + // again on the next render. 53 + useEffect(() => { 54 + if (!grants) return; 55 + 56 + const pending = grants.filter( 57 + (g) => !resolutionKickedRef.current.has(g.uri) && metadataByUri[g.uri] === undefined, 58 + ); 59 + if (pending.length === 0) return; 60 + 61 + // Mark as kicked ONLY for entries not already failed — failures stay retriable. 62 + pending.forEach((g) => { 63 + if (!failedByUri[g.uri]) resolutionKickedRef.current.add(g.uri); 64 + }); 65 + 66 + const batches = Array.from( 67 + { length: Math.ceil(pending.length / METADATA_BATCH_SIZE) }, 68 + (_, i) => pending.slice(i * METADATA_BATCH_SIZE, (i + 1) * METADATA_BATCH_SIZE), 69 + ); 70 + 71 + void batches.reduce(async (prev, batch) => { 72 + await prev; 73 + 74 + const metaResults = await Promise.allSettled( 75 + batch.map((g) => getOpake().resolveGrantMetadata(g.uri)), 76 + ); 77 + const ownerDids = [...new Set(batch.map((g) => g.ownerDid).filter((d) => !handleByDid[d]))]; 78 + const ownerResults = await Promise.allSettled( 79 + ownerDids.map((did) => getOpake().resolveIdentity(did)), 80 + ); 81 + 82 + setHandleByDid((prev) => ({ 83 + ...prev, 84 + ...Object.fromEntries( 85 + ownerDids.flatMap((did, idx) => { 86 + const r = ownerResults[idx]; 87 + return r.status === "fulfilled" && r.value.handle 88 + ? ([[did, r.value.handle]] as const) 89 + : []; 90 + }), 91 + ), 92 + })); 93 + 94 + setMetadataByUri((prev) => ({ 95 + ...prev, 96 + ...Object.fromEntries( 97 + batch.flatMap((g, idx) => { 98 + const r = metaResults[idx]; 99 + return r.status === "fulfilled" ? ([[g.uri, r.value]] as const) : []; 100 + }), 101 + ), 102 + })); 103 + 104 + // Track failures and remove cleared entries. Side-effect on the kick set lives here. 105 + const failureEntries = batch.flatMap((g, idx) => { 106 + const r = metaResults[idx]; 107 + if (r.status === "rejected") { 108 + resolutionKickedRef.current.delete(g.uri); 109 + const msg = r.reason instanceof Error ? r.reason.message : String(r.reason); 110 + return [[g.uri, msg] as const]; 111 + } 112 + return []; 113 + }); 114 + const succeededUris = new Set( 115 + batch.filter((_, idx) => metaResults[idx].status !== "rejected").map((g) => g.uri), 116 + ); 117 + setFailedByUri((prev) => ({ 118 + ...Object.fromEntries(Object.entries(prev).filter(([key]) => !succeededUris.has(key))), 119 + ...Object.fromEntries(failureEntries), 120 + })); 121 + }, Promise.resolve()); 122 + }, [grants, metadataByUri, failedByUri, handleByDid]); 123 + 124 + const retryResolution = useCallback((uri: string) => { 125 + resolutionKickedRef.current.delete(uri); 126 + setFailedByUri((prev) => 127 + Object.fromEntries(Object.entries(prev).filter(([key]) => key !== uri)), 128 + ); 129 + }, []); 130 + 131 + const entries: readonly ResolvedEntry[] = useMemo(() => { 132 + if (!grants) return []; 133 + return grants.map((grant) => { 134 + const metadata = metadataByUri[grant.uri] ?? null; 135 + const ownerHandle = handleByDid[grant.ownerDid] ?? null; 136 + const err = failedByUri[grant.uri]; 137 + if (metadata) return { grant, metadata, ownerHandle, status: "resolved" as const }; 138 + if (err) return { grant, metadata: null, ownerHandle, status: "error" as const, error: err }; 139 + return { grant, metadata: null, ownerHandle, status: "resolving" as const }; 140 + }); 141 + }, [grants, metadataByUri, failedByUri, handleByDid]); 142 + 143 + const handleDownload = useCallback(async (grantUri: string) => { 144 + setDownloading(grantUri); 145 + try { 146 + const result = await getOpake().downloadFromGrant(grantUri); 147 + triggerBrowserDownload(result.data, result.filename, "application/octet-stream"); 148 + toastSuccess(`Downloaded ${result.filename}`); 149 + } catch (err) { 150 + const message = err instanceof Error ? err.message : "Failed to download"; 151 + toastError(message); 152 + } finally { 153 + setDownloading(null); 154 + } 155 + }, []); 156 + 157 + const breadcrumbs = ( 158 + <div className="breadcrumbs text-ui min-w-0 flex-1 overflow-hidden"> 159 + <ul> 160 + <li> 161 + <span className="text-base-content font-medium">Shared with me</span> 162 + </li> 163 + </ul> 164 + </div> 165 + ); 166 + 167 + const isLoading = grants === null; 6 168 7 - function RouteComponent() { 8 - return <div>Hello "/cabinet/shared"!</div>; 169 + return ( 170 + <PanelShell depth={1} breadcrumbs={breadcrumbs} footer="Incoming shares"> 171 + {isLoading ? ( 172 + <div className="hero py-16"> 173 + <div className="hero-content flex-col text-center"> 174 + <span className="loading loading-spinner loading-sm" /> 175 + <div className="text-ui text-text-muted">Loading inbox…</div> 176 + </div> 177 + </div> 178 + ) : entries.length === 0 ? ( 179 + <div className="hero py-16"> 180 + <div className="hero-content flex-col text-center"> 181 + <div className="bg-accent flex size-13 items-center justify-center rounded-[14px]"> 182 + <ShareNetworkIcon size={22} className="text-text-faint" /> 183 + </div> 184 + <div className="text-ui text-text-muted">Nothing shared with you yet</div> 185 + <div className="text-text-faint max-w-60 text-xs leading-relaxed"> 186 + Files others share with your handle show up here. 187 + </div> 188 + </div> 189 + </div> 190 + ) : ( 191 + <ul className="flex flex-col gap-px p-3"> 192 + {entries.map((entry) => ( 193 + <SharedRow 194 + key={entry.grant.uri} 195 + entry={entry} 196 + isDownloading={downloading === entry.grant.uri} 197 + onDownload={() => void handleDownload(entry.grant.uri)} 198 + onRetry={() => retryResolution(entry.grant.uri)} 199 + /> 200 + ))} 201 + </ul> 202 + )} 203 + </PanelShell> 204 + ); 9 205 } 206 + 207 + interface SharedRowProps { 208 + readonly entry: ResolvedEntry; 209 + readonly isDownloading: boolean; 210 + readonly onDownload: () => void; 211 + readonly onRetry: () => void; 212 + } 213 + 214 + function SharedRow({ entry, isDownloading, onDownload, onRetry }: SharedRowProps) { 215 + const { grant, metadata, ownerHandle, status, error } = entry; 216 + const displayName = metadata?.name ?? "Encrypted file"; 217 + const ownerLabel = ownerHandle ? `@${ownerHandle}` : grant.ownerDid; 218 + 219 + return ( 220 + <li className="border-base-300/40 hover:bg-base-200/40 flex items-center justify-between gap-3 rounded-lg border px-3 py-2 transition-colors"> 221 + <div className="flex min-w-0 flex-1 items-center gap-3"> 222 + <div className="bg-accent flex size-8 shrink-0 items-center justify-center rounded-lg"> 223 + <ShareNetworkIcon size={16} className="text-primary" /> 224 + </div> 225 + <div className="min-w-0 flex-1"> 226 + <div className="text-base-content truncate text-xs font-medium"> 227 + {status === "resolving" ? ( 228 + <span className="text-text-faint italic">Resolving…</span> 229 + ) : status === "error" ? ( 230 + <span className="text-error" title={error}> 231 + Could not decrypt metadata 232 + </span> 233 + ) : ( 234 + displayName 235 + )} 236 + </div> 237 + <div className="text-text-faint truncate text-[11px]"> 238 + from {ownerLabel} · {formatDate(grant.createdAt)} 239 + </div> 240 + </div> 241 + </div> 242 + <div className="flex shrink-0 items-center gap-1"> 243 + {status === "error" && ( 244 + <button 245 + onClick={onRetry} 246 + className="btn btn-ghost btn-xs rounded-md" 247 + aria-label="Retry metadata resolution" 248 + title="Retry" 249 + > 250 + <ArrowClockwiseIcon size={12} /> 251 + </button> 252 + )} 253 + <button 254 + onClick={onDownload} 255 + disabled={isDownloading || status === "error"} 256 + className="btn btn-ghost btn-xs gap-1.5 rounded-md" 257 + aria-label={`Download ${displayName}`} 258 + > 259 + {isDownloading ? ( 260 + <span className="loading loading-spinner loading-xs" /> 261 + ) : ( 262 + <DownloadSimpleIcon size={12} /> 263 + )} 264 + <span className="text-[11px]">Download</span> 265 + </button> 266 + </div> 267 + </li> 268 + ); 269 + } 270 + 271 + function formatDate(iso: string): string { 272 + if (!iso) return "unknown date"; 273 + const d = new Date(iso); 274 + if (Number.isNaN(d.getTime())) return "unknown date"; 275 + return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); 276 + } 277 + 278 + export const Route = createLazyFileRoute("/cabinet/shared")({ 279 + component: SharedWithMePage, 280 + });
+1 -1
crates/opake-core/src/client/appview.rs
··· 418 418 assert!(resp.cursor.is_none()); 419 419 420 420 let req = &mock.requests()[0]; 421 - assert!(req.url.contains("/api/inbox?did=did:plc:me")); 421 + assert_eq!(req.url, "https://appview.test/api/inbox"); 422 422 assert!(req 423 423 .headers 424 424 .iter()
+8
crates/opake-core/src/error.rs
··· 23 23 #[error("record not found: {0}")] 24 24 NotFound(String), 25 25 26 + /// The target handle or DID is a valid identity but has not published an 27 + /// Opake public key yet (`app.opake.publicKey/self` is absent). Distinct 28 + /// from `NotFound` (which covers handle-resolution failures) so callers 29 + /// can offer a pending-share queue for this case without silently swallowing 30 + /// typos. 31 + #[error("recipient not ready: {0}")] 32 + RecipientNotReady(String), 33 + 26 34 #[error("{count} records named {name:?} — specify an AT URI instead: {}", uris.join(", "))] 27 35 AmbiguousName { 28 36 name: String,
+241
crates/opake-core/src/inbox_keeper/mod.rs
··· 1 + //! Persistent in-memory inbox state driven by SSE events. 2 + //! 3 + //! `InboxKeeper` is to the inbox list what [`WorkspaceKeeper`] is to the 4 + //! workspace list: a single in-memory source of truth that receives 5 + //! patches from SSE `grant:upsert` / `grant:delete` events and notifies 6 + //! subscribers with a typed snapshot. 7 + //! 8 + //! ## Cold-start 9 + //! 10 + //! The keeper is constructed empty with `loaded == false`. The first 11 + //! full-list fetch (see `listInbox` in the WASM layer) calls 12 + //! [`InboxKeeper::bootstrap`], which replaces the entry set and flips 13 + //! `loaded = true`. Thereafter, individual `SseEvent::GrantUpsert` / 14 + //! `SseEvent::GrantDelete` events apply incrementally. 15 + //! 16 + //! ## Why no crypto 17 + //! 18 + //! Unlike the workspace keeper, inbox entries are already-resolved 19 + //! appview records — a `grant:upsert` event carries the URI, owner, 20 + //! and document URI in plaintext. Metadata decryption still requires a 21 + //! cross-PDS fetch via [`Opake::resolve_grant_metadata`], but that's 22 + //! the consumer's job, not the keeper's. 23 + //! 24 + //! [`WorkspaceKeeper`]: crate::workspace_keeper::WorkspaceKeeper 25 + //! [`Opake::resolve_grant_metadata`]: crate::opake::Opake::resolve_grant_metadata 26 + 27 + use std::collections::HashMap; 28 + 29 + use serde::{Deserialize, Serialize}; 30 + 31 + // --------------------------------------------------------------------------- 32 + // Types 33 + // --------------------------------------------------------------------------- 34 + 35 + /// Opaque handle returned by [`InboxKeeper::install_watcher`]. Pass to 36 + /// [`InboxKeeper::unwatch`] to stop receiving notifications. 37 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 38 + pub struct InboxWatcherHandle(u64); 39 + 40 + /// An incoming grant as seen by the recipient — mirrors the appview's 41 + /// `InboxGrant` DTO but lives in this crate so the keeper stays 42 + /// self-contained. 43 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 44 + #[serde(rename_all = "snake_case")] 45 + pub struct InboxEntry { 46 + pub uri: String, 47 + pub owner_did: String, 48 + pub document_uri: String, 49 + pub created_at: String, 50 + } 51 + 52 + /// A snapshot of the full inbox at one moment in time. 53 + #[derive(Debug, Clone, Serialize, Deserialize)] 54 + #[serde(rename_all = "snake_case")] 55 + pub struct InboxSnapshot { 56 + pub entries: Vec<InboxEntry>, 57 + /// `true` once the keeper has been bootstrapped at least once. The 58 + /// initial watcher snapshot fires with `loaded == false` and an 59 + /// empty `entries` list so the UI can show a loading state. 60 + pub loaded: bool, 61 + } 62 + 63 + /// Callback fired whenever the inbox list changes. Must not re-enter 64 + /// the keeper. 65 + pub type InboxWatcherCallback = Box<dyn FnMut(&InboxSnapshot)>; 66 + 67 + // --------------------------------------------------------------------------- 68 + // InboxKeeper 69 + // --------------------------------------------------------------------------- 70 + 71 + /// Owns the inbox list and routes SSE grant events to it. 72 + pub struct InboxKeeper { 73 + entries: HashMap<String, InboxEntry>, 74 + watchers: HashMap<InboxWatcherHandle, InboxWatcherCallback>, 75 + next_watcher_id: u64, 76 + loaded: bool, 77 + } 78 + 79 + impl InboxKeeper { 80 + pub fn new() -> Self { 81 + Self { 82 + entries: HashMap::new(), 83 + watchers: HashMap::new(), 84 + next_watcher_id: 0, 85 + loaded: false, 86 + } 87 + } 88 + 89 + /// `true` once [`bootstrap`] has been called at least once. 90 + /// 91 + /// [`bootstrap`]: Self::bootstrap 92 + pub fn is_loaded(&self) -> bool { 93 + self.loaded 94 + } 95 + 96 + pub fn entry_count(&self) -> usize { 97 + self.entries.len() 98 + } 99 + 100 + pub fn watcher_count(&self) -> usize { 101 + self.watchers.len() 102 + } 103 + 104 + pub fn get(&self, uri: &str) -> Option<&InboxEntry> { 105 + self.entries.get(uri) 106 + } 107 + 108 + /// Build a fresh snapshot. Entries are sorted by URI for stable 109 + /// iteration order. 110 + pub fn snapshot(&self) -> InboxSnapshot { 111 + let mut entries: Vec<InboxEntry> = self.entries.values().cloned().collect(); 112 + entries.sort_by(|a, b| a.uri.cmp(&b.uri)); 113 + InboxSnapshot { 114 + entries, 115 + loaded: self.loaded, 116 + } 117 + } 118 + 119 + // -- Mutation API -- 120 + 121 + /// Replace the entire entry set. Called after a full-list fetch 122 + /// from the appview. 123 + pub fn bootstrap(&mut self, entries: Vec<InboxEntry>) { 124 + self.entries = entries.into_iter().map(|e| (e.uri.clone(), e)).collect(); 125 + self.loaded = true; 126 + self.notify(); 127 + } 128 + 129 + /// Insert or replace one entry. No-op if the new entry deep-equals 130 + /// the existing one — handles idempotent SSE echoes gracefully. 131 + pub fn upsert(&mut self, entry: InboxEntry) { 132 + let uri = entry.uri.clone(); 133 + if let Some(existing) = self.entries.get(&uri) { 134 + if existing == &entry { 135 + return; 136 + } 137 + } 138 + self.entries.insert(uri, entry); 139 + self.notify(); 140 + } 141 + 142 + /// Remove an entry by URI. No-op (no watcher fire) if it wasn't 143 + /// tracked. 144 + pub fn delete(&mut self, uri: &str) { 145 + if self.entries.remove(uri).is_some() { 146 + self.notify(); 147 + } 148 + } 149 + 150 + // -- Watcher API -- 151 + 152 + /// Install a watcher. Fires once immediately with the current 153 + /// snapshot (matching the `watchDirectory` / `watchWorkspaces` 154 + /// contract), and again on every subsequent change. 155 + pub fn install_watcher(&mut self, mut callback: InboxWatcherCallback) -> InboxWatcherHandle { 156 + let handle = InboxWatcherHandle(self.next_watcher_id); 157 + self.next_watcher_id += 1; 158 + 159 + let snap = self.snapshot(); 160 + callback(&snap); 161 + 162 + self.watchers.insert(handle, callback); 163 + handle 164 + } 165 + 166 + pub fn unwatch(&mut self, handle: InboxWatcherHandle) { 167 + self.watchers.remove(&handle); 168 + } 169 + 170 + /// Drop every entry, clear every watcher, and reset `loaded`. 171 + /// 172 + /// Called on `stopSseConsumer` so account switches don't leak the 173 + /// previous user's inbox into the next session's UI. 174 + pub fn uninstall_all(&mut self) { 175 + self.entries.clear(); 176 + self.watchers.clear(); 177 + self.loaded = false; 178 + } 179 + 180 + fn notify(&mut self) { 181 + let snap = self.snapshot(); 182 + for callback in self.watchers.values_mut() { 183 + callback(&snap); 184 + } 185 + } 186 + } 187 + 188 + impl Default for InboxKeeper { 189 + fn default() -> Self { 190 + Self::new() 191 + } 192 + } 193 + 194 + // --------------------------------------------------------------------------- 195 + // Entry builder 196 + // --------------------------------------------------------------------------- 197 + 198 + /// Construct an [`InboxEntry`] from an SSE grant record event. 199 + /// 200 + /// `recipient_did`: the caller's DID — we use it to filter out events 201 + /// where the caller is NOT the recipient (the broadcaster already 202 + /// routes by DID topic, but defense-in-depth is cheap here). 203 + pub fn try_build_entry_from_sse_record( 204 + record: &crate::sse::events::SseGrantRecord, 205 + recipient_did: &str, 206 + ) -> Option<InboxEntry> { 207 + // If the grant event carries an explicit recipient, verify it matches. 208 + // When absent (older payloads), trust the broadcaster's topic routing. 209 + if let Some(ref r) = record.recipient_did { 210 + if r != recipient_did { 211 + return None; 212 + } 213 + } 214 + 215 + Some(InboxEntry { 216 + uri: record.uri.clone(), 217 + owner_did: record.owner_did.clone(), 218 + document_uri: record.document_uri.clone(), 219 + created_at: record.created_at.clone().unwrap_or_default(), 220 + }) 221 + } 222 + 223 + /// Convenience wrapper: build an entry from an appview [`InboxGrant`]. 224 + /// 225 + /// [`InboxGrant`]: crate::client::InboxGrant 226 + pub fn entry_from_appview_grant(grant: &crate::client::InboxGrant) -> InboxEntry { 227 + InboxEntry { 228 + uri: grant.uri.clone(), 229 + owner_did: grant.owner_did.clone(), 230 + document_uri: grant.document_uri.clone(), 231 + created_at: grant.created_at.clone(), 232 + } 233 + } 234 + 235 + // --------------------------------------------------------------------------- 236 + // Tests 237 + // --------------------------------------------------------------------------- 238 + 239 + #[cfg(test)] 240 + #[path = "tests.rs"] 241 + mod tests;
+178
crates/opake-core/src/inbox_keeper/tests.rs
··· 1 + // InboxKeeper unit tests. 2 + 3 + use std::cell::RefCell; 4 + use std::rc::Rc; 5 + 6 + use super::*; 7 + 8 + fn sample_entry(uri: &str, owner: &str) -> InboxEntry { 9 + InboxEntry { 10 + uri: uri.to_string(), 11 + owner_did: owner.to_string(), 12 + document_uri: format!("at://{owner}/app.opake.document/doc1"), 13 + created_at: "2026-04-17T00:00:00Z".to_string(), 14 + } 15 + } 16 + 17 + fn capture_snapshots() -> (Rc<RefCell<Vec<InboxSnapshot>>>, InboxWatcherCallback) { 18 + let captured: Rc<RefCell<Vec<InboxSnapshot>>> = Rc::new(RefCell::new(Vec::new())); 19 + let captured_clone = Rc::clone(&captured); 20 + let callback: InboxWatcherCallback = 21 + Box::new(move |snap: &InboxSnapshot| captured_clone.borrow_mut().push(snap.clone())); 22 + (captured, callback) 23 + } 24 + 25 + #[test] 26 + fn new_keeper_is_empty_and_unloaded() { 27 + let keeper = InboxKeeper::new(); 28 + assert!(!keeper.is_loaded()); 29 + assert_eq!(keeper.entry_count(), 0); 30 + assert_eq!(keeper.watcher_count(), 0); 31 + } 32 + 33 + #[test] 34 + fn install_watcher_fires_initial_snapshot() { 35 + let mut keeper = InboxKeeper::new(); 36 + let (captured, callback) = capture_snapshots(); 37 + let _h = keeper.install_watcher(callback); 38 + 39 + let snaps = captured.borrow(); 40 + assert_eq!(snaps.len(), 1); 41 + assert!(!snaps[0].loaded); 42 + assert!(snaps[0].entries.is_empty()); 43 + } 44 + 45 + #[test] 46 + fn bootstrap_sets_loaded_and_notifies_watcher() { 47 + let mut keeper = InboxKeeper::new(); 48 + let (captured, callback) = capture_snapshots(); 49 + keeper.install_watcher(callback); 50 + 51 + keeper.bootstrap(vec![ 52 + sample_entry("at://a/app.opake.grant/g1", "did:plc:alice"), 53 + sample_entry("at://a/app.opake.grant/g2", "did:plc:alice"), 54 + ]); 55 + 56 + let snaps = captured.borrow(); 57 + assert_eq!(snaps.len(), 2); 58 + assert!(snaps[1].loaded); 59 + assert_eq!(snaps[1].entries.len(), 2); 60 + } 61 + 62 + #[test] 63 + fn upsert_with_identical_entry_does_not_refire() { 64 + let mut keeper = InboxKeeper::new(); 65 + let entry = sample_entry("at://a/app.opake.grant/g1", "did:plc:alice"); 66 + keeper.bootstrap(vec![entry.clone()]); 67 + 68 + let (captured, callback) = capture_snapshots(); 69 + keeper.install_watcher(callback); 70 + 71 + // Idempotent upsert — same fields, no change. 72 + keeper.upsert(entry.clone()); 73 + 74 + let snaps = captured.borrow(); 75 + assert_eq!(snaps.len(), 1, "no refire on identical upsert"); 76 + } 77 + 78 + #[test] 79 + fn upsert_refires_on_change() { 80 + let mut keeper = InboxKeeper::new(); 81 + keeper.bootstrap(vec![sample_entry( 82 + "at://a/app.opake.grant/g1", 83 + "did:plc:alice", 84 + )]); 85 + 86 + let (captured, callback) = capture_snapshots(); 87 + keeper.install_watcher(callback); 88 + 89 + let mut updated = sample_entry("at://a/app.opake.grant/g1", "did:plc:alice"); 90 + updated.created_at = "2026-04-18T00:00:00Z".to_string(); 91 + keeper.upsert(updated); 92 + 93 + let snaps = captured.borrow(); 94 + assert_eq!(snaps.len(), 2, "install fire + update fire"); 95 + } 96 + 97 + #[test] 98 + fn delete_removes_and_fires() { 99 + let mut keeper = InboxKeeper::new(); 100 + keeper.bootstrap(vec![sample_entry( 101 + "at://a/app.opake.grant/g1", 102 + "did:plc:alice", 103 + )]); 104 + 105 + let (captured, callback) = capture_snapshots(); 106 + keeper.install_watcher(callback); 107 + 108 + keeper.delete("at://a/app.opake.grant/g1"); 109 + 110 + let snaps = captured.borrow(); 111 + assert_eq!(snaps.len(), 2); 112 + assert!(snaps[1].entries.is_empty()); 113 + } 114 + 115 + #[test] 116 + fn delete_of_unknown_uri_does_not_fire() { 117 + let mut keeper = InboxKeeper::new(); 118 + keeper.bootstrap(vec![sample_entry( 119 + "at://a/app.opake.grant/g1", 120 + "did:plc:alice", 121 + )]); 122 + 123 + let (captured, callback) = capture_snapshots(); 124 + keeper.install_watcher(callback); 125 + 126 + keeper.delete("at://a/app.opake.grant/g-unknown"); 127 + 128 + let snaps = captured.borrow(); 129 + assert_eq!(snaps.len(), 1, "no fire on no-op delete"); 130 + } 131 + 132 + #[test] 133 + fn uninstall_all_drains_and_resets() { 134 + let mut keeper = InboxKeeper::new(); 135 + keeper.bootstrap(vec![sample_entry( 136 + "at://a/app.opake.grant/g1", 137 + "did:plc:alice", 138 + )]); 139 + 140 + let (_captured, callback) = capture_snapshots(); 141 + keeper.install_watcher(callback); 142 + 143 + keeper.uninstall_all(); 144 + 145 + assert!(!keeper.is_loaded()); 146 + assert_eq!(keeper.entry_count(), 0); 147 + assert_eq!(keeper.watcher_count(), 0); 148 + } 149 + 150 + #[test] 151 + fn try_build_entry_filters_non_recipient() { 152 + let record = crate::sse::events::SseGrantRecord { 153 + uri: "at://a/app.opake.grant/g1".to_string(), 154 + owner_did: "did:plc:alice".to_string(), 155 + recipient_did: Some("did:plc:bob".to_string()), 156 + document_uri: "at://a/app.opake.document/d1".to_string(), 157 + created_at: Some("2026-04-17T00:00:00Z".to_string()), 158 + }; 159 + 160 + // Caller is carol — shouldn't see bob's grant. 161 + assert!(try_build_entry_from_sse_record(&record, "did:plc:carol").is_none()); 162 + // Caller is bob — should see it. 163 + assert!(try_build_entry_from_sse_record(&record, "did:plc:bob").is_some()); 164 + } 165 + 166 + #[test] 167 + fn try_build_entry_defaults_created_at_when_absent() { 168 + let record = crate::sse::events::SseGrantRecord { 169 + uri: "at://a/app.opake.grant/g1".to_string(), 170 + owner_did: "did:plc:alice".to_string(), 171 + recipient_did: None, 172 + document_uri: "at://a/app.opake.document/d1".to_string(), 173 + created_at: None, 174 + }; 175 + 176 + let entry = try_build_entry_from_sse_record(&record, "did:plc:bob").unwrap(); 177 + assert_eq!(entry.created_at, ""); 178 + }
+1
crates/opake-core/src/lib.rs
··· 29 29 pub mod directories; 30 30 pub mod documents; 31 31 pub mod error; 32 + pub mod inbox_keeper; 32 33 pub mod keyrings; 33 34 pub mod manager; 34 35 pub mod metadata;
+43
crates/opake-core/src/manager/sharing.rs
··· 67 67 pub async fn list_shares(&mut self) -> Result<Vec<GrantEntry>, Error> { 68 68 sharing::list_grants(&mut self.opake.client).await 69 69 } 70 + 71 + /// Enqueue a pending share for a recipient who hasn't set up Opake yet. 72 + /// 73 + /// Cabinet only. Fetches the document's content key and writes a 74 + /// `pendingShare` record encrypted with the grant metadata the daemon 75 + /// needs to reconstruct the grant once the recipient publishes their 76 + /// public key. 77 + #[::opake_derive::signoff] 78 + pub async fn create_pending_share( 79 + &mut self, 80 + document_uri: &str, 81 + recipient: &str, 82 + permissions: &str, 83 + note: Option<&str>, 84 + ) -> Result<String, Error> { 85 + let FileContext::Cabinet(ref cabinet) = self.context else { 86 + return Err(Error::InvalidRecord( 87 + "pending shares are only supported from the cabinet".into(), 88 + )); 89 + }; 90 + 91 + let now = self.opake.now(); 92 + 93 + let content_key = documents::fetch_content_key( 94 + &mut self.opake.client, 95 + &cabinet.did, 96 + &cabinet.private_key, 97 + document_uri, 98 + ) 99 + .await?; 100 + 101 + sharing::create_pending_share( 102 + &mut self.opake.client, 103 + &content_key, 104 + document_uri, 105 + recipient, 106 + permissions, 107 + note, 108 + &now, 109 + &mut self.opake.rng, 110 + ) 111 + .await 112 + } 70 113 }
+16 -4
crates/opake-core/src/resolve.rs
··· 134 134 .find_map(|alias| alias.strip_prefix("at://")) 135 135 .map(|h| h.to_string()); 136 136 137 - // Step 5: Fetch public key record 137 + // Step 5: Fetch public key record. 138 + // A NotFound here means the DID is valid but hasn't published an Opake 139 + // key yet — that's a different situation from the DID/handle not existing 140 + // (steps 1–2). Surface it as RecipientNotReady so callers can offer a 141 + // pending-share queue without silently queuing shares for typo'd handles. 138 142 trace!("fetching public key from {}", pds_url); 139 143 let entry = get_record_public( 140 144 transport, ··· 143 147 PUBLIC_KEY_COLLECTION, 144 148 PUBLIC_KEY_RKEY, 145 149 ) 146 - .await?; 150 + .await 151 + .map_err(|e| match e { 152 + Error::NotFound(_) => { 153 + Error::RecipientNotReady(format!("{did} has not published an Opake public key yet")) 154 + } 155 + other => other, 156 + })?; 147 157 148 158 let record: PublicKeyRecord = serde_json::from_value(entry.value)?; 149 159 records::check_version(record.opake_version)?; ··· 343 353 } 344 354 345 355 #[tokio::test] 346 - async fn no_public_key_record_returns_not_found() { 356 + async fn no_public_key_record_returns_recipient_not_ready() { 357 + // A valid DID with no publicKey/self record is a distinct case from a 358 + // missing handle — the user exists but hasn't set up Opake yet. 347 359 let mock = MockTransport::new(); 348 360 mock.enqueue(success(&did_document_json( 349 361 "did:plc:nopubkey", ··· 359 371 let err = resolve_identity(&mock, "https://pds.caller", "did:plc:nopubkey") 360 372 .await 361 373 .unwrap_err(); 362 - assert!(matches!(err, Error::NotFound(_))); 374 + assert!(matches!(err, Error::RecipientNotReady(_))); 363 375 } 364 376 365 377 #[tokio::test]
+2 -2
crates/opake-core/src/sharing/mod.rs
··· 14 14 pub use heal::{heal_stale_grants, HealResult}; 15 15 pub use list::{list_grants, GrantEntry}; 16 16 pub use pending::{ 17 - cancel_pending_share, list_pending_shares, retry_pending_shares, PendingShareEntry, 18 - RetryParams, RetryResult, DEFAULT_PENDING_SHARE_TTL_SECONDS, 17 + cancel_pending_share, create_pending_share, list_pending_shares, retry_pending_shares, 18 + PendingShareEntry, RetryParams, RetryResult, DEFAULT_PENDING_SHARE_TTL_SECONDS, 19 19 }; 20 20 pub use revoke::revoke_grant; 21 21
+41 -3
crates/opake-core/src/sharing/pending.rs
··· 19 19 20 20 use super::create::{create_grant, GrantParams}; 21 21 22 + /// Enqueue a pending share for a recipient that hasn't set up Opake yet. 23 + /// 24 + /// Encrypts the original grant metadata (permissions + note) under the 25 + /// document's content key so the daemon can reconstruct the full grant 26 + /// when the recipient publishes their public key. Returns the AT-URI of 27 + /// the created pendingShare record. 28 + #[allow(clippy::too_many_arguments)] 29 + pub async fn create_pending_share( 30 + client: &mut XrpcClient<impl Transport>, 31 + content_key: &ContentKey, 32 + document_uri: &str, 33 + recipient: &str, 34 + permissions: &str, 35 + note: Option<&str>, 36 + now: &str, 37 + rng: &mut (impl CryptoRng + RngCore), 38 + ) -> Result<String, Error> { 39 + let metadata = GrantMetadata { 40 + permissions: Some(permissions.to_string()), 41 + note: note.map(str::to_string), 42 + }; 43 + let encrypted_metadata = crypto::encrypt_metadata(content_key, &metadata, rng)?; 44 + let record = PendingShare::new( 45 + document_uri.to_string(), 46 + recipient.to_string(), 47 + encrypted_metadata, 48 + now.to_string(), 49 + ); 50 + let record_ref = client 51 + .create_record(PENDING_SHARE_COLLECTION, &record) 52 + .await?; 53 + Ok(record_ref.uri) 54 + } 55 + 22 56 /// Default TTL for pending shares: 7 days. 23 57 pub const DEFAULT_PENDING_SHARE_TTL_SECONDS: i64 = 7 * 24 * 3600; 24 58 ··· 160 194 identity_cache.insert(entry.recipient.clone(), Some(id.clone())); 161 195 id 162 196 } 163 - Err(Error::NotFound(_)) => { 197 + // Recipient exists but hasn't published their Opake key yet. 198 + // This is exactly the condition that triggered the pending share — 199 + // keep it queued so the next retry can try again. 200 + Err(Error::NotFound(_)) | Err(Error::RecipientNotReady(_)) => { 164 201 identity_cache.insert(entry.recipient.clone(), None); 165 202 result.still_pending += 1; 166 203 continue; ··· 170 207 "pending share {}: can't resolve {}: {e}", 171 208 entry.uri, entry.recipient 172 209 ); 173 - // Don't cache transient errors 210 + // Transient errors (network, 5xx, etc.) are not cached so the 211 + // next retry will attempt resolution again. 174 212 result.failed += 1; 175 213 continue; 176 214 } ··· 197 235 .await 198 236 { 199 237 Ok(key) => { 200 - content_key_cache.insert(entry.document.clone(), ContentKey(key.0)); 238 + content_key_cache.insert(entry.document.clone(), key.clone()); 201 239 key 202 240 } 203 241 Err(e) => {
+333 -3
crates/opake-core/src/sharing/pending_tests.rs
··· 1 - // Tests for pending share retry logic will be added with MockTransport 2 - // coverage for: expiry, successful completion, recipient still missing, 3 - // transient errors. The e2e tests cover the full flow against fake-pds. 1 + // Unit tests for the pending share state machine. 2 + // 3 + // The retry logic has several independently testable paths: 4 + // • TTL expiry — expired entries are deleted, never resolved 5 + // • Still pending — RecipientNotReady/NotFound → kept in queue 6 + // • Transient failure — network/5xx → failed counter, NOT cached (retried next pass) 7 + // • Identity cache — same recipient in one pass is only resolved once 8 + // 9 + // create_pending_share is tested separately at the record-creation level. 10 + // 11 + // The completion (full grant creation) path exercises real crypto and is 12 + // covered by the e2e tests against fake-pds. 13 + 14 + // This file is compiled as `sharing::pending::tests` — `super` is `sharing::pending`. 15 + use super::{ 16 + create_pending_share, retry_pending_shares, RetryParams, RetryResult, 17 + DEFAULT_PENDING_SHARE_TTL_SECONDS, 18 + }; 19 + use crate::client::{HttpResponse, LegacySession, Session, XrpcClient}; 20 + use crate::crypto::{generate_content_key, OsRng}; 21 + use crate::records::{PendingShare, PENDING_SHARE_COLLECTION}; 22 + use crate::test_utils::{dummy_encrypted_metadata, MockTransport}; 23 + 24 + const OWNER_DID: &str = "did:plc:owner"; 25 + const RECIPIENT_DID: &str = "did:plc:recipient"; 26 + const DOC_URI: &str = "at://did:plc:owner/app.opake.document/doc1"; 27 + const PENDING_URI: &str = "at://did:plc:owner/app.opake.pendingShare/tid001"; 28 + 29 + fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 30 + let session = Session::Legacy(LegacySession { 31 + did: OWNER_DID.into(), 32 + handle: "owner.test".into(), 33 + access_jwt: "test-jwt".into(), 34 + refresh_jwt: "test-refresh".into(), 35 + }); 36 + XrpcClient::with_session(mock, "https://pds.test".into(), session) 37 + } 38 + 39 + fn ok(body: impl Into<Vec<u8>>) -> HttpResponse { 40 + HttpResponse { 41 + status: 200, 42 + headers: vec![], 43 + body: body.into(), 44 + } 45 + } 46 + 47 + fn not_found() -> HttpResponse { 48 + HttpResponse { 49 + status: 404, 50 + headers: vec![], 51 + body: br#"{"error":"RecordNotFound","message":"no such record"}"#.to_vec(), 52 + } 53 + } 54 + 55 + fn server_error() -> HttpResponse { 56 + HttpResponse { 57 + status: 503, 58 + headers: vec![], 59 + body: br#"{"error":"ServiceUnavailable","message":"try later"}"#.to_vec(), 60 + } 61 + } 62 + 63 + fn delete_ok() -> HttpResponse { 64 + ok(b"{}".to_vec()) 65 + } 66 + 67 + fn create_record_ok(uri: &str) -> HttpResponse { 68 + ok(serde_json::json!({"uri": uri, "cid": "bafynew"}) 69 + .to_string() 70 + .into_bytes()) 71 + } 72 + 73 + fn list_pending_shares_response(entries: &[(&str, PendingShare)]) -> HttpResponse { 74 + let records: Vec<serde_json::Value> = entries 75 + .iter() 76 + .map(|(uri, share)| { 77 + serde_json::json!({ 78 + "uri": uri, 79 + "cid": "bafypending", 80 + "value": share, 81 + }) 82 + }) 83 + .collect(); 84 + ok(serde_json::json!({"records": records}) 85 + .to_string() 86 + .into_bytes()) 87 + } 88 + 89 + fn did_document_json(did: &str, pds_url: &str) -> Vec<u8> { 90 + serde_json::json!({ 91 + "id": did, 92 + "alsoKnownAs": [format!("at://recipient.test")], 93 + "service": [{ 94 + "id": "#atproto_pds", 95 + "type": "AtprotoPersonalDataServer", 96 + "serviceEndpoint": pds_url, 97 + }] 98 + }) 99 + .to_string() 100 + .into_bytes() 101 + } 102 + 103 + fn pending_share(recipient: &str, created_at: &str) -> PendingShare { 104 + PendingShare::new( 105 + DOC_URI.to_string(), 106 + recipient.to_string(), 107 + dummy_encrypted_metadata(), 108 + created_at.to_string(), 109 + ) 110 + } 111 + 112 + // A zero private key — never reaches crypto in these tests (we never 113 + // get past the resolve step or TTL check to fetch_content_key). 114 + static DUMMY_PRIVATE_KEY: crate::crypto::X25519PrivateKey = [0u8; 32]; 115 + 116 + fn base_retry_params(now: i64) -> RetryParams<'static> { 117 + RetryParams { 118 + caller_pds_url: "https://pds.test", 119 + owner_did: OWNER_DID, 120 + owner_private_key: &DUMMY_PRIVATE_KEY, 121 + now, 122 + ttl_seconds: DEFAULT_PENDING_SHARE_TTL_SECONDS, 123 + } 124 + } 125 + 126 + // --------------------------------------------------------------------------- 127 + // create_pending_share 128 + // --------------------------------------------------------------------------- 129 + 130 + #[tokio::test] 131 + async fn create_pending_share_creates_record_and_returns_uri() { 132 + let mock = MockTransport::new(); 133 + mock.enqueue(create_record_ok(PENDING_URI)); 134 + 135 + let mut client = mock_client(mock.clone()); 136 + let content_key = generate_content_key(&mut OsRng); 137 + 138 + let uri = create_pending_share( 139 + &mut client, 140 + &content_key, 141 + DOC_URI, 142 + "alice.bsky.social", 143 + "read", 144 + Some("here you go"), 145 + "2026-04-01T00:00:00Z", 146 + &mut OsRng, 147 + ) 148 + .await 149 + .unwrap(); 150 + 151 + assert_eq!(uri, PENDING_URI); 152 + 153 + let reqs = mock.requests(); 154 + assert_eq!(reqs.len(), 1); 155 + assert!(reqs[0].url.contains("createRecord")); 156 + 157 + // Verify the right collection is used and fields are present. 158 + if let Some(crate::client::RequestBody::Json(v)) = &reqs[0].body { 159 + assert_eq!(v["collection"], PENDING_SHARE_COLLECTION); 160 + let record = &v["record"]; 161 + assert_eq!(record["recipient"], "alice.bsky.social"); 162 + assert_eq!(record["document"], DOC_URI); 163 + assert!(record["encryptedMetadata"]["ciphertext"]["$bytes"].is_string()); 164 + } else { 165 + panic!("expected JSON body"); 166 + } 167 + } 168 + 169 + // --------------------------------------------------------------------------- 170 + // retry_pending_shares — TTL expiry 171 + // --------------------------------------------------------------------------- 172 + 173 + #[tokio::test] 174 + async fn retry_expired_entry_deletes_record_and_counts_expired() { 175 + let mock = MockTransport::new(); 176 + 177 + let very_old_share = pending_share(RECIPIENT_DID, "2020-01-01T00:00:00Z"); 178 + mock.enqueue(list_pending_shares_response(&[( 179 + PENDING_URI, 180 + very_old_share, 181 + )])); 182 + mock.enqueue(delete_ok()); 183 + 184 + let mut client = mock_client(mock.clone()); 185 + // now = 2026-04-01, share was 2020-01-01 → well past 7-day TTL 186 + let now = 1_743_465_600i64; // 2026-04-01 00:00:00 UTC 187 + let result = retry_pending_shares(&mut client, &mock, &base_retry_params(now), &mut OsRng) 188 + .await 189 + .unwrap(); 190 + 191 + assert_eq!(result.checked, 1); 192 + assert_eq!(result.expired, 1); 193 + assert_eq!(result.still_pending, 0); 194 + assert_eq!(result.completed, 0); 195 + assert_eq!(result.failed, 0); 196 + 197 + let reqs = mock.requests(); 198 + assert_eq!(reqs.len(), 2); 199 + assert!(reqs[0].url.contains("listRecords")); 200 + assert!(reqs[1].url.contains("deleteRecord")); 201 + } 202 + 203 + #[tokio::test] 204 + async fn retry_non_expired_entry_is_not_deleted_without_resolution() { 205 + let mock = MockTransport::new(); 206 + 207 + // created 1 hour ago — not expired 208 + let fresh_share = pending_share(RECIPIENT_DID, "2026-04-01T00:00:00Z"); 209 + mock.enqueue(list_pending_shares_response(&[(PENDING_URI, fresh_share)])); 210 + 211 + // Resolution attempt: DID doc → public key → RecipientNotReady 212 + mock.enqueue(ok(did_document_json( 213 + RECIPIENT_DID, 214 + "https://pds.recipient.test", 215 + ))); 216 + mock.enqueue(not_found()); // publicKey/self is absent 217 + 218 + let mut client = mock_client(mock.clone()); 219 + let now = 1_743_469_200i64; // 2026-04-01 01:00:00 UTC (1 hour later) 220 + let result = retry_pending_shares(&mut client, &mock, &base_retry_params(now), &mut OsRng) 221 + .await 222 + .unwrap(); 223 + 224 + assert_eq!(result.checked, 1); 225 + assert_eq!(result.expired, 0); 226 + assert_eq!(result.still_pending, 1); 227 + assert_eq!(result.completed, 0); 228 + assert_eq!(result.failed, 0); 229 + } 230 + 231 + // --------------------------------------------------------------------------- 232 + // retry_pending_shares — still pending (RecipientNotReady) 233 + // --------------------------------------------------------------------------- 234 + 235 + #[tokio::test] 236 + async fn retry_recipient_not_ready_counts_as_still_pending() { 237 + // Before the RecipientNotReady fix, this was incorrectly counted as `failed`. 238 + // The recipient exists (DID resolves) but hasn't published publicKey/self. 239 + let mock = MockTransport::new(); 240 + 241 + let share = pending_share(RECIPIENT_DID, "2026-04-01T00:00:00Z"); 242 + mock.enqueue(list_pending_shares_response(&[(PENDING_URI, share)])); 243 + 244 + // DID doc resolves fine, public key record is 404 → RecipientNotReady 245 + mock.enqueue(ok(did_document_json( 246 + RECIPIENT_DID, 247 + "https://pds.recipient.test", 248 + ))); 249 + mock.enqueue(not_found()); 250 + 251 + let mut client = mock_client(mock.clone()); 252 + let now = 1_743_465_600i64; // well within TTL 253 + let result = retry_pending_shares(&mut client, &mock, &base_retry_params(now), &mut OsRng) 254 + .await 255 + .unwrap(); 256 + 257 + assert_eq!( 258 + result.still_pending, 1, 259 + "RecipientNotReady must be still_pending, not failed" 260 + ); 261 + assert_eq!(result.failed, 0); 262 + } 263 + 264 + // --------------------------------------------------------------------------- 265 + // retry_pending_shares — transient failure 266 + // --------------------------------------------------------------------------- 267 + 268 + #[tokio::test] 269 + async fn retry_transient_error_counts_as_failed_and_is_not_cached() { 270 + // When resolution fails with a transient error (5xx), the entry is counted 271 + // as failed. Critically, it must NOT be cached — a second pending share for 272 + // the same recipient in the same pass must also attempt resolution (and fail 273 + // separately), not short-circuit via the "still pending" cached path. 274 + let mock = MockTransport::new(); 275 + 276 + let share1 = pending_share(RECIPIENT_DID, "2026-04-01T00:00:00Z"); 277 + let share2 = pending_share(RECIPIENT_DID, "2026-04-01T00:00:00Z"); 278 + let uri2 = "at://did:plc:owner/app.opake.pendingShare/tid002"; 279 + 280 + mock.enqueue(list_pending_shares_response(&[ 281 + (PENDING_URI, share1), 282 + (uri2, share2), 283 + ])); 284 + 285 + // First resolution attempt → 503 286 + mock.enqueue(server_error()); 287 + // Second resolution attempt → 503 (no short-circuit from cache) 288 + mock.enqueue(server_error()); 289 + 290 + let mut client = mock_client(mock.clone()); 291 + let now = 1_743_465_600i64; 292 + let result = retry_pending_shares(&mut client, &mock, &base_retry_params(now), &mut OsRng) 293 + .await 294 + .unwrap(); 295 + 296 + assert_eq!(result.checked, 2); 297 + assert_eq!( 298 + result.failed, 2, 299 + "both entries must fail independently (no caching of transient errors)" 300 + ); 301 + assert_eq!(result.still_pending, 0); 302 + 303 + // Confirm two separate resolution attempts were made (not a cache hit on the second). 304 + let reqs = mock.requests(); 305 + // listRecords + 2 resolution attempts (each goes to .well-known or similar) 306 + assert!( 307 + reqs.len() >= 3, 308 + "expected listRecords + at least 2 resolution calls, got {}", 309 + reqs.len() 310 + ); 311 + } 312 + 313 + // --------------------------------------------------------------------------- 314 + // retry_pending_shares — empty queue 315 + // --------------------------------------------------------------------------- 316 + 317 + #[tokio::test] 318 + async fn retry_empty_queue_returns_zeroed_result() { 319 + let mock = MockTransport::new(); 320 + mock.enqueue(list_pending_shares_response(&[])); 321 + 322 + let mut client = mock_client(mock.clone()); 323 + let now = 1_743_465_600i64; 324 + let result = retry_pending_shares(&mut client, &mock, &base_retry_params(now), &mut OsRng) 325 + .await 326 + .unwrap(); 327 + 328 + assert_eq!( 329 + result, 330 + RetryResult::default(), 331 + "empty queue must return all-zero result" 332 + ); 333 + }
+15
crates/opake-wasm/src/file_manager_wasm.rs
··· 385 385 to_js(&shares) 386 386 } 387 387 388 + #[wasm_bindgen(js_name = createPendingShare)] 389 + pub async fn create_pending_share( 390 + &self, 391 + document_uri: &str, 392 + recipient: &str, 393 + permissions: &str, 394 + note: Option<String>, 395 + ) -> Result<String, JsError> { 396 + let (mut opake, ctx) = self.parts().await?; 397 + let mut mgr = opake.file_manager(ctx); 398 + mgr.create_pending_share(document_uri, recipient, permissions, note.as_deref()) 399 + .await 400 + .map_err(wasm_err) 401 + } 402 + 388 403 #[wasm_bindgen(js_name = deleteRecursive)] 389 404 pub async fn delete_recursive(&self, uri: &str) -> Result<JsValue, JsError> { 390 405 let (mut opake, ctx) = self.parts().await?;
+56
crates/opake-wasm/src/opake_wasm.rs
··· 23 23 use std::rc::Rc; 24 24 25 25 use futures_util::lock::Mutex; 26 + use opake_core::inbox_keeper::{self as ik, InboxKeeper}; 26 27 use opake_core::tree_keeper::TreeKeeper; 27 28 use opake_core::workspace_keeper::WorkspaceKeeper; 28 29 use serde::Serialize; ··· 52 53 /// `tree_keeper` so directory watcher fires and workspace list fires 53 54 /// don't block each other. 54 55 pub(crate) workspace_keeper: Rc<Mutex<WorkspaceKeeper>>, 56 + /// Live inbox state. Bootstrapped from `listInbox` and patched 57 + /// incrementally from SSE `grant:upsert` / `grant:delete` events. 58 + /// JS subscribes via `watchInbox`. 59 + pub(crate) inbox_keeper: Rc<Mutex<InboxKeeper>>, 55 60 /// `true` while an SSE consumer task is alive. Doubles as both the 56 61 /// idempotency gate on `startSseConsumer` and the cancellation 57 62 /// signal read by the consumer loop — `stopSseConsumer` clears it, ··· 76 81 inner: Rc::new(Mutex::new(Some(opake))), 77 82 tree_keeper: Rc::new(Mutex::new(TreeKeeper::new(did_owned))), 78 83 workspace_keeper: Rc::new(Mutex::new(WorkspaceKeeper::new())), 84 + inbox_keeper: Rc::new(Mutex::new(InboxKeeper::new())), 79 85 sse_started: Rc::new(std::cell::Cell::new(false)), 80 86 }) 81 87 } ··· 557 563 to_js(&result) 558 564 } 559 565 566 + /// List pending (queued) outgoing shares on the caller's PDS. 567 + #[wasm_bindgen(js_name = listPendingShares)] 568 + pub async fn list_pending_shares(&self) -> Result<JsValue, JsError> { 569 + let mut opake = self.opake().await?; 570 + let entries = opake.list_pending_shares().await.map_err(wasm_err)?; 571 + 572 + #[derive(Serialize)] 573 + struct Entry { 574 + uri: String, 575 + document: String, 576 + recipient: String, 577 + created_at: String, 578 + } 579 + 580 + let out: Vec<Entry> = entries 581 + .into_iter() 582 + .map(|e| Entry { 583 + uri: e.uri, 584 + document: e.document, 585 + recipient: e.recipient, 586 + created_at: e.created_at, 587 + }) 588 + .collect(); 589 + to_js(&out) 590 + } 591 + 592 + /// Cancel a pending share by AT-URI. 593 + #[wasm_bindgen(js_name = cancelPendingShare)] 594 + pub async fn cancel_pending_share(&self, uri: &str) -> Result<(), JsError> { 595 + let mut opake = self.opake().await?; 596 + opake.cancel_pending_share(uri).await.map_err(wasm_err) 597 + } 598 + 560 599 /// Retry all pending shares (resolve recipients, create grants). 561 600 #[wasm_bindgen(js_name = retryPendingSharesViaOpake)] 562 601 pub async fn retry_pending_shares_via_opake(&self) -> Result<JsValue, JsError> { ··· 692 731 } 693 732 694 733 /// Fetch all incoming grants from the AppView. 734 + /// 735 + /// Side effect: bootstraps the shared `InboxKeeper` with the result. 736 + /// Any `watchInbox` callers (current or future) receive a fresh 737 + /// snapshot with `loaded = true` as part of this call. Once 738 + /// bootstrapped, incremental SSE `grant:upsert` / `grant:delete` 739 + /// events keep the keeper in sync without further `listInbox` 740 + /// round-trips. 695 741 #[wasm_bindgen(js_name = listInbox)] 696 742 pub async fn list_inbox(&self, appview_url: Option<String>) -> Result<JsValue, JsError> { 697 743 let mut opake = self.opake().await?; ··· 699 745 .list_inbox(appview_url.as_deref()) 700 746 .await 701 747 .map_err(wasm_err)?; 748 + drop(opake); 749 + 750 + let entries: Vec<opake_core::inbox_keeper::InboxEntry> = 751 + grants.iter().map(ik::entry_from_appview_grant).collect(); 752 + 753 + { 754 + let mut keeper = self.inbox_keeper.lock().await; 755 + keeper.bootstrap(entries); 756 + } 757 + 702 758 to_js(&grants) 703 759 } 704 760
+120 -1
crates/opake-wasm/src/sse_wasm.rs
··· 23 23 use futures_util::lock::Mutex; 24 24 use opake_core::client::request_sse_token; 25 25 use opake_core::directories::DirectoryTree; 26 + use opake_core::inbox_keeper::{ 27 + self as ik, InboxKeeper, InboxSnapshot, InboxWatcherCallback, InboxWatcherHandle, 28 + }; 26 29 use opake_core::sse::consumer::{JitterRng, SleepFn, SseConsumer, TokenFetcher}; 27 30 use opake_core::sse::events::SseEvent; 28 31 use opake_core::sse::wasm_connection::WasmSseTransport; ··· 88 91 } 89 92 90 93 // --------------------------------------------------------------------------- 94 + // WasmInboxWatcher — returned by watchInbox, exposes close() 95 + // --------------------------------------------------------------------------- 96 + 97 + #[wasm_bindgen(js_name = InboxWatcher)] 98 + pub struct WasmInboxWatcher { 99 + inbox_keeper: Rc<Mutex<InboxKeeper>>, 100 + handle: InboxWatcherHandle, 101 + closed: Rc<std::cell::Cell<bool>>, 102 + } 103 + 104 + #[wasm_bindgen(js_class = InboxWatcher)] 105 + impl WasmInboxWatcher { 106 + /// Stop receiving notifications. Idempotent. 107 + pub async fn close(&self) { 108 + if self.closed.get() { 109 + return; 110 + } 111 + self.closed.set(true); 112 + let mut keeper = self.inbox_keeper.lock().await; 113 + keeper.unwatch(self.handle); 114 + } 115 + } 116 + 117 + // --------------------------------------------------------------------------- 91 118 // WasmOpakeHandle::watchWorkspaces 92 119 // --------------------------------------------------------------------------- 93 120 ··· 126 153 closed: Rc::new(std::cell::Cell::new(false)), 127 154 }) 128 155 } 156 + 157 + /// Subscribe to live changes in the inbox (incoming grants). 158 + /// 159 + /// Fires the callback once immediately with the current snapshot 160 + /// (which has `loaded = false` and empty entries if the keeper 161 + /// hasn't been bootstrapped yet), and again on every change. 162 + /// 163 + /// The keeper is populated by: 164 + /// - `listInbox()` (bootstrap — replaces the entry set) 165 + /// - SSE `grant:upsert` / `grant:delete` events (incremental) 166 + #[wasm_bindgen(js_name = watchInbox)] 167 + pub async fn watch_inbox( 168 + &self, 169 + callback: js_sys::Function, 170 + ) -> Result<WasmInboxWatcher, JsError> { 171 + let cb = js_inbox_watcher_callback(callback); 172 + let mut keeper = self.inbox_keeper.lock().await; 173 + let handle = keeper.install_watcher(cb); 174 + Ok(WasmInboxWatcher { 175 + inbox_keeper: Rc::clone(&self.inbox_keeper), 176 + handle, 177 + closed: Rc::new(std::cell::Cell::new(false)), 178 + }) 179 + } 129 180 } 130 181 131 182 // --------------------------------------------------------------------------- ··· 297 348 let opake_rc = Rc::clone(&self.inner); 298 349 let tree_keeper_rc = Rc::clone(&self.tree_keeper); 299 350 let workspace_keeper_rc = Rc::clone(&self.workspace_keeper); 351 + let inbox_keeper_rc = Rc::clone(&self.inbox_keeper); 300 352 let started_flag = Rc::clone(&self.sse_started); 301 353 302 354 let token_fetcher = make_token_fetcher(Rc::clone(&opake_rc), resolved_url.clone()); ··· 358 410 // trip. Idempotent upserts (same rotation + same data) 359 411 // don't re-fire watchers — see `WorkspaceKeeper::upsert`. 360 412 apply_keyring_to_workspace_keeper(&opake_rc, &workspace_keeper_rc, &event).await; 413 + 414 + // Grant events: apply to the inbox keeper so the 415 + // "Shared with me" view updates live. 416 + apply_grant_to_inbox_keeper(&opake_rc, &inbox_keeper_rc, &event).await; 361 417 } 362 418 // Task exited — clear the flag in case we broke on a 363 419 // transport error rather than an explicit stop, so a ··· 384 440 385 441 let tree_keeper = Rc::clone(&self.tree_keeper); 386 442 let workspace_keeper = Rc::clone(&self.workspace_keeper); 443 + let inbox_keeper = Rc::clone(&self.inbox_keeper); 387 444 wasm_bindgen_futures::spawn_local(async move { 388 445 let mut tk = tree_keeper.lock().await; 389 446 tk.uninstall_all(); 390 447 drop(tk); 391 448 let mut wk = workspace_keeper.lock().await; 392 449 wk.uninstall_all(); 393 - log::debug!("[sse] tree_keeper + workspace_keeper drained on stopSseConsumer"); 450 + drop(wk); 451 + let mut ik = inbox_keeper.lock().await; 452 + ik.uninstall_all(); 453 + log::debug!( 454 + "[sse] tree_keeper + workspace_keeper + inbox_keeper drained on stopSseConsumer" 455 + ); 394 456 }); 395 457 } 396 458 } ··· 580 642 } 581 643 _ => {} 582 644 } 645 + } 646 + 647 + /// Apply a grant record event to the `InboxKeeper`. 648 + /// 649 + /// `GrantUpsert`: build an entry filtered by the caller's DID. 650 + /// `GrantDelete`: delete by URI. Other events are no-ops. 651 + async fn apply_grant_to_inbox_keeper( 652 + opake_rc: &Rc<Mutex<Option<WasmOpake>>>, 653 + inbox_keeper_rc: &Rc<Mutex<InboxKeeper>>, 654 + event: &SseEvent, 655 + ) { 656 + match event { 657 + SseEvent::GrantUpsert(record) => { 658 + // Fetch the DID under the opake lock, then drop it before 659 + // acquiring the keeper lock (matches the workspace keeper 660 + // pattern — keeps the opake mutex free for SSE throughput). 661 + let my_did = { 662 + let guard = opake_rc.lock().await; 663 + let Some(opake) = guard.as_ref() else { 664 + log::warn!("[sse] inbox upsert: opake unavailable"); 665 + return; 666 + }; 667 + opake.did().to_string() 668 + }; 669 + let Some(entry) = ik::try_build_entry_from_sse_record(record, &my_did) else { 670 + // Not for us — silently drop. 671 + return; 672 + }; 673 + let mut keeper = inbox_keeper_rc.lock().await; 674 + keeper.upsert(entry); 675 + } 676 + SseEvent::GrantDelete(payload) => { 677 + if let Some(uri) = payload.best_uri() { 678 + let mut keeper = inbox_keeper_rc.lock().await; 679 + keeper.delete(uri); 680 + } 681 + } 682 + _ => {} 683 + } 684 + } 685 + 686 + /// Wrap a JS function as an [`InboxWatcherCallback`] that serializes 687 + /// the snapshot to a JS object on each call. 688 + fn js_inbox_watcher_callback(callback: js_sys::Function) -> InboxWatcherCallback { 689 + Box::new(move |snapshot: &InboxSnapshot| { 690 + let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); 691 + let snapshot_js = match snapshot.serialize(&serializer) { 692 + Ok(v) => v, 693 + Err(e) => { 694 + log::warn!("[sse] inbox snapshot serialize failed: {e}"); 695 + return; 696 + } 697 + }; 698 + if let Err(e) = callback.call1(&JsValue::NULL, &snapshot_js) { 699 + log::warn!("[sse] inbox watcher callback threw: {e:?}"); 700 + } 701 + }) 583 702 } 584 703 585 704 /// Wrap a JS function as a [`WorkspaceWatcherCallback`] that serializes
+1
crates/opake-wasm/src/wasm_util.rs
··· 69 69 Error::Xrpc { .. } => "Xrpc", 70 70 Error::Appview { .. } => "Appview", 71 71 Error::NotFound(_) => "NotFound", 72 + Error::RecipientNotReady(_) => "RecipientNotReady", 72 73 Error::AmbiguousName { .. } => "AmbiguousName", 73 74 Error::AlreadyExists(_) => "AlreadyExists", 74 75 Error::InvalidRecord(_) => "InvalidRecord",
+3
docs/CRATE_STRUCTURE.md
··· 23 23 workspace_keeper/ 24 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 25 tests.rs Unit tests 26 + inbox_keeper/ 27 + mod.rs InboxKeeper — in-memory incoming-share list state. Bootstrapped by `listInbox`, patched by `grant:upsert` / `grant:delete` SSE events (appview fans both to owner and recipient). `watchInbox` installs snapshot callbacks. Parallel design to WorkspaceKeeper; no crypto — entries are already-resolved appview records. 28 + tests.rs Unit tests 26 29 manager/ 27 30 mod.rs FileManager<'a, T, R, S> struct (borrows &mut Opake + &FileContext), create_record passthrough 28 31 types.rs UploadRequest, UploadResult, DownloadResult, MutationOutcome, FileContext
+27
docs/FLOWS.md
··· 52 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 53 54 54 See `WorkspaceKeeper` in `crates/opake-core/src/workspace_keeper/` and `apply_keyring_to_workspace_keeper` in `crates/opake-wasm/src/sse_wasm.rs`. 55 + 56 + ## Inbox live updates 57 + 58 + The inbox (incoming shares) is kept current without polling via the SSE consumer and `InboxKeeper`. 59 + 60 + **Cold start (bootstrap)** 61 + 62 + 1. `listInbox` calls the appview's `/api/inbox` endpoint → returns paginated `InboxGrant` records for the authenticated DID. 63 + 2. `InboxKeeper::bootstrap` replaces the entry set and flips `loaded = true`. All registered `watchInbox` callbacks receive an updated snapshot immediately. 64 + 65 + **Incremental updates (SSE)** 66 + 67 + SSE `grant:upsert` events route to `apply_grant_to_inbox_keeper`: 68 + 69 + 1. The appview broadcasts `grant:upsert` to the **recipient's** personal topic (in addition to the owner's). 70 + 2. `InboxKeeper::upsert` adds or updates the entry. Deduplication: if the new entry equals the existing one, no callbacks fire. 71 + 72 + SSE `grant:delete` events: 73 + 74 + 1. The appview fetches `owner_did` + `recipient_did` from the DB **before** deleting the row (the firehose delete payload carries only the URI). Both personal topics receive `grant:delete`. 75 + 2. `InboxKeeper::delete(uri)` removes the entry and fires callbacks. 76 + 77 + **Watcher teardown** 78 + 79 + `stopSseConsumer` → `InboxKeeper::uninstall_all` (clears entries, watchers, resets `loaded`). 80 + 81 + See `InboxKeeper` in `crates/opake-core/src/inbox_keeper/` and `apply_grant_to_inbox_keeper` in `crates/opake-wasm/src/sse_wasm.rs`.
+78
packages/opake-react/src/hooks/use-inbox.ts
··· 1 + "use client"; 2 + 3 + // useInbox — subscription-based inbox hook backed by `opake.watchInbox`. 4 + // 5 + // Mirrors `useWorkspaces`: 6 + // - Install a watcher on mount 7 + // - First fire delivers `{ entries: [], loaded: false }` (cold start) 8 + // - Bootstrap via `opake.listInbox()` exactly once across concurrent mounts 9 + // - Subsequent SSE `grant:upsert` / `grant:delete` events patch the keeper 10 + // - Watcher is closed on unmount 11 + 12 + import { useEffect, useState } from "react"; 13 + import type { InboxGrant, InboxSnapshot } from "@opake/sdk"; 14 + import { useOpake } from "../provider"; 15 + 16 + let bootstrapPromise: Promise<unknown> | null = null; 17 + 18 + interface UseInboxResult { 19 + /** The current inbox entries, or an empty array before bootstrap. */ 20 + readonly data: readonly InboxGrant[]; 21 + /** 22 + * `true` until the keeper has been bootstrapped at least once. After 23 + * that, `data` is authoritative even if it happens to be empty. 24 + */ 25 + readonly isLoading: boolean; 26 + /** The raw snapshot, if consumers need the `loaded` flag directly. */ 27 + readonly snapshot: InboxSnapshot | null; 28 + } 29 + 30 + /** 31 + * Subscribe to live updates of the current user's inbox (incoming shares). 32 + * 33 + * Requires an `OpakeProvider` ancestor and an active SSE consumer for 34 + * real-time updates (the provider starts one by default). 35 + * 36 + * @example 37 + * ```tsx 38 + * function SharedWithMe() { 39 + * const { data, isLoading } = useInbox(); 40 + * if (isLoading) return <Spinner />; 41 + * return <ul>{data.map((g) => <li key={g.uri}>{g.documentUri}</li>)}</ul>; 42 + * } 43 + * ``` 44 + */ 45 + export function useInbox(): UseInboxResult { 46 + const opake = useOpake(); 47 + const [snapshot, setSnapshot] = useState<InboxSnapshot | null>(null); 48 + 49 + useEffect(() => { 50 + let handledFirstFire = false; 51 + 52 + const watcher = opake.watchInbox((snap) => { 53 + setSnapshot(snap); 54 + 55 + if (!handledFirstFire) { 56 + handledFirstFire = true; 57 + if (!snap.loaded && !bootstrapPromise) { 58 + bootstrapPromise = opake 59 + .listInbox() 60 + .catch((err: unknown) => { 61 + console.warn("[opake-react] listInbox bootstrap failed:", err); 62 + }) 63 + .finally(() => { 64 + bootstrapPromise = null; 65 + }); 66 + } 67 + } 68 + }); 69 + 70 + return () => watcher.close(); 71 + }, [opake]); 72 + 73 + return { 74 + data: snapshot?.entries ?? [], 75 + isLoading: !snapshot?.loaded, 76 + snapshot, 77 + }; 78 + }
+34
packages/opake-react/src/hooks/use-pending-shares.ts
··· 1 + "use client"; 2 + 3 + // usePendingShares — list the caller's queued outgoing shares and cancel them. 4 + // Cabinet-only. Backed by the React Query cache since pending shares 5 + // don't flow through SSE (they're local-only until the daemon completes 6 + // or expires them). 7 + 8 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 9 + import type { PendingShareEntry } from "@opake/sdk"; 10 + import { useOpake } from "../provider"; 11 + import { opakeKeys } from "../keys"; 12 + 13 + /** List every pending share on the caller's PDS. */ 14 + export function usePendingShares() { 15 + const opake = useOpake(); 16 + 17 + return useQuery<readonly PendingShareEntry[]>({ 18 + queryKey: opakeKeys.pendingShares(), 19 + queryFn: () => opake.listPendingShares(), 20 + }); 21 + } 22 + 23 + /** Cancel a pending share by URI. Invalidates the pending-shares query on success. */ 24 + export function useCancelPendingShare() { 25 + const opake = useOpake(); 26 + const queryClient = useQueryClient(); 27 + 28 + return useMutation({ 29 + mutationFn: (uri: string) => opake.cancelPendingShare(uri), 30 + onSuccess: () => { 31 + void queryClient.invalidateQueries({ queryKey: opakeKeys.pendingShares() }); 32 + }, 33 + }); 34 + }
+95
packages/opake-react/src/hooks/use-share-mutations.ts
··· 1 + "use client"; 2 + 3 + // useShareFile / useRevokeShare — sharing mutations with cache 4 + // invalidation for the `useShares(documentUri)` query. 5 + 6 + import { useMutation, useQueryClient } from "@tanstack/react-query"; 7 + import { OpakeError } from "@opake/sdk"; 8 + import { useOpake } from "../provider"; 9 + import { opakeKeys } from "../keys"; 10 + 11 + interface ShareFileInput { 12 + readonly documentUri: string; 13 + readonly handleOrDid: string; 14 + readonly note?: string; 15 + } 16 + 17 + /** 18 + * Share a file with another user by handle or DID. 19 + * 20 + * Resolves the recipient's identity, then: 21 + * - Recipient has a public key → creates a grant (immediate access). 22 + * - Recipient has no identity record → queues a pending share (the 23 + * daemon completes it when the recipient publishes their key). 24 + * 25 + * Invalidates `useShares(documentUri)` on success so the caller's 26 + * share list refreshes immediately. 27 + */ 28 + export function useShareFile() { 29 + const opake = useOpake(); 30 + const queryClient = useQueryClient(); 31 + 32 + return useMutation({ 33 + mutationFn: async (input: ShareFileInput) => { 34 + const fm = await opake.cabinet(); 35 + try { 36 + try { 37 + const recipient = await opake.resolveIdentity(input.handleOrDid); 38 + await fm.share( 39 + input.documentUri, 40 + recipient.did, 41 + recipient.publicKey, 42 + "read", 43 + input.note, 44 + ); 45 + return { pending: false } as const; 46 + } catch (err) { 47 + // RecipientNotReady: valid identity, no publicKey/self record yet. 48 + // Queue a pending share — the daemon retries once they publish their key. 49 + // NotFound propagates as-is — the handle doesn't exist, not a pending-share case. 50 + if (err instanceof OpakeError && err.kind === "RecipientNotReady") { 51 + await fm.createPendingShare( 52 + input.documentUri, 53 + input.handleOrDid, 54 + "read", 55 + input.note ?? null, 56 + ); 57 + return { pending: true } as const; 58 + } 59 + throw err; 60 + } 61 + } finally { 62 + fm.dispose(); 63 + } 64 + }, 65 + onSuccess: (_, input) => { 66 + void queryClient.invalidateQueries({ queryKey: opakeKeys.shares(input.documentUri) }); 67 + void queryClient.invalidateQueries({ queryKey: opakeKeys.pendingShares() }); 68 + }, 69 + }); 70 + } 71 + 72 + /** 73 + * Revoke an existing grant by URI. On success, invalidates any 74 + * `useShares` query — React Query can't know which document's 75 + * share list the grant belonged to, so we invalidate the shares 76 + * root and let the individual queries refetch. 77 + */ 78 + export function useRevokeShare() { 79 + const opake = useOpake(); 80 + const queryClient = useQueryClient(); 81 + 82 + return useMutation({ 83 + mutationFn: async (grantUri: string) => { 84 + const fm = await opake.cabinet(); 85 + try { 86 + await fm.revokeShare(grantUri); 87 + } finally { 88 + fm.dispose(); 89 + } 90 + }, 91 + onSuccess: () => { 92 + void queryClient.invalidateQueries({ queryKey: opakeKeys.sharesAll() }); 93 + }, 94 + }); 95 + }
+39
packages/opake-react/src/hooks/use-shares.ts
··· 1 + "use client"; 2 + 3 + // useShares — fetch every grant on the caller's PDS and filter to a 4 + // single document. Read-only; invalidated by `useRevokeShare` / 5 + // `useShareFile` mutations. 6 + 7 + import { useQuery } from "@tanstack/react-query"; 8 + import type { GrantEntry } from "@opake/sdk"; 9 + import { useOpake } from "../provider"; 10 + import { opakeKeys } from "../keys"; 11 + 12 + /** 13 + * List every active grant for a specific document. 14 + * 15 + * The underlying `listShares` call enumerates the full cabinet grant 16 + * collection; this hook filters to the single document client-side. 17 + * Cabinet only — workspace sharing doesn't use grants. 18 + * 19 + * @example 20 + * ```tsx 21 + * const { data: shares, isLoading } = useShares(documentUri); 22 + * ``` 23 + */ 24 + export function useShares(documentUri: string) { 25 + const opake = useOpake(); 26 + 27 + return useQuery<readonly GrantEntry[]>({ 28 + queryKey: opakeKeys.shares(documentUri), 29 + queryFn: async () => { 30 + const fm = await opake.cabinet(); 31 + try { 32 + const all = await fm.listShares(); 33 + return all.filter((g) => g.document === documentUri); 34 + } finally { 35 + fm.dispose(); 36 + } 37 + }, 38 + }); 39 + }
+3 -4
packages/opake-react/src/hooks/use-tree-mutation.ts
··· 53 53 54 54 onMutate: options.optimisticUpdate 55 55 ? async (input) => { 56 - // Narrow the optimisticUpdate callback once so the closure 57 - // below isn't fighting the "options might have changed" 58 - // widening. This also avoids the non-null assertion. 59 - const apply = options.optimisticUpdate; 56 + // Narrow once: we're inside the `options.optimisticUpdate` truthy 57 + // branch but TS can't flow that into an async callback body. 58 + const apply = options.optimisticUpdate!; 60 59 await queryClient.cancelQueries({ queryKey: key }); 61 60 const previous = queryClient.getQueryData<DirectoryTreeSnapshot>(key); 62 61
+6
packages/opake-react/src/index.ts
··· 31 31 } from "./hooks/use-directory-mutations"; 32 32 export { useCreateWorkspace } from "./hooks/use-create-workspace"; 33 33 34 + // Sharing hooks 35 + export { useInbox } from "./hooks/use-inbox"; 36 + export { useShares } from "./hooks/use-shares"; 37 + export { useShareFile, useRevokeShare } from "./hooks/use-share-mutations"; 38 + export { usePendingShares, useCancelPendingShare } from "./hooks/use-pending-shares"; 39 + 34 40 // Daemon integration 35 41 export { useDaemon } from "./hooks/use-daemon"; 36 42
+9
packages/opake-react/src/keys.ts
··· 28 28 29 29 /** Inbox (shared items received). */ 30 30 inbox: () => ["opake", "inbox"] as const, 31 + 32 + /** All share queries across every document — used for cross-cutting invalidation. */ 33 + sharesAll: () => ["opake", "shares"] as const, 34 + 35 + /** Outgoing shares for a specific document. */ 36 + shares: (documentUri: string) => ["opake", "shares", documentUri] as const, 37 + 38 + /** Queued pending shares (not yet completed by the daemon). */ 39 + pendingShares: () => ["opake", "pending-shares"] as const, 31 40 } as const;
+4
packages/opake-sdk/src/errors.ts
··· 7 7 /** Error kinds matching opake-core's Error enum variants. */ 8 8 export type OpakeErrorKind = 9 9 | "NotFound" 10 + | "RecipientNotReady" 10 11 | "Auth" 11 12 | "Encryption" 12 13 | "Decryption" ··· 19 20 | "AmbiguousName" 20 21 | "Serialization" 21 22 | "Mnemonic" 23 + | "Sse" 22 24 | "Unknown"; 23 25 24 26 /** ··· 47 49 48 50 const KNOWN_KINDS = new Set<string>([ 49 51 "NotFound", 52 + "RecipientNotReady", 50 53 "Auth", 51 54 "Encryption", 52 55 "Decryption", ··· 59 62 "AmbiguousName", 60 63 "Serialization", 61 64 "Mnemonic", 65 + "Sse", 62 66 ]); 63 67 64 68 /**
+47
packages/opake-sdk/src/file-manager.ts
··· 11 11 DocumentMetadata, 12 12 DownloadResult, 13 13 DeleteRecursiveResult, 14 + GrantEntry, 14 15 } from "./types"; 15 16 import { parseWasmError, wrapWasmErrors } from "./errors"; 16 17 import { registerCleanup, unregisterCleanup } from "./finalizer"; ··· 19 20 deleteRecursiveResultSchema, 20 21 directoryTreeSnapshotSchema, 21 22 documentMetadataSchema, 23 + grantEntriesSchema, 22 24 treeWithMetadataSchema, 23 25 } from "./schemas"; 24 26 ··· 59 61 ): Promise<unknown>; 60 62 revokeShare(grantUri: string): Promise<void>; 61 63 listShares(): Promise<unknown>; 64 + createPendingShare( 65 + documentUri: string, 66 + recipient: string, 67 + permissions: string, 68 + note: string | null, 69 + ): Promise<string>; 62 70 syncAndApplyProposals(): Promise<number>; 63 71 isOwner(): boolean; 64 72 watchDirectory( ··· 507 515 @wrapWasmErrors 508 516 revokeShare(grantUri: string): Promise<void> { 509 517 return this.requireHandle().revokeShare(grantUri); 518 + } 519 + 520 + /** 521 + * List every grant on the caller's PDS (cabinet only). 522 + * 523 + * Returns all outgoing shares regardless of which document they target — 524 + * callers filter by `grant.document` for per-document views. Metadata 525 + * stays encrypted on the wire; callers that need filenames do a 526 + * separate `getDocumentMetadata` lookup. 527 + */ 528 + @wrapWasmErrors 529 + listShares(): Promise<readonly GrantEntry[]> { 530 + return this.requireHandle() 531 + .listShares() 532 + .then((raw) => grantEntriesSchema.parse(raw)) as Promise<readonly GrantEntry[]>; 533 + } 534 + 535 + /** 536 + * Queue a pending share for a recipient who hasn't set up Opake yet. 537 + * 538 + * Writes an `app.opake.pendingShare` record to the caller's PDS. The 539 + * daemon retries periodically — when the recipient publishes their 540 + * public key, the pending share is replaced with a real grant and the 541 + * record is deleted. Pending shares expire after 7 days. 542 + * 543 + * @param documentUri - URI of the document to share. 544 + * @param recipient - Recipient's handle or DID as entered by the user. 545 + * @param permissions - Access role (typically `"read"`). 546 + * @param note - Optional message carried through to the resulting grant. 547 + * @returns The URI of the created pending share record. 548 + */ 549 + @wrapWasmErrors 550 + createPendingShare( 551 + documentUri: string, 552 + recipient: string, 553 + permissions: string, 554 + note: string | null, 555 + ): Promise<string> { 556 + return this.requireHandle().createPendingShare(documentUri, recipient, permissions, note); 510 557 } 511 558 512 559 // ---------------------------------------------------------------------------
+8
packages/opake-sdk/src/index.ts
··· 7 7 // Schema-driven derived types 8 8 export { type WorkspaceSnapshot } from "./schemas"; 9 9 10 + // Sharing watcher (live inbox subscription — mirror of WorkspaceWatcher) 11 + export { type InboxWatcher } from "./types"; 12 + 10 13 // Errors 11 14 export { OpakeError, type OpakeErrorKind } from "./errors"; 12 15 ··· 58 61 type PairResponseRecord, 59 62 type InvitationEntry, 60 63 type TaskDef, 64 + type GrantEntry, 65 + type InboxGrant, 66 + type InboxSnapshot, 67 + type ResolvedGrantMetadata, 68 + type PendingShareEntry, 61 69 } from "./types"; 62 70 63 71 // Real-time event streaming is WASM-owned:
+157
packages/opake-sdk/src/opake.ts
··· 13 13 import type { 14 14 AccountConfig, 15 15 AccountConfigPatch, 16 + DownloadResult, 17 + InboxGrant, 18 + InboxSnapshot, 19 + InboxWatcher, 16 20 MutationResult, 17 21 OpakeInitOptions, 22 + PendingShareEntry, 23 + ResolvedGrantMetadata, 18 24 ResolvedIdentity, 19 25 ResolvedWorkspace, 20 26 WorkspaceEntry, ··· 26 32 import { 27 33 resolvedIdentitySchema, 28 34 createWorkspaceResultSchema, 35 + downloadResultSchema, 36 + inboxGrantsSchema, 37 + inboxSnapshotSchema, 29 38 listWorkspacesResultSchema, 39 + pendingShareEntriesSchema, 40 + resolvedGrantMetadataSchema, 30 41 syncSingleResultSchema, 31 42 workspaceSnapshotSchema, 32 43 type WorkspaceSnapshot, ··· 52 63 53 64 /** Internal shape of the WASM `WorkspaceWatcher` object. */ 54 65 type WasmWorkspaceWatcherHandle = { 66 + close(): Promise<void>; 67 + free(): void; 68 + }; 69 + 70 + /** Internal shape of the WASM `InboxWatcher` object. */ 71 + type WasmInboxWatcherHandle = { 55 72 close(): Promise<void>; 56 73 free(): void; 57 74 }; ··· 883 900 failed: number; 884 901 }> { 885 902 return this.requireContext().retryPendingSharesViaOpake(); 903 + } 904 + 905 + // --------------------------------------------------------------------------- 906 + // Sharing — inbox + pending shares + cross-PDS grant download 907 + // --------------------------------------------------------------------------- 908 + 909 + /** 910 + * Fetch every incoming grant from the AppView. 911 + * 912 + * Also bootstraps the in-memory `InboxKeeper` — any current or future 913 + * `watchInbox` callers receive a fresh snapshot with `loaded = true`. 914 + * Once bootstrapped, SSE `grant:upsert` / `grant:delete` events keep 915 + * the keeper in sync without further `listInbox` round-trips. 916 + */ 917 + @wrapWasmErrors 918 + @withTokenGuard 919 + listInbox(): Promise<readonly InboxGrant[]> { 920 + return this.requireContext() 921 + .listInbox(null) 922 + .then((raw) => inboxGrantsSchema.parse(raw)) as Promise<readonly InboxGrant[]>; 923 + } 924 + 925 + /** 926 + * Download and decrypt a shared document using a grant URI. 927 + * 928 + * Cross-PDS — uses the recipient's identity key to unwrap the content 929 + * key embedded in the grant, then fetches and decrypts the blob from 930 + * the grant owner's PDS. All network I/O is unauthenticated (public 931 + * PDS endpoints). 932 + */ 933 + @wrapWasmErrors 934 + @withTokenGuard 935 + downloadFromGrant(grantUri: string): Promise<DownloadResult> { 936 + return this.requireContext().downloadFromGrant(grantUri).then(downloadResultSchema.parse); 937 + } 938 + 939 + /** 940 + * Resolve a grant's document metadata (filename + encrypted fields) 941 + * without downloading the blob. 942 + * 943 + * Cross-PDS — fetches grant + document records from the owner's PDS, 944 + * unwraps the content key, decrypts metadata. Useful for rendering 945 + * a "shared with me" list without paying the download cost for 946 + * every entry. 947 + */ 948 + @wrapWasmErrors 949 + @withTokenGuard 950 + resolveGrantMetadata(grantUri: string): Promise<ResolvedGrantMetadata> { 951 + return this.requireContext() 952 + .resolveGrantMetadata(grantUri) 953 + .then(resolvedGrantMetadataSchema.parse); 954 + } 955 + 956 + /** 957 + * List every pending (queued) outgoing share on the caller's PDS. 958 + * 959 + * A pending share exists when a recipient hadn't set up Opake yet at 960 + * the time of sharing. The daemon retries until the recipient 961 + * publishes a public key or the share expires (7 days). 962 + */ 963 + @wrapWasmErrors 964 + @withTokenGuard 965 + listPendingShares(): Promise<readonly PendingShareEntry[]> { 966 + return this.requireContext() 967 + .listPendingShares() 968 + .then((raw) => pendingShareEntriesSchema.parse(raw)) as Promise<readonly PendingShareEntry[]>; 969 + } 970 + 971 + /** Cancel (delete) a pending share by its AT-URI. */ 972 + @wrapWasmErrors 973 + @withTokenGuard 974 + cancelPendingShare(uri: string): Promise<void> { 975 + return this.requireContext().cancelPendingShare(uri); 976 + } 977 + 978 + /** 979 + * Subscribe to live changes in the inbox (incoming grants). 980 + * 981 + * Fires the handler once immediately with the current snapshot 982 + * (`loaded: false` + empty `entries` if the keeper hasn't been 983 + * bootstrapped yet), and again on every `grant:upsert` / `grant:delete` 984 + * SSE event. The initial `listInbox` call populates the keeper. 985 + * 986 + * Mirrors `watchWorkspaces`: returns a synchronous handle; registration 987 + * is kicked off eagerly and `close()` chains onto the pending Promise. 988 + * 989 + * @example 990 + * ```tsx 991 + * useEffect(() => { 992 + * const watcher = opake.watchInbox((snapshot) => { 993 + * setInbox(snapshot.entries); 994 + * setLoaded(snapshot.loaded); 995 + * }); 996 + * return () => watcher.close(); 997 + * }, [opake]); 998 + * ``` 999 + */ 1000 + watchInbox(handler: (snapshot: InboxSnapshot) => void): InboxWatcher { 1001 + const adapter = (raw: unknown) => { 1002 + let snapshot: InboxSnapshot; 1003 + try { 1004 + snapshot = inboxSnapshotSchema.parse(raw); 1005 + } catch (err) { 1006 + console.warn("[opake-sdk] watchInbox snapshot parse failed:", err); 1007 + return; 1008 + } 1009 + try { 1010 + handler(snapshot); 1011 + } catch (err) { 1012 + console.warn("[opake-sdk] watchInbox handler threw:", err); 1013 + } 1014 + }; 1015 + 1016 + const pending = this.requireContext().watchInbox(adapter); 1017 + let closed = false; 1018 + let wasmWatcher: WasmInboxWatcherHandle | null = null; 1019 + 1020 + pending.then( 1021 + (w) => { 1022 + if (closed) { 1023 + void w.close(); 1024 + return; 1025 + } 1026 + wasmWatcher = w as WasmInboxWatcherHandle; 1027 + }, 1028 + (err: unknown) => { 1029 + console.warn("[opake-sdk] watchInbox registration failed:", err); 1030 + }, 1031 + ); 1032 + 1033 + return { 1034 + close: () => { 1035 + if (closed) return; 1036 + closed = true; 1037 + if (wasmWatcher) { 1038 + void wasmWatcher.close(); 1039 + wasmWatcher = null; 1040 + } 1041 + }, 1042 + }; 886 1043 } 887 1044 888 1045 // ---------------------------------------------------------------------------
+102
packages/opake-sdk/src/schemas.ts
··· 208 208 }); 209 209 210 210 // --------------------------------------------------------------------------- 211 + // Sharing 212 + // --------------------------------------------------------------------------- 213 + 214 + /** 215 + * A grant record on the sharer's PDS. The WASM `list_shares` binding 216 + * emits snake_case fields straight from `GrantEntry` in core — we map 217 + * to camelCase here and surface only the fields JS consumers need (the 218 + * `encrypted_metadata` envelope stays in Rust). 219 + */ 220 + export const grantEntrySchema = z 221 + .object({ 222 + uri: z.string(), 223 + document: z.string(), 224 + recipient: z.string(), 225 + created_at: z.string(), 226 + expires_at: z.string().nullable().optional(), 227 + }) 228 + .transform((r) => ({ 229 + uri: r.uri, 230 + document: r.document, 231 + recipient: r.recipient, 232 + createdAt: r.created_at, 233 + expiresAt: r.expires_at ?? null, 234 + })); 235 + 236 + export type GrantEntry = z.output<typeof grantEntrySchema>; 237 + 238 + export const grantEntriesSchema = z.array(grantEntrySchema); 239 + 240 + /** 241 + * An incoming grant indexed by the AppView. Fields are snake_case on 242 + * the wire (serde) and get camelCased here. 243 + */ 244 + export const inboxGrantSchema = z 245 + .object({ 246 + uri: z.string(), 247 + owner_did: z.string(), 248 + document_uri: z.string(), 249 + created_at: z.string(), 250 + }) 251 + .transform((r) => ({ 252 + uri: r.uri, 253 + ownerDid: r.owner_did, 254 + documentUri: r.document_uri, 255 + createdAt: r.created_at, 256 + })); 257 + 258 + export type InboxGrant = z.output<typeof inboxGrantSchema>; 259 + 260 + export const inboxGrantsSchema = z.array(inboxGrantSchema); 261 + 262 + /** Snapshot fired by `watchInbox`. */ 263 + export const inboxSnapshotSchema = z 264 + .object({ 265 + entries: z.array(inboxGrantSchema), 266 + loaded: z.boolean(), 267 + }) 268 + .transform((r) => ({ 269 + entries: r.entries, 270 + loaded: r.loaded, 271 + })); 272 + 273 + export type InboxSnapshot = z.output<typeof inboxSnapshotSchema>; 274 + 275 + /** 276 + * Decrypted grant metadata — filename + the underlying `DocumentMetadata`. 277 + * The WASM binding emits `{ name, metadata: DocumentMetadata }` with 278 + * core's snake_case serde format — the nested metadata piggybacks on 279 + * `documentMetadataSchema`. 280 + */ 281 + export const resolvedGrantMetadataSchema = z 282 + .object({ 283 + name: z.string(), 284 + metadata: documentMetadataSchema, 285 + }) 286 + .transform((r) => ({ 287 + name: r.name, 288 + metadata: r.metadata, 289 + })); 290 + 291 + export type ResolvedGrantMetadata = z.output<typeof resolvedGrantMetadataSchema>; 292 + 293 + /** Pending share entry as emitted by `list_pending_shares`. */ 294 + export const pendingShareEntrySchema = z 295 + .object({ 296 + uri: z.string(), 297 + document: z.string(), 298 + recipient: z.string(), 299 + created_at: z.string(), 300 + }) 301 + .transform((r) => ({ 302 + uri: r.uri, 303 + document: r.document, 304 + recipient: r.recipient, 305 + createdAt: r.created_at, 306 + })); 307 + 308 + export type PendingShareEntry = z.output<typeof pendingShareEntrySchema>; 309 + 310 + export const pendingShareEntriesSchema = z.array(pendingShareEntrySchema); 311 + 312 + // --------------------------------------------------------------------------- 211 313 // Pairing 212 314 // --------------------------------------------------------------------------- 213 315
+50
packages/opake-sdk/src/types.ts
··· 176 176 export type PairResponseRecord = Record<string, unknown>; 177 177 178 178 // --------------------------------------------------------------------------- 179 + // Sharing 180 + // --------------------------------------------------------------------------- 181 + 182 + /** A grant record on the sharer's PDS (outgoing share). */ 183 + export interface GrantEntry { 184 + readonly uri: string; 185 + readonly document: string; 186 + readonly recipient: string; 187 + readonly createdAt: string; 188 + readonly expiresAt: string | null; 189 + } 190 + 191 + /** An incoming grant as indexed by the AppView (shared-with-me). */ 192 + export interface InboxGrant { 193 + readonly uri: string; 194 + readonly ownerDid: string; 195 + readonly documentUri: string; 196 + readonly createdAt: string; 197 + } 198 + 199 + /** Snapshot emitted by `watchInbox` — mirrors the keeper's internal shape. */ 200 + export interface InboxSnapshot { 201 + readonly entries: readonly InboxGrant[]; 202 + readonly loaded: boolean; 203 + } 204 + 205 + /** 206 + * Handle returned by `Opake.watchInbox`. Call `.close()` to unsubscribe — 207 + * typically from a React useEffect cleanup. 208 + */ 209 + export interface InboxWatcher { 210 + /** Stop receiving notifications. Idempotent. */ 211 + close(): void; 212 + } 213 + 214 + /** Decrypted grant metadata — name + raw `DocumentMetadata`. */ 215 + export interface ResolvedGrantMetadata { 216 + readonly name: string; 217 + readonly metadata: DocumentMetadata; 218 + } 219 + 220 + /** A queued outgoing share waiting for the recipient to publish a public key. */ 221 + export interface PendingShareEntry { 222 + readonly uri: string; 223 + readonly document: string; 224 + readonly recipient: string; 225 + readonly createdAt: string; 226 + } 227 + 228 + // --------------------------------------------------------------------------- 179 229 // Invitations 180 230 // --------------------------------------------------------------------------- 181 231
+2 -2
tests/tests/web/sharing.test.ts
··· 40 40 41 41 await page.goto(`${webUrl}/cabinet/shared`); 42 42 43 - await expect(page.getByText("No shared files yet")).toBeVisible({ 43 + await expect(page.getByText("Nothing shared with you yet")).toBeVisible({ 44 44 timeout: 10_000, 45 45 }); 46 46 await expect( 47 - page.getByText("Share files from the file action menu"), 47 + page.getByText("Files others share with your handle show up here."), 48 48 ).toBeVisible(); 49 49 50 50 const banner = page.getByRole("alert");
tests/tests/web/sharing.test.ts-snapshots/sharing-empty-state-chromium-darwin.png

This is a binary file and will not be displayed.