this repo has no description
1
fork

Configure Feed

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

Docs update first pass

+3688 -350
+29 -7
apps/web/src/components/content/chapter.tsx
··· 9 9 } from "react"; 10 10 import { InfoIcon, WarningIcon, DesktopIcon, TerminalIcon } from "@phosphor-icons/react"; 11 11 import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; 12 + import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism"; 12 13 13 14 /* ─── Chapter header ───────────────────────────────────────────────────────── */ 14 15 ··· 140 141 interface CodeBlockProps { 141 142 readonly language: string; 142 143 readonly title?: string; 143 - readonly children: ReactNode; 144 + readonly children?: ReactNode; 145 + /** 146 + * Explicit code string. When passed, bypasses `children` extraction. 147 + * Use this for multi-line snippets in MDX: JSX children go through MDX's 148 + * whitespace normalization (which strips the minimum indent among 149 + * indented lines), but JSX attribute strings pass through untouched. 150 + */ 151 + readonly code?: string; 144 152 } 145 153 146 154 function extractText(node: ReactNode): string { ··· 153 161 return ""; 154 162 } 155 163 156 - export function CodeBlock({ language, title, children }: CodeBlockProps) { 157 - const code = extractText(children).trim(); 164 + export function CodeBlock({ language, title, children, code }: CodeBlockProps) { 165 + const source = code ?? extractText(children); 166 + const trimmed = source.trim(); 158 167 159 168 return ( 160 - <div className="border-border-accent/30 my-4 overflow-hidden rounded-lg border"> 169 + <div className="not-prose border-border-accent/30 my-4 overflow-hidden rounded-lg border"> 161 170 {title && ( 162 171 <div className="bg-base-200/60 border-b border-inherit px-4 py-1.5"> 163 172 <span className="text-text-muted font-mono text-[0.72rem]">{title}</span> ··· 165 174 )} 166 175 <SyntaxHighlighter 167 176 language={language} 168 - useInlineStyles={false} 169 - className="bg-base-100! text-ui m-0! p-4! leading-relaxed" 177 + style={oneLight} 178 + customStyle={{ 179 + margin: 0, 180 + padding: "1rem", 181 + fontSize: "0.82rem", 182 + lineHeight: "1.55", 183 + whiteSpace: "pre", 184 + }} 185 + codeTagProps={{ 186 + style: { 187 + fontFamily: 188 + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', 189 + whiteSpace: "pre", 190 + }, 191 + }} 170 192 > 171 - {code} 193 + {trimmed} 172 194 </SyntaxHighlighter> 173 195 </div> 174 196 );
+255
apps/web/src/components/content/docs-sidebar.tsx
··· 1 + import { Link } from "@tanstack/react-router"; 2 + import { CATEGORY_META, findDoc, partitionCategoryForSidebar, type DocMeta } from "@/lib/docs-registry"; 3 + 4 + interface DocsSidebarProps { 5 + /** 6 + * Slug of the current page. Together with `currentGroup`, used to 7 + * render the active highlight. Two docs can share a slug (`use/sharing` 8 + * vs `sdk/sharing`), so the group is required for disambiguation on 9 + * nested routes. 10 + */ 11 + readonly currentSlug?: string; 12 + /** 13 + * Group of the current page (e.g. `"sdk"`). Pass `undefined` for flat 14 + * docs. Paired with `currentSlug` to identify the active item uniquely. 15 + */ 16 + readonly currentGroup?: string; 17 + /** 18 + * Visual density variant. `public` renders at full width for the marketing 19 + * docs layout; `cabinet` is compact to fit inside the file-browser panel. 20 + */ 21 + readonly variant?: "public" | "cabinet"; 22 + } 23 + 24 + function isCurrent( 25 + doc: DocMeta, 26 + currentSlug: string | undefined, 27 + currentGroup: string | undefined, 28 + ): boolean { 29 + if (currentSlug === undefined) return false; 30 + if (doc.slug !== currentSlug) return false; 31 + return doc.group === currentGroup; 32 + } 33 + 34 + /** 35 + * Persistent docs table of contents. Reads directly from `DOCS_REGISTRY`, 36 + * so registering a new doc there is all that's needed for it to show up in 37 + * the sidebar. Rendered on every doc page (index, flat slug, nested slug). 38 + */ 39 + export function DocsSidebar({ 40 + currentSlug, 41 + currentGroup, 42 + variant = "public", 43 + }: DocsSidebarProps) { 44 + const faq = findDoc("faq"); 45 + 46 + const baseSectionGap = variant === "cabinet" ? "space-y-4" : "space-y-5"; 47 + const headingSize = variant === "cabinet" ? "text-caption" : "text-ui"; 48 + const linkSize = variant === "cabinet" ? "text-caption" : "text-ui"; 49 + 50 + return ( 51 + <nav aria-label="Documentation" className={`${baseSectionGap} text-sm`}> 52 + <Link 53 + to="/docs" 54 + className={`text-text-muted hover:text-base-content ${linkSize} block font-medium`} 55 + > 56 + Docs home 57 + </Link> 58 + 59 + {CATEGORY_META.map((cat) => { 60 + const { ungrouped, groups } = partitionCategoryForSidebar(cat.key); 61 + if (ungrouped.length === 0 && groups.length === 0) return null; 62 + return ( 63 + <section key={cat.key}> 64 + <div 65 + className={`text-text-muted mb-2 ${headingSize} font-medium tracking-wide uppercase`} 66 + > 67 + {cat.label} 68 + </div> 69 + <ul className="flex flex-col gap-0.5"> 70 + {ungrouped.map((doc) => ( 71 + <SidebarLink 72 + key={doc.slug} 73 + doc={doc} 74 + isCurrent={isCurrent(doc, currentSlug, currentGroup)} 75 + linkSize={linkSize} 76 + /> 77 + ))} 78 + {groups.map((group) => ( 79 + <li key={group.key} className="mt-2"> 80 + <div 81 + className={`text-text-muted ${headingSize} mb-1 pl-1 font-medium opacity-80`} 82 + > 83 + {group.label} 84 + </div> 85 + <ul className="border-border-accent/30 ml-1.5 flex flex-col gap-0.5 border-l pl-2"> 86 + {group.docs.map((doc) => ( 87 + <SidebarLink 88 + key={doc.slug} 89 + doc={doc} 90 + isCurrent={isCurrent(doc, currentSlug, currentGroup)} 91 + linkSize={linkSize} 92 + /> 93 + ))} 94 + </ul> 95 + </li> 96 + ))} 97 + </ul> 98 + </section> 99 + ); 100 + })} 101 + 102 + {faq && ( 103 + <section> 104 + <ul className="flex flex-col gap-0.5"> 105 + <SidebarLink 106 + doc={faq} 107 + isCurrent={isCurrent(faq, currentSlug, currentGroup)} 108 + linkSize={linkSize} 109 + /> 110 + </ul> 111 + </section> 112 + )} 113 + </nav> 114 + ); 115 + } 116 + 117 + interface SidebarLinkProps { 118 + readonly doc: DocMeta; 119 + readonly isCurrent: boolean; 120 + readonly linkSize: string; 121 + } 122 + 123 + function SidebarLink({ doc, isCurrent, linkSize }: SidebarLinkProps) { 124 + const className = isCurrent 125 + ? `text-primary ${linkSize} block rounded px-2 py-1 font-medium` 126 + : `text-text-muted hover:text-base-content hover:bg-accent/30 ${linkSize} block rounded px-2 py-1 transition-colors`; 127 + const ariaCurrent = isCurrent ? "page" : undefined; 128 + 129 + // TanStack's typed Link forks on nested vs flat just like the docs index 130 + // card — the `to` argument's shape has to match the registered route. 131 + return ( 132 + <li> 133 + {doc.group ? ( 134 + <Link 135 + to="/docs/$category/$slug" 136 + params={{ category: doc.group, slug: doc.slug }} 137 + className={className} 138 + aria-current={ariaCurrent} 139 + > 140 + {doc.title} 141 + </Link> 142 + ) : ( 143 + <Link 144 + to="/docs/$slug" 145 + params={{ slug: doc.slug }} 146 + className={className} 147 + aria-current={ariaCurrent} 148 + > 149 + {doc.title} 150 + </Link> 151 + )} 152 + </li> 153 + ); 154 + } 155 + 156 + /** 157 + * Variant that renders `Link`s pointing at the cabinet docs routes instead 158 + * of the public ones. Same shape, different `to` targets. 159 + */ 160 + export function DocsSidebarCabinet({ 161 + currentSlug, 162 + currentGroup, 163 + }: { 164 + readonly currentSlug?: string; 165 + readonly currentGroup?: string; 166 + }) { 167 + const faq = findDoc("faq"); 168 + 169 + return ( 170 + <nav aria-label="Documentation" className="text-caption space-y-4"> 171 + <Link 172 + to="/cabinet/docs" 173 + className="text-text-muted hover:text-base-content block text-xs font-medium" 174 + > 175 + Docs home 176 + </Link> 177 + 178 + {CATEGORY_META.map((cat) => { 179 + const { ungrouped, groups } = partitionCategoryForSidebar(cat.key); 180 + if (ungrouped.length === 0 && groups.length === 0) return null; 181 + return ( 182 + <section key={cat.key}> 183 + <div className="text-text-muted mb-1.5 text-[0.65rem] font-medium tracking-wide uppercase"> 184 + {cat.label} 185 + </div> 186 + <ul className="flex flex-col gap-0.5"> 187 + {ungrouped.map((doc) => ( 188 + <CabinetLink 189 + key={doc.slug} 190 + doc={doc} 191 + isCurrent={isCurrent(doc, currentSlug, currentGroup)} 192 + /> 193 + ))} 194 + {groups.map((group) => ( 195 + <li key={group.key} className="mt-1.5"> 196 + <div className="text-text-muted mb-1 pl-1 text-[0.65rem] font-medium opacity-80"> 197 + {group.label} 198 + </div> 199 + <ul className="border-border-accent/30 ml-1 flex flex-col gap-0.5 border-l pl-1.5"> 200 + {group.docs.map((doc) => ( 201 + <CabinetLink 202 + key={doc.slug} 203 + doc={doc} 204 + isCurrent={isCurrent(doc, currentSlug, currentGroup)} 205 + /> 206 + ))} 207 + </ul> 208 + </li> 209 + ))} 210 + </ul> 211 + </section> 212 + ); 213 + })} 214 + 215 + {faq && ( 216 + <section> 217 + <ul> 218 + <CabinetLink doc={faq} isCurrent={isCurrent(faq, currentSlug, currentGroup)} /> 219 + </ul> 220 + </section> 221 + )} 222 + </nav> 223 + ); 224 + } 225 + 226 + function CabinetLink({ doc, isCurrent }: { readonly doc: DocMeta; readonly isCurrent: boolean }) { 227 + const className = isCurrent 228 + ? "text-primary block rounded px-1.5 py-0.5 text-xs font-medium" 229 + : "text-text-muted hover:text-base-content hover:bg-accent/30 block rounded px-1.5 py-0.5 text-xs transition-colors"; 230 + const ariaCurrent = isCurrent ? "page" : undefined; 231 + 232 + return ( 233 + <li> 234 + {doc.group ? ( 235 + <Link 236 + to="/cabinet/docs/$category/$slug" 237 + params={{ category: doc.group, slug: doc.slug }} 238 + className={className} 239 + aria-current={ariaCurrent} 240 + > 241 + {doc.title} 242 + </Link> 243 + ) : ( 244 + <Link 245 + to="/cabinet/docs/$slug" 246 + params={{ slug: doc.slug }} 247 + className={className} 248 + aria-current={ariaCurrent} 249 + > 250 + {doc.title} 251 + </Link> 252 + )} 253 + </li> 254 + ); 255 + }
+46
apps/web/src/components/content/docs.tsx
··· 2 2 import { Link } from "@tanstack/react-router"; 3 3 import { ArrowRightIcon } from "@phosphor-icons/react"; 4 4 import { resolveIcon } from "./icons"; 5 + import { nextDoc } from "@/lib/docs-registry"; 5 6 6 7 /* ─── Docs index header ────────────────────────────────────────────────────── */ 7 8 ··· 141 142 /> 142 143 </Link> 143 144 </div> 145 + ); 146 + } 147 + 148 + /* ─── Linear "Next" chapter link ─────────────────────────────────────────── */ 149 + 150 + interface DocsNextProps { 151 + /** Slug of the current page; the next doc in the same group/category is resolved from the registry. */ 152 + readonly slug: string; 153 + } 154 + 155 + /** 156 + * Small "Next chapter" link at the end of a doc page. Reads from 157 + * {@link nextDoc} so the reading order is always the registry order — 158 + * there's nothing to maintain per-page beyond the component usage. 159 + * Renders nothing when the current doc is the last in its sequence. 160 + */ 161 + export function DocsNext({ slug }: DocsNextProps) { 162 + const next = nextDoc(slug); 163 + if (!next) return null; 164 + 165 + // `no-underline` defeats Tailwind Typography's default link styling; the 166 + // card is its own visual affordance and doesn't need an underline on top. 167 + const className = 168 + "not-prose border-border-accent/40 bg-base-100 group hover:border-primary/60 hover:shadow-panel-sm mt-12 flex items-center justify-between rounded-xl border p-4 no-underline transition-all"; 169 + const body = ( 170 + <> 171 + <div className="flex flex-col"> 172 + <span className="text-text-muted text-ui">Next</span> 173 + <span className="text-base-content text-[1.05rem] font-medium">{next.title}</span> 174 + </div> 175 + <ArrowRightIcon 176 + size={18} 177 + className="text-primary transition-transform group-hover:translate-x-1" 178 + /> 179 + </> 180 + ); 181 + 182 + return next.group ? ( 183 + <Link to="/docs/$category/$slug" params={{ category: next.group, slug: next.slug }} className={className}> 184 + {body} 185 + </Link> 186 + ) : ( 187 + <Link to="/docs/$slug" params={{ slug: next.slug }} className={className}> 188 + {body} 189 + </Link> 144 190 ); 145 191 } 146 192
+1
apps/web/src/components/content/index.ts
··· 30 30 DocsIndexPrimary, 31 31 DocsIndexSecondary, 32 32 DocsIndexSection, 33 + DocsNext, 33 34 } from "./docs"; 34 35 35 36 export {
+3 -4
apps/web/src/content/docs/build/cli.mdx
··· 1 1 <ChapterHeader title="The CLI Manual" /> 2 2 3 3 <Lead> 4 - For the power users, the keyboard-bound, and the automation-obsessed. The Opake CLI is the 5 - reference implementation of our protocol. 4 + The Opake CLI is the reference implementation of the protocol. Everything the web app and SDK 5 + can do, the CLI can do first — identity, files, sharing, workspaces, device pairing, daemon 6 + maintenance. 6 7 </Lead> 7 8 8 9 ## Installation ··· 82 83 --- 83 84 84 85 ## 3. Sharing & Collaboration 85 - 86 - Cryptographic access control at your fingertips. 87 86 88 87 ### Direct Sharing (Grants) 89 88
+207
apps/web/src/content/docs/build/lexicons.mdx
··· 1 + import { 2 + wrappedKeyShape, 3 + encryptionEnvelopeShape, 4 + keyringRefShape, 5 + encryptedMetadataShape, 6 + documentRecordShape, 7 + grantRecordShape, 8 + keyringRecordShape, 9 + documentUpdateShape, 10 + directoryUpdateShape, 11 + } from "./lexicons/_snippets"; 12 + 13 + <ChapterHeader title="Lexicon reference" /> 14 + 15 + <Lead> 16 + Opake publishes `app.opake.*` records to the caller's PDS. This page is a working 17 + reference for the schemas — what a document record looks like on the wire, how the 18 + encryption envelope is shaped, what fields the indexer relies on. The authoritative 19 + schemas live in [`/lexicons`](https://tangled.org/opake.app/opake/tree/main/lexicons) 20 + in the repo. 21 + </Lead> 22 + 23 + ## Collections 24 + 25 + | NSID | Type | Purpose | 26 + |---|---|---| 27 + | `app.opake.accountConfig` | record (singleton) | Per-account preferences, indexer URL pin | 28 + | `app.opake.publicKey` | record (singleton) | X25519 encryption public key for key discovery | 29 + | `app.opake.document` | record | An encrypted file | 30 + | `app.opake.directory` | record | Organisational grouping of documents + sub-directories | 31 + | `app.opake.grant` | record | One-to-one share grant | 32 + | `app.opake.keyring` | record | Workspace (named group with a shared symmetric key) | 33 + | `app.opake.pendingShare` | record | Queued share waiting for recipient to publish their key | 34 + | `app.opake.documentUpdate` | record | Proposed content or metadata change to another member's document | 35 + | `app.opake.directoryUpdate` | record | Proposed structural change to a workspace directory | 36 + | `app.opake.keyringUpdate` | record | Proposed membership / role / metadata change to a keyring | 37 + | `app.opake.invitation` | record | Workspace invitation with token and role | 38 + | `app.opake.invitationAcceptance` | record | Acceptance of a workspace invitation | 39 + | `app.opake.pairRequest` | record | New-device identity-transfer request | 40 + | `app.opake.pairResponse` | record | Existing-device encrypted identity payload | 41 + | `app.opake.defs` | defs | Shared types (wrappedKey, encryptionEnvelope, keyringRef, encryptedMetadata) | 42 + | `app.opake.authFullAccess` | permission-set | OAuth scope bundle covering every `app.opake.*` collection | 43 + 44 + Everything record-typed lives in the PDS's repo. The only blobs are the encrypted file 45 + contents referenced from `app.opake.document.blob` — up to 50 MB per the default PDS 46 + config. 47 + 48 + ## Encryption primitives 49 + 50 + Three small shapes underpin every opaque field on every record. They live in 51 + [`app.opake.defs`](https://tangled.org/opake.app/opake/blob/main/lexicons/app.opake.defs.json) 52 + as shared refs. 53 + 54 + ### `wrappedKey` 55 + 56 + <CodeBlock language="json" title="wrappedKey" code={wrappedKeyShape} /> 57 + 58 + A symmetric key encrypted to a specific recipient's X25519 public key. `algo` is 59 + `x25519-hkdf-a256kw` — ephemeral-sender Diffie-Hellman, HKDF-derived KEK, AES-KeyWrap 60 + over the content key. `ciphertext` is `[32-byte ephemeral pubkey || 40-byte wrapped 61 + key]`. 62 + 63 + ### `encryptionEnvelope` 64 + 65 + <CodeBlock language="json" title="encryptionEnvelope" code={encryptionEnvelopeShape} /> 66 + 67 + Full shape for direct-sharing: the content key is wrapped once per recipient, all 68 + copies listed in `keys`. AES-256-GCM on the blob with the given `nonce`. One content 69 + key, many wrapped copies. Rotating a recipient's wrap doesn't affect the others. 70 + 71 + ### `keyringRef` 72 + 73 + <CodeBlock language="json" title="keyringRef" code={keyringRefShape} /> 74 + 75 + The workspace alternative to `encryptionEnvelope`. Instead of wrapping the content 76 + key to each member directly, wrap it under the keyring's group key once. Members 77 + unwrap the group key from their keyring entry, then use the group key to unwrap the 78 + content key. `rotation` identifies which generation of the group key was used — the 79 + keyring record keeps historical rotations in `keyHistory` so remaining members can 80 + still decrypt pre-rotation documents. 81 + 82 + ### `encryptedMetadata` 83 + 84 + <CodeBlock language="json" title="encryptedMetadata" code={encryptedMetadataShape} /> 85 + 86 + Record-level metadata (filename, MIME type, size, tags, description) is encrypted in 87 + one blob with the same key protecting the record's payload. The PDS only ever sees 88 + ciphertext for these fields — there's no server-side search over real filenames. 89 + Record-level fields that ARE in plaintext (the `blob.mimeType`, a placeholder `name`) 90 + are always dummy values. 91 + 92 + ## Record shapes 93 + 94 + ### `app.opake.document` 95 + 96 + <CodeBlock language="json" title="document" code={documentRecordShape} /> 97 + 98 + The `encryption` field is a tagged union. `directEncryption` carries a full 99 + `encryptionEnvelope` — used for cabinet documents and one-to-one shares. Workspace 100 + documents use `keyringEncryption` instead: just the `keyringRef`, the algo, and the 101 + nonce. The content key isn't stored on the document at all; it's protected by the 102 + keyring's group key. 103 + 104 + `opakeVersion` lets clients reject records they don't understand. Bumping it on a 105 + schema change means old clients see a decryption failure, not a silent mis-parse. 106 + 107 + ### `app.opake.grant` 108 + 109 + <CodeBlock language="json" title="grant" code={grantRecordShape} /> 110 + 111 + One grant per (document, recipient) pair. The grant lives on the **sharer's** PDS, 112 + not the recipient's — the recipient discovers the grant by subscribing to the indexer. 113 + Grants reference the document by AT-URI; there's no keyring involved. Revoking a 114 + grant is a `deleteRecord` call; there's no key rotation because the document was 115 + already decryptable by the sharer's content key. 116 + 117 + ### `app.opake.keyring` 118 + 119 + <CodeBlock language="json" title="keyring" code={keyringRecordShape} /> 120 + 121 + A workspace. `members` is keyed by DID, with each entry carrying a wrapped copy of 122 + the current group key plus the member's role (`manager` / `editor` / `viewer`). 123 + `keyHistory` retains the wrapped copies from prior rotations so members still in the 124 + workspace can decrypt documents uploaded under older generations of the group key. 125 + 126 + Removing a member atomically: 127 + 128 + 1. Generates a new group key, bumps `rotation`. 129 + 2. Archives the old `members` dict into `keyHistory[rotation - 1]`. 130 + 3. Writes the new `members` dict (without the removed member, re-wrapped to the rest). 131 + 132 + Per-document content keys and blobs aren't touched. The removed member could still 133 + decrypt anything uploaded under a prior rotation with a cached copy of the old group 134 + key — "no revocation guarantee for historical access" is called out up front; apps 135 + that need forward-secret removal must re-upload documents after rotation. 136 + 137 + ### `app.opake.documentUpdate` 138 + 139 + <CodeBlock language="json" title="documentUpdate" code={documentUpdateShape} /> 140 + 141 + Workspace proposals. When a member who isn't the owner wants to update a document, 142 + they write this record to their own PDS. The indexer sees it via firehose, validates 143 + the proposer's role against the target keyring, and surfaces it for the owner to 144 + apply. Once the owner applies (writing the updated document to their own PDS), the 145 + proposal record is deleted from the member's PDS. 146 + 147 + `actionType` distinguishes `replaceContent` (new blob + nonce) from `replaceMetadata` 148 + (only the encrypted metadata envelope changed). 149 + 150 + ### `app.opake.directoryUpdate` 151 + 152 + <CodeBlock language="json" title="directoryUpdate" code={directoryUpdateShape} /> 153 + 154 + Same pattern as `documentUpdate` but for structural changes — move entries between 155 + directories, rename, create, delete. The indexer validates, the owner applies, 156 + proposal gets cleaned up. 157 + 158 + ## Indexing 159 + 160 + The indexer subscribes to the atproto firehose and consumes seven `app.opake.*` 161 + collections. For each event, it: 162 + 163 + 1. Parses the record against the lexicon schema (rejects malformed records). 164 + 2. Resolves the keyring membership if the record is workspace-scoped (rejects 165 + records from non-members). 166 + 3. Writes to its own Postgres index. 167 + 4. Fans out to SSE subscribers via Phoenix PubSub. 168 + 169 + The indexer never decrypts anything. All the filtering is over plaintext metadata 170 + (DIDs, AT-URIs, timestamps, roles) — the encrypted payload passes through opaque. 171 + 172 + See [Live updates](/docs/sdk/events) for the client-side stream consumer, and 173 + [docs/indexer.md](https://tangled.org/opake.app/opake/blob/main/docs/indexer.md) 174 + for the indexer's own configuration and API. 175 + 176 + ## Schema versioning 177 + 178 + Every record carries `opakeVersion: integer`. Current version is `1` across the board. 179 + The compatibility contract: 180 + 181 + - **Minor additions** (optional fields, new known values for string enums) don't bump 182 + the version. Old clients ignore unknown fields. 183 + - **Breaking changes** (required field additions, envelope restructures, algorithm 184 + swaps) bump the version. Old clients are expected to reject. 185 + - **Decryption-relevant changes** always bump the version, even if the wire format 186 + looks backward-compatible. We'd rather an old client fail fast than silently 187 + decrypt under the wrong assumption. 188 + 189 + If you're building a client that needs to survive across Opake versions, validate 190 + `opakeVersion` up front and fall back to "please upgrade" messaging for anything past 191 + your supported set. 192 + 193 + ## Getting the authoritative schemas 194 + 195 + The JSON schema files in [`/lexicons`](https://tangled.org/opake.app/opake/tree/main/lexicons) 196 + are the canonical source. Every field description, constraint, and `knownValues` list is 197 + there. Clone the repo and wire them into your codegen: 198 + 199 + ```sh 200 + git clone https://tangled.org/opake.app/opake 201 + # then point your lexicon codegen at opake/lexicons/ 202 + ``` 203 + 204 + The Rust core uses `atproto-rs` codegen over these same files. The TypeScript SDK 205 + re-exports the field-level types via `@opake/sdk` — check 206 + [Files & directories](/docs/sdk/files) and [Sharing](/docs/sdk/sharing) for how they 207 + surface in the high-level API.
+118
apps/web/src/content/docs/build/lexicons/_snippets.ts
··· 1 + // Code snippets for the lexicon reference page. 2 + // Schema-format samples kept readable by rendering as JSON; full 3 + // authoritative schemas live in the repo under /lexicons. 4 + 5 + export const wrappedKeyShape = `{ 6 + "did": "did:plc:alice...", // recipient DID 7 + "ciphertext": "<bytes>", // content key, encrypted to their pubkey 8 + "algo": "x25519-hkdf-a256kw" 9 + }`; 10 + 11 + export const encryptionEnvelopeShape = `{ 12 + "algo": "aes-256-gcm", 13 + "nonce": "<12-byte IV>", 14 + "keys": [ // one wrapped copy per recipient 15 + { "did": "did:plc:alice", "ciphertext": "...", "algo": "x25519-hkdf-a256kw" }, 16 + { "did": "did:plc:bob", "ciphertext": "...", "algo": "x25519-hkdf-a256kw" } 17 + ] 18 + }`; 19 + 20 + export const keyringRefShape = `{ 21 + "keyring": "at://did:plc:owner/app.opake.keyring/abc123", 22 + "wrappedContentKey": "<bytes>", // content key encrypted under group key 23 + "rotation": 3 // which generation of the group key 24 + }`; 25 + 26 + export const encryptedMetadataShape = `{ 27 + "ciphertext": "<AES-256-GCM ciphertext>", 28 + "nonce": "<12-byte IV>" 29 + } 30 + 31 + // Plaintext (after decryption) is a JSON object: 32 + { 33 + "name": "budget.pdf", 34 + "mimeType": "application/pdf", 35 + "size": 4821, 36 + "description": "Q4 planning doc", 37 + "tags": ["finance", "q4"], 38 + "createdAt": "2026-04-24T10:00:00Z" 39 + }`; 40 + 41 + export const documentRecordShape = `{ 42 + "$type": "app.opake.document", 43 + "opakeVersion": 1, 44 + "blob": { 45 + "$type": "blob", 46 + "ref": { "$link": "bafyreib..." }, 47 + "mimeType": "application/octet-stream", 48 + "size": 4821 49 + }, 50 + "encryption": { 51 + // Cabinet / direct-share case: 52 + "$type": "app.opake.document#directEncryption", 53 + "envelope": { /* encryptionEnvelope, see above */ } 54 + 55 + // ...or workspace case: 56 + // "$type": "app.opake.document#keyringEncryption", 57 + // "keyringRef": { /* keyringRef, see above */ }, 58 + // "algo": "aes-256-gcm", 59 + // "nonce": "<bytes>" 60 + }, 61 + "encryptedMetadata": { /* see above */ }, 62 + "visibility": "private", 63 + "createdAt": "2026-04-24T10:00:00Z" 64 + }`; 65 + 66 + export const grantRecordShape = `{ 67 + "$type": "app.opake.grant", 68 + "opakeVersion": 1, 69 + "document": "at://did:plc:alice/app.opake.document/xyz789", 70 + "recipient": "did:plc:bob", 71 + "wrappedKey": { 72 + "did": "did:plc:bob", 73 + "ciphertext": "<content key, re-wrapped to Bob>", 74 + "algo": "x25519-hkdf-a256kw" 75 + }, 76 + "createdAt": "2026-04-24T10:05:00Z" 77 + }`; 78 + 79 + export const keyringRecordShape = `{ 80 + "$type": "app.opake.keyring", 81 + "opakeVersion": 1, 82 + "members": { 83 + "did:plc:alice": { "wrappedKey": {...}, "role": "manager" }, 84 + "did:plc:bob": { "wrappedKey": {...}, "role": "editor" }, 85 + "did:plc:carol": { "wrappedKey": {...}, "role": "viewer" } 86 + }, 87 + "rotation": 3, 88 + "keyHistory": { 89 + "0": { /* prior rotation's members, for historical decryption */ }, 90 + "1": { /* ... */ }, 91 + "2": { /* ... */ } 92 + }, 93 + "encryptedMetadata": { /* name, description, icon */ }, 94 + "createdAt": "2026-04-20T15:00:00Z" 95 + }`; 96 + 97 + export const documentUpdateShape = `{ 98 + "$type": "app.opake.documentUpdate", 99 + "opakeVersion": 1, 100 + "target": "at://did:plc:owner/app.opake.document/xyz789", 101 + "keyring": "at://did:plc:owner/app.opake.keyring/abc123", 102 + "actionType": "replaceContent", // or "replaceMetadata" 103 + "newBlob": { /* blob ref */ }, 104 + "newNonce": "<bytes>", 105 + "encryptedMetadata": { /* optional */ }, 106 + "createdAt": "2026-04-24T10:10:00Z" 107 + }`; 108 + 109 + export const directoryUpdateShape = `{ 110 + "$type": "app.opake.directoryUpdate", 111 + "opakeVersion": 1, 112 + "keyring": "at://did:plc:owner/app.opake.keyring/abc123", 113 + "actionType": "move", // or "create" / "rename" / "delete" / "placement" 114 + "target": "at://did:plc:owner/app.opake.directory/old-parent", 115 + "entry": "at://did:plc:owner/app.opake.document/xyz789", 116 + "newParent": "at://did:plc:owner/app.opake.directory/new-parent", 117 + "createdAt": "2026-04-24T10:15:00Z" 118 + }`;
+445
apps/web/src/content/docs/build/react/_snippets.ts
··· 1 + // Code snippets for the @opake/react docs pages. 2 + // MDX 3 dedents multi-line template literals inside .mdx files; a .ts 3 + // import bypasses that. See build/sdk/_snippets.ts for context. 4 + 5 + // -- react/overview.mdx ----------------------------------------------------- 6 + 7 + export const providerSetup = `import { Opake } from "@opake/sdk"; 8 + import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb"; 9 + import { OpakeProvider } from "@opake/react"; 10 + 11 + const storage = new IndexedDbStorage(); 12 + const opake = await Opake.init({ storage }); 13 + 14 + // Once per app, at or near the root. Everything under it gets useOpake, 15 + // useFileManager, useDirectory, plus the subscription-backed hooks. 16 + <OpakeProvider opake={opake}> 17 + <App /> 18 + </OpakeProvider>`; 19 + 20 + export const helloHook = `import { useOpake, useDirectory } from "@opake/react"; 21 + 22 + function CabinetRoot() { 23 + const opake = useOpake(); // raw Opake instance, for things not wrapped yet 24 + 25 + // Live-subscribed snapshot of the cabinet's root directory. Pass null 26 + // for the keyringUri (= cabinet) and null for the directoryUri (= root). 27 + // Re-renders on every upload, rename, move, delete, or remote change. 28 + const { snapshot, isReady } = useDirectory(null, null); 29 + 30 + if (!isReady) return <p>Loading\u2026</p>; 31 + if (!snapshot?.rootUri) return <p>Empty cabinet.</p>; 32 + 33 + const root = snapshot.directories[snapshot.rootUri]; 34 + return ( 35 + <ul> 36 + {root?.entries.map((entry) => ( 37 + <li key={entry.uri}>{entry.uri.split("/").pop()}</li> 38 + ))} 39 + </ul> 40 + ); 41 + }`; 42 + 43 + export const providerWithQueryClient = `import { QueryClient } from "@tanstack/react-query"; 44 + import { OpakeProvider } from "@opake/react"; 45 + 46 + // If your app already has a QueryClient (most TanStack-using apps do), 47 + // pass it in. OpakeProvider registers its own queries inside your 48 + // shared client instead of maintaining a parallel one. 49 + const queryClient = new QueryClient({ 50 + defaultOptions: { queries: { staleTime: 60_000 } }, 51 + }); 52 + 53 + <OpakeProvider opake={opake} queryClient={queryClient}> 54 + <App /> 55 + </OpakeProvider>`; 56 + 57 + export const providerDisableSse = `// Rare: you're gating the SSE consumer on something app-specific 58 + // (feature flag, explicit user opt-in, offline mode) and want to start 59 + // it yourself. 60 + <OpakeProvider opake={opake} disableSseAutoStart> 61 + <App /> 62 + </OpakeProvider> 63 + 64 + // Somewhere else, when you're ready: 65 + import { useStartSseConsumer } from "@opake/react"; 66 + 67 + function OfflineAwareGate() { 68 + const online = useNavigatorOnline(); 69 + useStartSseConsumer(online ? undefined : null); 70 + return <Outlet />; 71 + }`; 72 + 73 + // -- react/queries.mdx ------------------------------------------------------ 74 + 75 + export const useDirectoryExample = `import { useDirectory } from "@opake/react"; 76 + 77 + function Directory({ keyringUri, uri }: { 78 + keyringUri: string | null; // null for cabinet, keyring URI for a workspace 79 + uri: string | null; // null to watch the context's root 80 + }) { 81 + const { snapshot, isReady, error, retry } = useDirectory(keyringUri, uri); 82 + 83 + if (error) return <RetryBanner message={error.message} onRetry={retry} />; 84 + if (!isReady) return <p>Loading\u2026</p>; 85 + if (!snapshot) return <p>This directory no longer exists.</p>; 86 + 87 + return <DirectoryTreeView snapshot={snapshot} focusedUri={uri ?? snapshot.rootUri} />; 88 + }`; 89 + 90 + export const useDirectoryMetadataExample = `import { useDirectoryMetadata } from "@opake/react"; 91 + 92 + function DirectoryFilenames({ keyringUri, directoryUri }: { 93 + keyringUri: string | null; 94 + directoryUri: string; 95 + }) { 96 + // Thin read: just the decrypted metadata for documents in one 97 + // directory (filename, MIME type, size, tags, descriptions). 98 + const { data: metadata, isLoading } = useDirectoryMetadata(keyringUri, directoryUri); 99 + if (isLoading) return <p>Loading\u2026</p>; 100 + 101 + return ( 102 + <ul> 103 + {Object.entries(metadata ?? {}).map(([docUri, meta]) => ( 104 + <li key={docUri}> 105 + <a href={\`/doc/\${encodeURIComponent(docUri)}\`}>{meta.name}</a> 106 + <small> ({meta.mimeType}, {meta.size} bytes)</small> 107 + </li> 108 + ))} 109 + </ul> 110 + ); 111 + }`; 112 + 113 + export const useWorkspacesExample = `import { useWorkspaces } from "@opake/react"; 114 + 115 + function WorkspaceList() { 116 + const { data: workspaces, isLoading } = useWorkspaces(); 117 + if (isLoading) return <p>Loading workspaces\u2026</p>; 118 + 119 + return ( 120 + <ul> 121 + {workspaces.map((ws) => ( 122 + <li key={ws.uri}> 123 + {ws.name || "Unnamed workspace"}{" "} 124 + <small>({ws.memberCount} member{ws.memberCount === 1 ? "" : "s"})</small> 125 + </li> 126 + ))} 127 + </ul> 128 + ); 129 + }`; 130 + 131 + export const useInboxExample = `import { useInbox } from "@opake/react"; 132 + 133 + function Inbox() { 134 + const { data: inbox, isLoading } = useInbox(); 135 + if (isLoading) return <p>Loading inbox\u2026</p>; 136 + 137 + return ( 138 + <ul> 139 + {inbox.map((grant) => ( 140 + <li key={grant.uri}> 141 + <code>{grant.documentUri}</code>{" "} 142 + <small>from {grant.ownerDid}</small> 143 + </li> 144 + ))} 145 + </ul> 146 + ); 147 + }`; 148 + 149 + export const useSharesExample = `import { useShares } from "@opake/react"; 150 + 151 + function ShareList({ documentUri }: { documentUri: string }) { 152 + const { data: grants, isLoading } = useShares(documentUri); 153 + if (isLoading) return <p>Loading\u2026</p>; 154 + 155 + return ( 156 + <ul> 157 + {grants?.map((g) => ( 158 + <li key={g.uri}> 159 + Shared with {g.recipient} on {new Date(g.createdAt).toLocaleDateString()} 160 + </li> 161 + ))} 162 + </ul> 163 + ); 164 + }`; 165 + 166 + export const usePendingSharesExample = `import { usePendingShares, useCancelPendingShare } from "@opake/react"; 167 + 168 + function PendingList() { 169 + const { data: pending, isLoading } = usePendingShares(); 170 + const cancel = useCancelPendingShare(); 171 + if (isLoading) return null; 172 + 173 + return ( 174 + <ul> 175 + {pending?.map((p) => ( 176 + <li key={p.uri}> 177 + {p.document} \u2192 {p.recipient}{" "} 178 + <button onClick={() => cancel.mutate(p.uri)} disabled={cancel.isPending}> 179 + Cancel 180 + </button> 181 + </li> 182 + ))} 183 + </ul> 184 + ); 185 + }`; 186 + 187 + // -- react/mutations.mdx ---------------------------------------------------- 188 + 189 + export const useUploadExample = `import { useUpload } from "@opake/react"; 190 + 191 + function UploadButton({ keyringUri, directoryUri }: { 192 + keyringUri: string | null; // null for cabinet 193 + directoryUri: string; 194 + }) { 195 + const upload = useUpload(keyringUri); 196 + 197 + const onFile = async (file: File) => { 198 + const data = new Uint8Array(await file.arrayBuffer()); 199 + await upload.mutateAsync({ 200 + data, 201 + filename: file.name, 202 + mimeType: file.type || "application/octet-stream", 203 + directoryUri, 204 + }); 205 + // A placeholder row appears in any useDirectory watching 206 + // directoryUri immediately via the optimistic overlay; it's 207 + // replaced by the real entry once the SSE echo arrives. 208 + }; 209 + 210 + return <input type="file" onChange={(e) => onFile(e.target.files![0]!)} />; 211 + }`; 212 + 213 + export const useDownloadExample = `import { useDownload } from "@opake/react"; 214 + 215 + function DownloadButton({ keyringUri, documentUri }: { 216 + keyringUri: string | null; 217 + documentUri: string; 218 + }) { 219 + const download = useDownload(keyringUri); 220 + 221 + const onClick = async () => { 222 + const { filename, data } = await download.mutateAsync(documentUri); 223 + const url = URL.createObjectURL(new Blob([data])); 224 + const a = document.createElement("a"); 225 + a.href = url; 226 + a.download = filename; 227 + a.click(); 228 + URL.revokeObjectURL(url); 229 + }; 230 + 231 + return ( 232 + <button onClick={onClick} disabled={download.isPending}> 233 + {download.isPending ? "Downloading\u2026" : "Download"} 234 + </button> 235 + ); 236 + }`; 237 + 238 + export const useDeleteExample = `import { useDelete } from "@opake/react"; 239 + 240 + function DeleteButton({ keyringUri, documentUri, parentDirectoryUri }: { 241 + keyringUri: string | null; 242 + documentUri: string; 243 + parentDirectoryUri: string; 244 + }) { 245 + const del = useDelete(keyringUri); 246 + 247 + return ( 248 + <button 249 + onClick={() => del.mutate({ documentUri, parentDirectoryUri })} 250 + disabled={del.isPending} 251 + > 252 + {del.isPending ? "Deleting\u2026" : "Delete"} 253 + </button> 254 + ); 255 + }`; 256 + 257 + export const useMoveExample = `import { useMove } from "@opake/react"; 258 + 259 + function useDragDropMove(keyringUri: string | null) { 260 + const move = useMove(keyringUri); 261 + 262 + return (entry: { uri: string; parentUri: string }, targetDirUri: string) => { 263 + move.mutate({ 264 + entryUri: entry.uri, 265 + sourceDirUri: entry.parentUri, 266 + targetDirUri, 267 + }); 268 + // The overlay shows the entry in its new directory immediately; 269 + // the real tree update arrives via SSE and dedups against the overlay. 270 + }; 271 + }`; 272 + 273 + export const useDirectoryMutationsExample = `import { 274 + useCreateDirectory, 275 + useRenameDirectory, 276 + useDeleteDirectory, 277 + } from "@opake/react"; 278 + 279 + function DirectoryActions({ keyringUri, parentUri }: { 280 + keyringUri: string | null; 281 + parentUri: string; 282 + }) { 283 + const create = useCreateDirectory(keyringUri); 284 + const rename = useRenameDirectory(keyringUri); 285 + const remove = useDeleteDirectory(keyringUri); 286 + 287 + return ( 288 + <> 289 + <button onClick={() => create.mutate({ name: "New Folder", parentUri })}> 290 + New folder 291 + </button> 292 + <button 293 + onClick={() => 294 + rename.mutate({ directoryUri: parentUri, newName: "Renamed" }) 295 + } 296 + > 297 + Rename 298 + </button> 299 + <button onClick={() => remove.mutate({ directoryUri: parentUri })}> 300 + Delete folder 301 + </button> 302 + </> 303 + ); 304 + }`; 305 + 306 + export const useShareMutationsExample = `import { OpakeError } from "@opake/sdk"; 307 + import { useShareFile, useRevokeShare } from "@opake/react"; 308 + 309 + function ShareButton({ documentUri, handle }: { 310 + documentUri: string; 311 + handle: string; 312 + }) { 313 + const share = useShareFile(); 314 + 315 + const onClick = async () => { 316 + const result = await share.mutateAsync({ 317 + documentUri, 318 + handleOrDid: handle, 319 + note: "Here's that file you asked for", 320 + }); 321 + // result.pending === true means the recipient hasn't published 322 + // an encryption key yet; the daemon retries until they do. 323 + notify(result.pending ? "Queued until recipient is ready" : "Shared"); 324 + }; 325 + 326 + return ( 327 + <button onClick={onClick} disabled={share.isPending}> 328 + Share with {handle} 329 + </button> 330 + ); 331 + } 332 + 333 + function RevokeButton({ grantUri }: { grantUri: string }) { 334 + const revoke = useRevokeShare(); 335 + return ( 336 + <button onClick={() => revoke.mutate(grantUri)} disabled={revoke.isPending}> 337 + Revoke 338 + </button> 339 + ); 340 + }`; 341 + 342 + export const useCreateWorkspaceExample = `import { useState } from "react"; 343 + import { useCreateWorkspace } from "@opake/react"; 344 + 345 + function NewWorkspaceForm() { 346 + const create = useCreateWorkspace(); 347 + const [name, setName] = useState(""); 348 + 349 + return ( 350 + <form 351 + onSubmit={(e) => { 352 + e.preventDefault(); 353 + create.mutate({ name }); 354 + // The new workspace appears in useWorkspaces() automatically 355 + // via the SSE keyring:upsert echo. No cache invalidation needed. 356 + }} 357 + > 358 + <input value={name} onChange={(e) => setName(e.target.value)} /> 359 + <button disabled={!name || create.isPending}>Create workspace</button> 360 + </form> 361 + ); 362 + }`; 363 + 364 + export const overlayMechanics = `// You don't interact with the overlay directly — mutation hooks 365 + // apply patches in onMutate and release them 2s after settle. The 366 + // timeline, for a useMove: 367 + // 368 + // t+0 move.mutate({...}) fires 369 + // t+0 overlay patch applied \u2192 useDirectory re-renders with 370 + // the entry in its new directory 371 + // t+~150ms PDS write completes \u2192 mutation resolves 372 + // t+~1s SSE echo arrives \u2192 TreeKeeper updates, base 373 + // snapshot reflects the move 374 + // t+2s overlay patch released \u2192 no-op because the base 375 + // snapshot already agrees 376 + // 377 + // If the mutation fails (network hiccup, auth expired, permission 378 + // denied), the overlay releases immediately in onError and the UI 379 + // snaps back to the pre-mutation tree.`; 380 + 381 + // -- react/live-updates.mdx ------------------------------------------------- 382 + 383 + export const useStartSseGated = `import { useStartSseConsumer } from "@opake/react"; 384 + 385 + function SseGate({ children }: { children: ReactNode }) { 386 + const user = useAuthUser(); 387 + // Skip starting when there's no authenticated user. Starting anyway 388 + // would trigger a token exchange that fails with Auth. 389 + useStartSseConsumer(user ? undefined : null); 390 + return <>{children}</>; 391 + }`; 392 + 393 + export const useStartSseOverride = `// Override the indexer URL at runtime. Wins over the user's PDS 394 + // accountConfig for the rest of the Opake instance's lifetime. 395 + useStartSseConsumer("https://indexer.example.com");`; 396 + 397 + export const useDaemonExample = `import { useDaemon } from "@opake/react"; 398 + import { Opake } from "@opake/sdk"; 399 + 400 + function DaemonRunner({ taskStore }: { taskStore: TaskStore }) { 401 + // Side-effect hook: starts the daemon on mount, stops it on unmount. 402 + // Returns void \u2014 task state lives in your TaskStore, not in React. 403 + useDaemon({ 404 + taskDefs: Opake.taskDefs(), 405 + taskStore, 406 + onSessionExpired: () => { 407 + // Your app's logout path \u2014 the daemon hit a dead session and 408 + // can't continue without re-auth. 409 + window.location.href = "/login"; 410 + }, 411 + }); 412 + 413 + return null; // or a small status indicator wired to taskStore 414 + }`; 415 + 416 + export const manualInvalidation = `import { useQueryClient } from "@tanstack/react-query"; 417 + import { opakeKeys } from "@opake/react"; 418 + 419 + function RefreshButton({ documentUri }: { documentUri: string }) { 420 + const qc = useQueryClient(); 421 + return ( 422 + <button 423 + onClick={() => { 424 + // Most state updates via SSE. These factories are for the few 425 + // queries that don't (shares, pending shares, tasks) \u2014 hit 426 + // them if you know an external write landed outside the stream. 427 + qc.invalidateQueries({ queryKey: opakeKeys.shares(documentUri) }); 428 + qc.invalidateQueries({ queryKey: opakeKeys.pendingShares() }); 429 + }} 430 + > 431 + Refresh 432 + </button> 433 + ); 434 + }`; 435 + 436 + export const keyFactoriesShape = `opakeKeys.all(); // every opake query 437 + opakeKeys.cabinetTree(); // cabinet directory tree 438 + opakeKeys.workspaceTree(keyringUri); // one workspace's tree 439 + opakeKeys.metadata(directoryUri); // decrypted doc metadata for a dir 440 + opakeKeys.tasks(); // daemon task records 441 + opakeKeys.identity(handleOrDid); // resolved identity cache 442 + opakeKeys.inbox(); // incoming shares (legacy \u2014 SSE now) 443 + opakeKeys.sharesAll(); // every shares query across docs 444 + opakeKeys.shares(documentUri); // outgoing shares for one doc 445 + opakeKeys.pendingShares(); // queued shares waiting on recipient`;
+125
apps/web/src/content/docs/build/react/live-updates.mdx
··· 1 + import { 2 + useStartSseGated, 3 + useStartSseOverride, 4 + useDaemonExample, 5 + manualInvalidation, 6 + keyFactoriesShape, 7 + } from "./_snippets"; 8 + 9 + <ChapterHeader title="@opake/react — Live updates" /> 10 + 11 + <Lead> 12 + The provider auto-starts the SSE consumer, so most apps don't need to think about live 13 + updates at all. This page covers the escape hatches: gating the stream on runtime 14 + conditions, running the daemon for background maintenance, and invalidating the few 15 + queries that don't flow through SSE. 16 + </Lead> 17 + 18 + ## What's live by default 19 + 20 + Render an `OpakeProvider` and you get: 21 + 22 + - `useDirectory` — directory trees update as `document:upsert`, `directory:upsert`, 23 + `document:delete`, `directory:delete` events arrive from the indexer. 24 + - `useWorkspaces` — workspace list updates on `keyring:upsert` / `keyring:delete`. 25 + - `useInbox` — incoming shares update on `grant:upsert` / `grant:delete` for the caller's 26 + personal topic. 27 + - Mutations — the optimistic overlay reflects writes in the same render tick, then dedups 28 + against the echo when it arrives. 29 + 30 + No polling, no timers, no manual refetching. A typical PDS-write-to-SSE-echo round-trip is 31 + under a second, so remote changes from other devices surface within that window. 32 + 33 + ## Gating the auto-start 34 + 35 + The provider's auto-start is unconditional: it fires a `startSseConsumer` call on mount. 36 + That's fine when you only render the provider after the user is authenticated. It's a 37 + problem when you wrap the whole app in the provider and rely on internal auth state to 38 + decide who's logged in — an unauthenticated start triggers a token-exchange request that 39 + fails with `Auth`. 40 + 41 + Two options. The clean one: render the provider conditionally. Unmount when the user signs 42 + out, mount when they sign in. `OpakeProvider` already cleans up on unmount (stops the 43 + stream, wipes keeper state). 44 + 45 + The escape hatch: set `disableSseAutoStart` and drive the start yourself with 46 + `useStartSseConsumer`: 47 + 48 + <CodeBlock language="tsx" title="sse-gate.tsx" code={useStartSseGated} /> 49 + 50 + `useStartSseConsumer` is deliberately start-only. There's no corresponding stop — it 51 + exists to let you delay the start past provider mount, not to toggle the stream on and 52 + off during a session. If you need stop-on-condition, use the conditional-provider pattern 53 + above. 54 + 55 + ### Override the indexer URL 56 + 57 + The consumer resolves its URL from a priority chain: runtime override → 58 + `accountConfig.indexerUrl` on the user's PDS → the compile-time default. Pass an explicit 59 + URL to `useStartSseConsumer` to win the chain: 60 + 61 + <CodeBlock language="tsx" title="override.ts" code={useStartSseOverride} /> 62 + 63 + Typical use: pinning to a specific indexer for testing, or overriding the default in a 64 + development build. 65 + 66 + ## The daemon 67 + 68 + `@opake/react` ships a side-effect hook for the background daemon: 69 + 70 + <CodeBlock language="tsx" title="daemon-runner.tsx" code={useDaemonExample} /> 71 + 72 + The daemon is pure maintenance now — SSE replaced the tree-sync job, but the daemon still 73 + runs: 74 + 75 + - Pending-share retries (when a recipient publishes their encryption key, the daemon 76 + promotes queued pending shares to real grants). 77 + - Stale pair-request cleanup (requests past their TTL get deleted from the PDS). 78 + - Grant-healing (finding and repairing grants whose wrapped key is missing or 79 + invalidated by a keyring rotation). 80 + 81 + `useDaemon` is imperative: it starts the daemon on mount, stops it on unmount, and returns 82 + nothing. Task state lives in the `TaskStore` you pass in — implement the interface with 83 + IndexedDB (for persistence across reloads) or an in-memory Map (for tests / SSR). The 84 + daemon's own data flow is opaque to React; if you want to surface task status, subscribe 85 + to the `TaskStore` from your own component state. 86 + 87 + `onSessionExpired` is called exactly once when a task fails with an `Auth` error. Wire it 88 + to your app's logout path — by the time this fires the session is dead and no further 89 + Opake calls will succeed without re-auth. 90 + 91 + ## Manual invalidation 92 + 93 + Most state is SSE-driven. The few queries that aren't: 94 + 95 + - `useShares(documentUri)` / `usePendingShares()` — react-query backed, invalidated 96 + automatically by `useShareFile` / `useRevokeShare` / `useCancelPendingShare` on success. 97 + If you know an external write landed (e.g. a CLI on another device revoked a grant), 98 + invalidate manually. 99 + - `useDirectoryMetadata(keyringUri, directoryUri)` — invalidated by every tree mutation 100 + the React layer runs. Not invalidated for writes from other devices. 101 + - Daemon task state — lives in your `TaskStore`, separate from react-query. 102 + 103 + For the first two, `opakeKeys` gives you stable query-key factories: 104 + 105 + <CodeBlock language="tsx" title="refresh-button.tsx" code={manualInvalidation} /> 106 + 107 + The full list of key factories: 108 + 109 + <CodeBlock language="typescript" title="opakeKeys" code={keyFactoriesShape} /> 110 + 111 + Invalidating `opakeKeys.all()` nukes every Opake-owned query, which forces a cold refetch 112 + of everything react-query caches. Useful as a "something's really off, start over" button; 113 + heavy otherwise. 114 + 115 + ## Account switching 116 + 117 + Passing a new `Opake` instance to the provider's `opake` prop creates a fresh cache. The 118 + overlay, FileManager cache, and keeper state all rebuild from the new instance; patches 119 + and cached FileManagers from the old identity don't leak across. 120 + 121 + If your app supports multiple signed-in accounts, the typical pattern is: keep one 122 + `OpakeProvider` at the root, re-key it when the active account changes. The WASM module 123 + stops the old stream + wipes keeper state on unmount, then starts fresh on the new mount. 124 + 125 + <DocsNext slug="live-updates" />
+149
apps/web/src/content/docs/build/react/mutations.mdx
··· 1 + import { 2 + useUploadExample, 3 + useDownloadExample, 4 + useDeleteExample, 5 + useMoveExample, 6 + useDirectoryMutationsExample, 7 + useShareMutationsExample, 8 + useCreateWorkspaceExample, 9 + overlayMechanics, 10 + } from "./_snippets"; 11 + 12 + <ChapterHeader title="@opake/react — Writing hooks" /> 13 + 14 + <Lead> 15 + Mutation hooks wrap the SDK's write operations. Every tree-level mutation is paired with 16 + an optimistic overlay patch so the UI reflects the change synchronously; hooks that 17 + don't affect the tree (sharing, workspace creation, downloads) go through plain 18 + react-query mutations. 19 + </Lead> 20 + 21 + ## The keyringUri parameter 22 + 23 + Every tree mutation hook takes a `keyringUri: string | null`. Pass `null` for the cabinet 24 + or a workspace's keyring URI otherwise. The keyringUri determines: 25 + 26 + - Which `FileManager` the hook acquires from the provider's cache. 27 + - Which optimistic overlay scope receives the patch (so `useDirectory` watching the same 28 + scope re-renders optimistically). 29 + - Which query-cache entry gets invalidated on settle. 30 + 31 + If your component is rendered for a single context, thread the keyringUri in via props or 32 + a context value and pass it once. Don't rebuild it per call — each distinct keyringUri 33 + builds a separate overlay scope. 34 + 35 + ## File operations 36 + 37 + ### `useUpload(keyringUri)` 38 + 39 + <CodeBlock language="tsx" title="upload-button.tsx" code={useUploadExample} /> 40 + 41 + The input carries `{ data: Uint8Array, filename, mimeType, description?, tags?, 42 + directoryUri? }`. Omit `directoryUri` to upload to the context root. The optimistic 43 + overlay inserts a placeholder entry in the target directory right away; the real entry 44 + replaces it when the SSE echo arrives (~1s after PDS write). 45 + 46 + Results are `UploadResult = { uri, proposed }` from the SDK. In a workspace you don't own, 47 + `proposed: true` means the upload landed as a `documentUpdate` proposal on your own PDS; 48 + the workspace owner's daemon applies it. See [workspaces](/docs/sdk/workspaces) for the 49 + proposal flow. 50 + 51 + ### `useDownload(keyringUri)` 52 + 53 + <CodeBlock language="tsx" title="download-button.tsx" code={useDownloadExample} /> 54 + 55 + Downloads aren't cached — the hook is a `useMutation`, not a `useQuery`, because caching 56 + decrypted plaintext in react-query would leak it into memory for longer than necessary. 57 + Each call re-fetches and re-decrypts. 58 + 59 + Returns `{ filename, data: Uint8Array }`. `filename` is the decrypted original name; 60 + `data` is the plaintext. Both are safe to drop from memory as soon as you've handed them 61 + off (to a `Blob`, a worker, whatever). 62 + 63 + ### `useDelete(keyringUri)` 64 + 65 + <CodeBlock language="tsx" title="delete-button.tsx" code={useDeleteExample} /> 66 + 67 + `parentDirectoryUri` is required. The delete is atomic with removing the entry from its 68 + parent, so the hook needs to know which directory to patch. The optimistic overlay filters 69 + the entry out of the parent immediately; the SSE echo confirms. 70 + 71 + ### `useMove(keyringUri)` 72 + 73 + <CodeBlock language="tsx" title="drag-drop.tsx" code={useMoveExample} /> 74 + 75 + Moves work across directories in the same context only. A cabinet document can move 76 + between cabinet directories; a workspace document can move between directories in that 77 + workspace. Cross-context moves (workspace → cabinet, cabinet → workspace) aren't a move — 78 + they're a download + upload + delete, and the SDK deliberately doesn't hide that cost 79 + behind a single call. 80 + 81 + The input is `{ entryUri, sourceDirUri, targetDirUri }`. Works for both documents and 82 + sub-directories — the tree operation is uniform. 83 + 84 + ## Directory operations 85 + 86 + <CodeBlock language="tsx" title="directory-actions.tsx" code={useDirectoryMutationsExample} /> 87 + 88 + - `useCreateDirectory(keyringUri)` takes `{ name, parentUri? }`. Omit `parentUri` to 89 + create at the root. The optimistic overlay inserts a placeholder directory entry 90 + immediately; the real entry replaces it on echo. 91 + - `useRenameDirectory(keyringUri)` takes `{ directoryUri, newName }`. Renames stay 92 + optimistic: the tree reflects the new name synchronously. 93 + - `useDeleteDirectory(keyringUri)` takes `{ directoryUri }` and recursively deletes the 94 + directory and every document inside it. Returns a `{ documentsDeleted, 95 + directoriesDeleted }` count on success. 96 + 97 + ## Sharing 98 + 99 + <CodeBlock language="tsx" title="share-buttons.tsx" code={useShareMutationsExample} /> 100 + 101 + `useShareFile` takes `{ documentUri, handleOrDid, note? }`. All grants are read-only; 102 + there's no permissions parameter. On success the mutation returns `{ pending: false }` for 103 + a direct share, or `{ pending: true }` when the recipient has a valid atproto identity but 104 + hasn't published an Opake encryption key yet — at which point it's been queued as a 105 + pending share for the daemon to retry. 106 + 107 + On success the hook invalidates both the per-document `useShares(documentUri)` query and 108 + the top-level `usePendingShares()` query, so the lists refresh automatically. 109 + 110 + `useRevokeShare` takes a grant URI. Deletes the grant record from your PDS; the recipient 111 + sees the grant disappear from their `useInbox()` on the next firehose round-trip. All 112 + `useShares` queries are invalidated on success. 113 + 114 + ## Workspaces 115 + 116 + <CodeBlock language="tsx" title="new-workspace-form.tsx" code={useCreateWorkspaceExample} /> 117 + 118 + `useCreateWorkspace` takes `{ name, description? }` and returns `{ keyringUri, key }` 119 + (the `key` is the group key; you don't usually need it — subsequent calls re-resolve via 120 + the URI and the caller's Identity). 121 + 122 + The new workspace appears in `useWorkspaces()` automatically via the SSE `keyring:upsert` 123 + echo — no cache invalidation needed. Membership management hooks (add/remove member, 124 + leave workspace) aren't wrapped yet; call `opake.addWorkspaceMember(...)` / 125 + `opake.removeWorkspaceMember(...)` / `opake.leaveWorkspace(...)` directly from `useOpake`. 126 + 127 + ## How the optimistic overlay behaves 128 + 129 + <CodeBlock language="typescript" title="timeline" code={overlayMechanics} /> 130 + 131 + Under the hood: 132 + 133 + - Each mutation hook wraps `useTreeMutation`, which takes an optional `optimisticUpdate` 134 + function `(snapshot, input) => snapshot`. That function is called on `onMutate` to 135 + compute the patched snapshot; the patch gets pushed onto the provider's `OptimisticOverlay`. 136 + - `useDirectory` reads `snapshot.project(scope, base)` — the base from the keeper, with 137 + overlay patches composed on top. The projection is a no-op when there are no patches, 138 + so there's no per-render cost when nothing is in flight. 139 + - Patches release 2 seconds after the mutation settles. The delay spans the typical 140 + PDS-write-to-SSE-echo round-trip; dropping the patch earlier would briefly reveal the 141 + pre-mutation tree between the SDK promise resolving and the echo arriving. 142 + - On error, the patch releases immediately (no server-side state to wait for) and the UI 143 + snaps back. 144 + 145 + If you build a custom mutation that needs the same behaviour, `useTreeMutation` is 146 + exported. Pass your own `mutationFn` and `optimisticUpdate` — see the source of 147 + `useUpload` / `useMove` for real examples. 148 + 149 + <DocsNext slug="mutations" />
+107
apps/web/src/content/docs/build/react/overview.mdx
··· 1 + import { 2 + providerSetup, 3 + helloHook, 4 + providerWithQueryClient, 5 + providerDisableSse, 6 + } from "./_snippets"; 7 + 8 + <ChapterHeader title="@opake/react — Overview" /> 9 + 10 + <Lead> 11 + React bindings on top of `@opake/sdk`. One provider at the root, a handful of hooks for 12 + common read/write patterns, and an optimistic overlay that keeps the UI in sync during 13 + in-flight mutations. 14 + </Lead> 15 + 16 + ## Install 17 + 18 + <CodeBlock language="sh" code={`bun add @opake/react @opake/sdk @tanstack/react-query react`} /> 19 + 20 + `@opake/react` depends on `@opake/sdk` and `@tanstack/react-query`. React 18+ is required; 21 + the hooks use modern concurrent features. 22 + 23 + ## The provider 24 + 25 + <CodeBlock language="tsx" title="provider-setup.tsx" code={providerSetup} /> 26 + 27 + `OpakeProvider` does three things: 28 + 29 + - Exposes the passed `Opake` instance via `useOpake()`. 30 + - Holds a refcounted `FileManagerCache` so multiple components asking for the same 31 + `FileManager` share one handle. The cache clears when the provider unmounts. 32 + - Starts the WASM SSE consumer on mount and stops it + wipes keepers on unmount. This is 33 + why the provider is a *component*: its lifecycle is where the live-update stream lives. 34 + 35 + All hooks described on the other pages assume they're rendered inside this provider. 36 + 37 + ## A first hook 38 + 39 + <CodeBlock language="tsx" title="hello-hook.tsx" code={helloHook} /> 40 + 41 + `useDirectory(keyringUri, directoryUri)` is a subscription. The first argument selects the 42 + context (null for the cabinet, a workspace keyring URI otherwise); the second picks the 43 + directory to watch (null means "resolve the root of this context"). The initial snapshot 44 + comes from cache plus a delta sync; subsequent snapshots come from SSE events. No polling, 45 + no manual invalidation. 46 + 47 + The returned shape is `{ snapshot, isReady, error, resolvedDirectoryUri, retry }`. While 48 + the watcher is installing, `snapshot` is `null` and `isReady` is `false`. Once the first 49 + fire lands, `snapshot` becomes a `DirectoryTreeSnapshot` and `isReady` flips to `true`. 50 + If the watched directory gets deleted (by a move, a recursive delete, or a remote peer), 51 + the watcher fires one last time with a null snapshot and closes — at that point `isReady` 52 + is `false` again and `snapshot` stays null. If you need to distinguish "still loading" 53 + from "was here, now gone", track a "have I ever been ready" latch in local state. 54 + 55 + ## Custom QueryClient 56 + 57 + Most apps already have a `@tanstack/react-query` client. Pass it in so Opake's queries share 58 + the same cache: 59 + 60 + <CodeBlock 61 + language="tsx" 62 + title="with-query-client.tsx" 63 + code={providerWithQueryClient} 64 + /> 65 + 66 + Without the `queryClient` prop, `OpakeProvider` creates its own default. That's fine for 67 + small apps but means Opake and the rest of your app run on separate caches; invalidation 68 + from one side doesn't cascade to the other. 69 + 70 + ## Disabling the auto-start 71 + 72 + Most apps want the SSE consumer to start automatically with the provider. A few don't: apps 73 + with an offline mode, apps behind a feature flag, apps that want to start the stream only 74 + after the user explicitly goes online. 75 + 76 + <CodeBlock 77 + language="tsx" 78 + title="disable-sse.tsx" 79 + code={providerDisableSse} 80 + /> 81 + 82 + `disableSseAutoStart` turns off the provider's built-in start. Use `useStartSseConsumer` 83 + wherever you want to control the timing. Passing `null` to it skips the start entirely; 84 + passing `undefined` (or any string URL) triggers it. The WASM consumer is idempotent, so 85 + calling from multiple places is safe. 86 + 87 + ## The optimistic overlay 88 + 89 + When a mutation hook fires (e.g. `useMove`), Opake doesn't wait for the round-trip to 90 + update your UI. It applies a local patch to the directory-tree snapshot, synchronously. 91 + Components subscribed via `useDirectory` re-render with the moved entry in its new 92 + location within the same render tick. 93 + 94 + When the real event arrives from SSE (or the mutation resolves, whichever is first), the 95 + overlay dedups the patch against the real state and drops it. If the mutation fails, the 96 + overlay rolls back. 97 + 98 + The overlay is per-`Opake`-instance. Account switching (passing a new `Opake` as 99 + `opake={...}`) creates a fresh overlay, so patches from one user don't project onto the 100 + next user's trees. 101 + 102 + You don't interact with the overlay directly; it's transparent. Mutation hooks return 103 + `react-query` mutation objects with the usual `mutate` / `mutateAsync` / `isPending` / 104 + `error` API, and reads happen through `useDirectory` which folds overlay patches in 105 + automatically. 106 + 107 + <DocsNext slug="overview" />
+131
apps/web/src/content/docs/build/react/queries.mdx
··· 1 + import { 2 + useDirectoryExample, 3 + useDirectoryMetadataExample, 4 + useWorkspacesExample, 5 + useInboxExample, 6 + useSharesExample, 7 + usePendingSharesExample, 8 + } from "./_snippets"; 9 + 10 + <ChapterHeader title="@opake/react — Reading hooks" /> 11 + 12 + <Lead> 13 + Read-only hooks split by delivery method. Three are subscriptions wired to the WASM 14 + keepers and re-render on SSE events within a firehose round-trip. Three are react-query 15 + queries that cache and re-fetch on invalidation — useful for state that doesn't flow 16 + through the live stream. 17 + </Lead> 18 + 19 + ## Subscriptions (SSE-backed) 20 + 21 + These hooks install a watcher on the WASM side, get one eager fire with current state, and 22 + then re-fire on every relevant SSE event. The `OpakeProvider` auto-starts the consumer, so 23 + everything below is "just works" inside a provider — no bootstrap plumbing. 24 + 25 + ### `useDirectory(keyringUri, directoryUri)` 26 + 27 + <CodeBlock language="tsx" title="directory-view.tsx" code={useDirectoryExample} /> 28 + 29 + The primary hook for rendering a directory tree. Pass `keyringUri = null` for the cabinet 30 + or a workspace's keyring URI. Pass `directoryUri = null` to watch the root of that context, 31 + or a specific directory URI otherwise. 32 + 33 + - `snapshot` is a `DirectoryTreeSnapshot` once the initial load lands, `null` before that 34 + (and `null` again if the watched directory was deleted). 35 + - `isReady` is the recommended "do I have data" check. It's `true` exactly when `snapshot` 36 + is non-null. 37 + - `error` is populated if `loadTree` or the watcher install threw. Show a retry UI and 38 + call `retry()` to rerun the acquisition. 39 + - `resolvedDirectoryUri` tells you the URI that's actually being watched — useful when you 40 + passed `null` and need to know where the root landed. 41 + 42 + Snapshots fold in the optimistic overlay automatically, so a mutation you fire shows up in 43 + the same render tick. See the [mutations page](/docs/react/mutations) for details. 44 + 45 + ### `useWorkspaces()` 46 + 47 + <CodeBlock language="tsx" title="workspace-list.tsx" code={useWorkspacesExample} /> 48 + 49 + Subscribes to the `WorkspaceKeeper`. First call bootstraps the keeper via `listWorkspaces`; 50 + subsequent SSE `keyring:upsert` / `keyring:delete` events patch the list in place. 51 + 52 + `data` is a `readonly WorkspaceEntry[]` with `{ uri, ownerDid, rotation, memberCount, 53 + createdAt, name, description, icon }`. `isLoading` is `true` until the keeper has 54 + bootstrapped once; after that it stays `false` even when the list is empty. The raw 55 + `snapshot.loaded` flag is also exposed if you need to distinguish the cold-start state from 56 + "loaded, currently empty." 57 + 58 + ### `useInbox()` 59 + 60 + <CodeBlock language="tsx" title="inbox.tsx" code={useInboxExample} /> 61 + 62 + Same subscription shape as `useWorkspaces`. Backed by the `InboxKeeper`, which the indexer 63 + fans into the caller's personal topic for both owner-side (you just shared something) and 64 + recipient-side (someone shared with you) events. 65 + 66 + Entries are `InboxGrant` values: `{ uri, ownerDid, documentUri, createdAt }`. To actually 67 + open an incoming share, call `opake.downloadFromGrant(grantUri)` or 68 + `opake.resolveGrantMetadata(grantUri)` — see the [SDK sharing page](/docs/sdk/sharing). 69 + 70 + ## Cached reads (react-query) 71 + 72 + These hooks don't subscribe to SSE. They fetch once per query key and cache the result. The 73 + `OpakeProvider` invalidates them automatically when a local mutation changes the relevant 74 + state; updates from other devices surface on the next manual invalidation or refetch 75 + trigger. 76 + 77 + ### `useDirectoryMetadata(keyringUri, directoryUri)` 78 + 79 + <CodeBlock 80 + language="tsx" 81 + title="directory-filenames.tsx" 82 + code={useDirectoryMetadataExample} 83 + /> 84 + 85 + Thin read for a directory's document metadata — filenames, MIME types, sizes, tags, 86 + descriptions. Useful when a list view needs names but you don't want the full subtree that 87 + `useDirectory` carries. 88 + 89 + Returns a react-query `{ data, isLoading, ... }` where `data` is 90 + `Readonly<Record<string, DocumentMetadata>>` keyed by document URI. The query is 91 + invalidated whenever any tree mutation fires (to keep things simple, the mutation hook 92 + invalidates the `["opake", "metadata"]` prefix), which in practice means this is live for 93 + the user's own writes but not for writes from other devices. 94 + 95 + ### `useShares(documentUri)` 96 + 97 + <CodeBlock language="tsx" title="shares-list.tsx" code={useSharesExample} /> 98 + 99 + List every active grant for a specific document. The underlying `listShares` call 100 + enumerates the caller's entire grant collection; the hook filters client-side to the single 101 + document you asked about. Cabinet-only — workspace sharing doesn't use grants. 102 + 103 + `useShareFile` and `useRevokeShare` invalidate this query automatically on success. 104 + 105 + ### `usePendingShares()` 106 + 107 + <CodeBlock 108 + language="tsx" 109 + title="pending-shares.tsx" 110 + code={usePendingSharesExample} 111 + /> 112 + 113 + Pending shares are the queue of "I wanted to share this with Bob, but Bob hasn't published 114 + an encryption key yet" entries. The daemon retries them on a schedule; the hook reads the 115 + current queue, and `useCancelPendingShare` drops one. 116 + 117 + The query is local-only — pending shares don't flow through SSE, they only exist in the 118 + caller's cabinet until the daemon completes or expires them. 119 + 120 + ## Composing with suspense / error boundaries 121 + 122 + The subscription hooks don't throw. Errors are surfaced on the `error` field (for 123 + `useDirectory`) or swallowed (for `useWorkspaces` / `useInbox`, since a bootstrap failure 124 + just leaves `isLoading` pinned true). Wire your own error boundary around the components 125 + that call these hooks if you want suspense-style flow. 126 + 127 + The react-query hooks behave like any other `useQuery` — they respect `QueryClient`-level 128 + `defaultOptions.queries.useErrorBoundary`, `retry`, and `suspense` if you opt in. See 129 + [react-query's docs](https://tanstack.com/query/latest) for the boundary patterns. 130 + 131 + <DocsNext slug="queries" />
+658
apps/web/src/content/docs/build/sdk/_snippets.ts
··· 1 + // Code snippets for the SDK overview and authentication pages. 2 + // 3 + // MDX 3 dedents multi-line template literals that appear inside .mdx files 4 + // (both in JSX children and in attribute positions). Moving the snippets 5 + // into a plain .ts file bypasses the MDX parser — template literals here 6 + // preserve indentation verbatim. 7 + 8 + // -- overview.mdx ----------------------------------------------------------- 9 + 10 + export const helloCabinet = `import { Opake } from "@opake/sdk"; 11 + import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb"; 12 + 13 + const storage = new IndexedDbStorage(); 14 + 15 + // Assumes the user has already logged in. See authentication.mdx. 16 + const opake = await Opake.init({ storage }); 17 + const fm = await opake.cabinet(); 18 + 19 + // Upload 20 + const bytes = new TextEncoder().encode("hello from an encrypted cabinet"); 21 + await fm.uploadAt(bytes, "greetings.txt", "text/plain"); 22 + 23 + // Read back 24 + const { plaintext, filename } = await fm.downloadAt("/greetings.txt"); 25 + console.log(filename, new TextDecoder().decode(plaintext)); 26 + // → "greetings.txt hello from an encrypted cabinet"`; 27 + 28 + export const staticSurface = `// OAuth two-step 29 + Opake.startLogin(handle, options); 30 + Opake.completeLogin(code, state, pending, options); 31 + Opake.loginWithAppPassword(options); 32 + 33 + // Seed-phrase recovery 34 + Opake.generateMnemonic(); 35 + Opake.validateSeedPhrase(phrase); 36 + Opake.createIdentity(phrase, did); 37 + 38 + // Device pairing 39 + Opake.createPairRequest(storage, did); 40 + Opake.awaitPairCompletion(storage, did, rkey); 41 + Opake.cancelPairRequest(storage, did, rkey); 42 + 43 + // The one that returns an Opake instance 44 + Opake.init({ storage, did? });`; 45 + 46 + export const instanceSurface = `opake.did; // invariant for the instance's lifetime 47 + 48 + // FileManager access 49 + opake.cabinet(); 50 + opake.workspaceByUri(uri); 51 + 52 + // Workspace lifecycle 53 + opake.createWorkspace(name, description?); 54 + opake.listWorkspaces(); 55 + opake.watchWorkspaces(handler); 56 + 57 + // Incoming shares 58 + opake.listInbox(); 59 + opake.watchInbox(handler); 60 + 61 + // Live updates 62 + opake.startSseConsumer(indexerUrl?); 63 + opake.stopSseConsumer(); 64 + 65 + // ...plus pairing (approving side) and maintenance ops`; 66 + 67 + export const storageOptions = `// Browsers. The default choice. 68 + import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb"; 69 + const storage = new IndexedDbStorage(); // persists via Dexie 70 + 71 + // Tests and scripts. In-memory, lost on process exit. 72 + import { MemoryStorage } from "@opake/sdk"; 73 + const storage = new MemoryStorage();`; 74 + 75 + export const errorHandling = `import { OpakeError } from "@opake/sdk"; 76 + 77 + try { 78 + const opake = await Opake.init({ storage }); 79 + } catch (err) { 80 + if (err instanceof OpakeError) { 81 + switch (err.kind) { 82 + case "IdentityMissing": 83 + // Normal: the user is signed in but hasn't bootstrapped an 84 + // identity on this device yet. Route them to creation, recovery, 85 + // or pairing. 86 + break; 87 + case "NotFound": 88 + // No session at all. Send them to login. 89 + break; 90 + case "Auth": 91 + // Token refresh failed. The session is dead. 92 + break; 93 + default: 94 + throw err; 95 + } 96 + } 97 + }`; 98 + 99 + // -- authentication.mdx ----------------------------------------------------- 100 + 101 + export const startLogin = `import { Opake } from "@opake/sdk"; 102 + import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb"; 103 + 104 + const storage = new IndexedDbStorage(); 105 + 106 + const { authUrl, pending } = await Opake.startLogin("alice.bsky.social", { 107 + redirectUri: "https://myapp.com/callback", 108 + }); 109 + 110 + Opake.savePendingLogin(pending); 111 + window.location.href = authUrl;`; 112 + 113 + export const callbackPage = `import { Opake } from "@opake/sdk"; 114 + import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb"; 115 + 116 + const storage = new IndexedDbStorage(); 117 + 118 + const pending = Opake.loadPendingLogin(); 119 + if (!pending) { 120 + // No pending state, or expired (10-minute TTL). Send back to login. 121 + window.location.href = "/login"; 122 + } 123 + 124 + const params = new URLSearchParams(window.location.search); 125 + await Opake.completeLogin( 126 + params.get("code")!, 127 + params.get("state")!, 128 + pending!, 129 + { storage, redirectUri: "https://myapp.com/callback" }, 130 + ); 131 + 132 + const opake = await Opake.init({ storage });`; 133 + 134 + export const embeddedLogin = `await Opake.login("alice.bsky.social", { 135 + storage, 136 + redirectUri: "https://myapp.com/callback", 137 + authorize: async (authUrl) => { 138 + const popup = window.open(authUrl, "_blank", "width=600,height=800"); 139 + // Resolve with { code, state } however your host surfaces the callback — 140 + // postMessage from the popup, main-window listener on custom URL scheme, 141 + // Electron BrowserWindow webContents event, etc. 142 + return await waitForCallback(popup); 143 + }, 144 + });`; 145 + 146 + export const appPasswordLogin = `import { Opake } from "@opake/sdk"; 147 + 148 + await Opake.loginWithAppPassword( 149 + "alice.bsky.social", 150 + "xxxx-xxxx-xxxx-xxxx", 151 + { storage }, 152 + ); 153 + 154 + const opake = await Opake.init({ storage });`; 155 + 156 + export const routingOnInitErrors = `import { Opake, OpakeError } from "@opake/sdk"; 157 + 158 + try { 159 + const opake = await Opake.init({ storage }); 160 + // Signed in, identity loaded. Proceed. 161 + } catch (err) { 162 + if (err instanceof OpakeError) { 163 + switch (err.kind) { 164 + case "NotFound": 165 + // No session in storage for this DID. Send to login. 166 + window.location.href = "/login"; 167 + break; 168 + case "IdentityMissing": 169 + // Session is live, but no encryption identity yet on this device. 170 + // Route to identity creation, seed-phrase recovery, or device pairing. 171 + window.location.href = "/recover-or-pair"; 172 + break; 173 + case "Auth": 174 + // Session exists but refresh failed. Credentials are stale. 175 + await storage.clearSession(accounts[0]!.did); 176 + window.location.href = "/login"; 177 + break; 178 + default: 179 + throw err; 180 + } 181 + } else { 182 + throw err; 183 + } 184 + }`; 185 + 186 + export const accountSwitcher = `const accounts = await Opake.listAccounts(storage); 187 + for (const { did, handle, pdsUrl } of accounts) { 188 + console.log(handle, did, pdsUrl); 189 + } 190 + 191 + // Open a specific account: 192 + const opake = await Opake.init({ storage, did: accounts[0]!.did });`; 193 + 194 + // -- identity.mdx ----------------------------------------------------------- 195 + 196 + export const identityShape = `interface Identity { 197 + did: string; 198 + public_key: string; // X25519, base64 199 + private_key: string; // X25519, base64. Lives in Storage, read by WASM only. 200 + signing_key?: string; // Ed25519, base64. Lives in Storage, read by WASM only. 201 + verify_key?: string; // Ed25519, base64. 202 + }`; 203 + 204 + export const createIdentityFresh = `// Generate a new 24-word BIP-39 phrase. 205 + const phrase = await Opake.generateSeedPhrase(); 206 + 207 + // UX: show \`phrase\` to the user and require confirmation it's written 208 + // down BEFORE you call createIdentity. Once the derived identity is 209 + // saved to Storage, the phrase is the only recovery path if Storage 210 + // is ever wiped. 211 + await requireUserConfirmedBackup(phrase); 212 + 213 + // Derive the X25519 + Ed25519 keypairs from the phrase. 214 + const identity = await Opake.createIdentity(phrase, did); 215 + await storage.saveIdentity(did, identity); 216 + 217 + // Publish the public key so others can encrypt to you. 218 + const opake = await Opake.init({ storage, did }); 219 + await opake.publishPublicKey();`; 220 + 221 + export const recoverFromPhrase = `// Validate before deriving. Garbage input would silently produce 222 + // garbage keys that only fail later at decrypt time. 223 + if (!(await Opake.validateSeedPhrase(phrase))) { 224 + throw new Error("That doesn't look like a valid 24-word phrase."); 225 + } 226 + 227 + const identity = await Opake.createIdentity(phrase, did); 228 + await storage.saveIdentity(did, identity); 229 + 230 + const opake = await Opake.init({ storage, did }); 231 + 232 + // publishPublicKey is idempotent. If the user has used Opake before on 233 + // another device, the record is already there and the putRecord 234 + // overwrites with the same content. Safe to call either way. 235 + await opake.publishPublicKey();`; 236 + 237 + export const createPairRequestNewDevice = `// New device. User has a live session (from login) but no Identity yet. 238 + 239 + const { uri, rkey, ephemeralPublicKey } = await Opake.createPairRequest( 240 + storage, 241 + did, 242 + ); 243 + 244 + // Show a fingerprint so the user can verify on their other device that 245 + // the request really is from this one. First 8 bytes of the ephemeral 246 + // public key, hex-paired. 247 + const fingerprint = Array.from(ephemeralPublicKey.slice(0, 8)) 248 + .map((b) => b.toString(16).padStart(2, "0")) 249 + .join(":"); 250 + displayToUser(\`Fingerprint: \${fingerprint}\`);`; 251 + 252 + export const awaitPairCompletionNewDevice = `const controller = new AbortController(); 253 + 254 + try { 255 + await Opake.awaitPairCompletion(storage, did, rkey, { 256 + pollIntervalMs: 3000, 257 + timeoutMs: 10 * 60 * 1000, // 10 minutes 258 + signal: controller.signal, 259 + }); 260 + 261 + // Identity is in Storage. Opake.init now succeeds. 262 + const opake = await Opake.init({ storage, did }); 263 + } catch (err) { 264 + if (err instanceof DOMException && err.name === "AbortError") { 265 + // User cancelled before approval arrived. Wipe the request. 266 + await Opake.cancelPairRequest(storage, did, rkey); 267 + return; 268 + } 269 + throw err; 270 + }`; 271 + 272 + export const approvePairRequestExistingDevice = `// Existing device. Already has an Identity in Storage, so \`opake\` exists. 273 + 274 + const requests = await opake.listPairRequests(); 275 + // requests: { uri: string; ephemeralKey: Uint8Array; createdAt: string }[] 276 + 277 + // Present them with fingerprints. The user picks the one matching what 278 + // their new device is showing. Do NOT auto-approve — the fingerprint 279 + // check is the only defense against a MITM injecting a fake request. 280 + const selected = await askUserToPickRequest(requests); 281 + 282 + await opake.approvePairRequest(selected.uri, selected.ephemeralKey); 283 + // The new device's awaitPairCompletion resolves within one poll.`; 284 + 285 + // -- files.mdx -------------------------------------------------------------- 286 + 287 + export const getFileManager = `// Personal files. One FileManager per Opake instance. 288 + const cabinet = await opake.cabinet(); 289 + 290 + // Shared workspace. Resolved by URI; the WASM side unwraps the group 291 + // key internally using the caller's Identity, and the resulting 292 + // FileManager routes reads to the owner's PDS, writes to proposals 293 + // if the caller isn't the owner. 294 + const workspace = await opake.workspaceByUri(workspaceUri);`; 295 + 296 + export const uploadDocument = `const data = await readAsBytes(userFile); // from your UI 297 + 298 + const result = await fm.upload(data, "budget.pdf", "application/pdf", { 299 + description: "Q4 planning doc", 300 + tags: ["finance", "q4"], 301 + directoryUri: currentDirectoryUri, // where to place it in the tree 302 + }); 303 + 304 + if (result.proposed) { 305 + // Workspace member: the upload was written as a documentUpdate proposal. 306 + // The owner's daemon will apply it on their next sync. 307 + notify("Upload queued — waiting for the workspace owner to apply."); 308 + } else { 309 + // Cabinet, or a workspace you own: applied immediately. 310 + notify("Uploaded."); 311 + }`; 312 + 313 + export const downloadDocument = `const { filename, data } = await fm.download(documentUri); 314 + 315 + // data is a Uint8Array — decrypted plaintext ready for use. 316 + const blob = new Blob([data], { type: "application/octet-stream" }); 317 + saveAsFile(filename, blob);`; 318 + 319 + export const readingTrees = `// Fast load. Uses whatever's in the local cache plus a delta sync; 320 + // returns the directory structure only, no document metadata. 321 + const tree = await fm.loadTree(); 322 + 323 + // Same tree, plus decrypted metadata for one directory's documents. 324 + // Use this when rendering a directory view — the extra round-trips 325 + // only happen for that one directory. 326 + const withNames = await fm.loadTreeWithMetadata(currentDirectoryUri); 327 + 328 + // Force a fresh PDS fetch before returning. Use sparingly; this bypasses 329 + // the cache. Typically called after a write that invalidates state the 330 + // indexer hasn't caught up to yet. 331 + const fresh = await fm.syncAndLoadTree(currentDirectoryUri);`; 332 + 333 + export const structureChanges = `// Create a directory. 334 + await fm.createDirectory("Photos", parentDirectoryUri); 335 + 336 + // Move an entry (document or sub-directory) between directories. 337 + await fm.move(entryUri, sourceDirectoryUri, targetDirectoryUri); 338 + 339 + // Delete a document. parentDirectoryUri is required: the delete is 340 + // atomic with removing this entry from the parent. 341 + await fm.delete(documentUri, parentDirectoryUri); 342 + 343 + // Recursively delete a directory and everything inside it. 344 + await fm.deleteRecursive(directoryUri);`; 345 + 346 + export const updateMetadataContent = `// Change metadata without re-uploading the blob. 347 + await fm.updateMetadata(documentUri, { 348 + filename: "budget-final.pdf", 349 + description: "Approved version", 350 + tags: ["finance", "q4", "approved"], 351 + }); 352 + 353 + // Replace document contents in place. Existing grants stay valid — 354 + // updateContent re-encrypts with the same content key, so recipients 355 + // don't need re-wrapping. 356 + await fm.updateContent(documentUri, newBytes);`; 357 + 358 + export const watchDirectory = `const watcher = fm.watchDirectory(directoryUri, (snapshot) => { 359 + if (snapshot === null) { 360 + // The directory was deleted (by the owner, or recursively from a 361 + // parent). Stop reading state that references it. 362 + handleDirectoryGone(); 363 + return; 364 + } 365 + renderDirectory(snapshot); 366 + }); 367 + 368 + // Stop listening when you're done — on UI teardown, logout, or before 369 + // switching to a different directory. 370 + watcher.close();`; 371 + 372 + // -- sharing.mdx ------------------------------------------------------------ 373 + 374 + export const shareDocument = `// Resolve the recipient first. This returns { did, pdsUrl, publicKey }, 375 + // where publicKey is the recipient's X25519 public encryption key. 376 + const recipient = await opake.resolveIdentity("bob.bsky.social"); 377 + 378 + // Direct share. Writes an app.opake.grant record on YOUR PDS that wraps 379 + // the document's content key to the recipient's public key. The grant 380 + // lives under your repo; the recipient discovers it via the indexer. 381 + const fm = await opake.cabinet(); 382 + await fm.share( 383 + documentUri, 384 + recipient.did, 385 + recipient.publicKey, 386 + "read", 387 + "For your review — draft v2", 388 + );`; 389 + 390 + export const handleRecipientNotReady = `import { OpakeError } from "@opake/sdk"; 391 + 392 + try { 393 + const recipient = await opake.resolveIdentity(handleOrDid); 394 + await fm.share(documentUri, recipient.did, recipient.publicKey, "read"); 395 + } catch (err) { 396 + if (err instanceof OpakeError && err.kind === "RecipientNotReady") { 397 + // The target has a valid atproto identity but hasn't published an 398 + // Opake public key yet (hasn't used Opake). Queue a pending share; 399 + // the daemon will retry until they sign up or it expires (7 days). 400 + await fm.createPendingShare(documentUri, handleOrDid, "read", null); 401 + return; 402 + } 403 + throw err; 404 + }`; 405 + 406 + export const listOutgoingShares = `const grants = await fm.listShares(); 407 + for (const g of grants) { 408 + // g: { uri, document, recipient, createdAt, expiresAt } 409 + console.log(\`Shared \${g.document} with \${g.recipient}\`); 410 + } 411 + 412 + // Grants don't carry the document filename directly; metadata stays 413 + // encrypted on the wire. If you need names, do a separate 414 + // getDocumentMetadata lookup per document URI.`; 415 + 416 + export const revokeShareSnippet = `await fm.revokeShare(grantUri); 417 + // The grant record is deleted from your PDS. The indexer removes it 418 + // from its index on the next firehose event; the recipient's 419 + // watchInbox fires with the updated (shorter) list. 420 + // 421 + // Caveat: revocation is forward-only. If the recipient already 422 + // decrypted and saved the key, you can't take that back. If the 423 + // document needs to be unreadable to the ex-recipient going forward, 424 + // delete-and-reupload instead of revoke.`; 425 + 426 + export const listInboxSnippet = `const grants = await opake.listInbox(); 427 + for (const g of grants) { 428 + // g: { uri, ownerDid, documentUri, createdAt } 429 + // ownerDid is the DID of whoever shared with you. 430 + console.log(\`Shared with you: \${g.documentUri} from \${g.ownerDid}\`); 431 + }`; 432 + 433 + export const watchInbox = `const watcher = opake.watchInbox((snapshot) => { 434 + // snapshot.loaded is false while the keeper is bootstrapping. Once 435 + // the initial listInbox completes, it flips to true and fires again 436 + // with the current entries. 437 + renderInbox(snapshot.entries, snapshot.loaded); 438 + }); 439 + 440 + // Stop listening when you're done. 441 + watcher.close();`; 442 + 443 + export const downloadFromGrantSnippet = `// Peek at the metadata (filename, MIME type, size, etc.) without 444 + // downloading the blob. Useful for rendering a "shared with me" list. 445 + const { filename, metadata } = await opake.resolveGrantMetadata(grantUri); 446 + 447 + // Download and decrypt in full. 448 + const { filename: f, data } = await opake.downloadFromGrant(grantUri); 449 + saveAsFile(f, new Blob([data]));`; 450 + 451 + export const pendingShares = `// List queued shares that haven't landed as grants yet. 452 + const pending = await opake.listPendingShares(); 453 + for (const p of pending) { 454 + // p: { uri, document, recipient, createdAt } 455 + console.log(\`Pending: \${p.document} → \${p.recipient}\`); 456 + } 457 + 458 + // Manually kick the retry loop. The daemon runs this on a schedule too. 459 + const outcome = await opake.retryPendingShares(); 460 + // { checked, completed, expired, still_pending, failed } 461 + 462 + // Cancel a specific pending share. 463 + await opake.cancelPendingShare(pendingShareUri);`; 464 + 465 + // -- workspaces.mdx --------------------------------------------------------- 466 + 467 + export const createWorkspace = `const { keyringUri, key } = await opake.createWorkspace( 468 + "Family Photos", 469 + "Shared vacation + milestone photos", 470 + ); 471 + 472 + // key is the group key for this workspace. You don't usually hold onto 473 + // it; subsequent operations re-resolve via keyringUri and the caller's 474 + // Identity unwraps the member entry each time. 475 + const fm = await opake.workspaceByUri(keyringUri);`; 476 + 477 + export const listAndWatchWorkspaces = `// One-shot list. 478 + const workspaces = await opake.listWorkspaces(); 479 + for (const ws of workspaces) { 480 + console.log(ws.name, ws.role, ws.keyringUri); 481 + } 482 + 483 + // Subscription. Fires once immediately with the current snapshot, 484 + // then again on every keyring:upsert / keyring:delete SSE event. 485 + const watcher = opake.watchWorkspaces((snapshot) => { 486 + renderWorkspaceList(snapshot.entries, snapshot.loaded); 487 + }); 488 + 489 + // Stop listening when you're done. 490 + watcher.close();`; 491 + 492 + export const addWorkspaceMember = `// Look up the invitee's published public encryption key first. 493 + // You need both their DID and the X25519 public key bytes. 494 + const { did, publicKey } = await resolveHandleAndPublicKey(handle); 495 + 496 + await opake.addWorkspaceMember(keyringUri, did, publicKey, "editor"); 497 + 498 + // The workspace keyring record on the owner's PDS now has an extra 499 + // member entry containing the group key wrapped to the invitee's 500 + // public key. They'll see the workspace in their next listWorkspaces.`; 501 + 502 + export const removeWorkspaceMember = `// Owner removing a member rotates the group key in place. 503 + const result = await opake.removeWorkspaceMember(keyringUri, memberDid); 504 + 505 + if (result.proposed) { 506 + // The caller isn't the owner; this was written as a keyringUpdate 507 + // proposal. The owner's daemon applies it. 508 + return; 509 + } 510 + 511 + // Owner path: result.rotation is the new rotation number. Existing 512 + // documents stay readable by remaining members because keyHistory 513 + // retains the prior rotation's member entries. Documents uploaded 514 + // AFTER this point are wrapped under the new key, which the removed 515 + // member doesn't have. 516 + console.log("Rotated to", result.rotation);`; 517 + 518 + export const leaveWorkspace = `// Opt out of a workspace you're a member of (not the owner of). 519 + // Writes a keyringUpdate proposal with actionType "leave"; the owner's 520 + // daemon processes it and triggers a normal remove-member rotation. 521 + await opake.leaveWorkspace(keyringUri);`; 522 + 523 + export const proposalFlow = `// Member writing to a workspace they don't own. 524 + const result = await fm.upload(data, "notes.md", "text/markdown", { 525 + directoryUri: someWorkspaceDir, 526 + }); 527 + // result.uri points at a documentUpdate proposal record on the CALLER's 528 + // PDS, NOT at a new document on the owner's PDS. 529 + // result.proposed === true 530 + 531 + // Owner side (running anywhere the owner's Opake instance is alive): 532 + // syncWorkspaceByUri picks up pending proposals, validates the 533 + // proposer's role, applies them as canonical records on the owner's 534 + // PDS, and deletes the proposal from the member's PDS. 535 + await opake.syncWorkspaceByUri(keyringUri);`; 536 + 537 + export const mutationResultHandling = `// Every write returns MutationResult: 538 + // { uri: string; proposed: boolean } 539 + // 540 + // proposed: true means the write is a documentUpdate/directoryUpdate 541 + // record on the caller's own PDS, waiting for the workspace owner to 542 + // apply it. The URI in that case points at the proposal record, not the 543 + // target document/directory, so don't treat it as a new document URI. 544 + // 545 + // proposed: false means the write was applied directly — either the 546 + // caller owns the workspace (or it's their cabinet), or the owner is 547 + // acting on their own records. 548 + 549 + const result = await fm.updateMetadata(documentUri, { filename: "v2.pdf" }); 550 + if (result.proposed) { 551 + // Optimistic UI: mark the row as "pending apply" but show the new name. 552 + markPending(documentUri, "v2.pdf"); 553 + } else { 554 + // Direct apply: the change is already on PDS. 555 + updateLocalTree(documentUri, "v2.pdf"); 556 + }`; 557 + 558 + // -- events.mdx ------------------------------------------------------------- 559 + 560 + export const startConsumerBasic = `// Start the stream with the indexer URL resolved via the priority chain 561 + // (runtime override → user's accountConfig on PDS → compile-time default). 562 + // Idempotent: a second call while one is running is a no-op. 563 + await opake.startSseConsumer();`; 564 + 565 + export const startConsumerCustom = `// Override the indexer URL at runtime. Wins over whatever's in the user's 566 + // accountConfig for the rest of the Opake instance's lifetime. 567 + await opake.startSseConsumer("https://indexer.example.com");`; 568 + 569 + export const teardownSequence = `// Logout / account switch teardown. Do it in this order. 570 + 571 + // 1. Stop the stream so no events land against state you're about to drop. 572 + opake.stopSseConsumer(); 573 + 574 + // 2. Drain the in-memory keepers. ContentKeys are zeroized; the decrypted 575 + // directory-name cache is cleared. Anything sitting on an \`opake.watch*\` 576 + // handler receives one final snapshot (empty, loaded=false) and closes. 577 + opake.wipeState(); 578 + 579 + // OpakeProvider in @opake/react does this pair on unmount for you.`; 580 + 581 + export const consumerGating = `// Hold off on starting the stream until the app has a live session. 582 + // Typical: inside a useEffect / onMount that depends on auth state. 583 + 584 + if (session.status === "active") { 585 + await opake.startSseConsumer(); 586 + } 587 + 588 + // If the user hasn't logged in yet, starting anyway would trigger a 589 + // token-exchange request that fails with Auth. Cleaner to gate on 590 + // authentication state.`; 591 + 592 + // -- storage.mdx ------------------------------------------------------------ 593 + 594 + export const storageInterface = `interface Storage { 595 + // Config — account roster and default DID. 596 + loadConfig(): Promise<Config>; 597 + saveConfig(config: Config): Promise<void>; 598 + 599 + // Identity — X25519 + Ed25519 keypairs, per DID. 600 + loadIdentity(did: string): Promise<Identity>; 601 + saveIdentity(did: string, identity: Identity): Promise<void>; 602 + 603 + // Session — OAuth or legacy tokens, DPoP keys, per DID. 604 + loadSession(did: string): Promise<Session>; 605 + saveSession(did: string, session: Session): Promise<void>; 606 + clearSession(did: string): Promise<void>; 607 + 608 + // Full account removal (identity + session + cache + config entry). 609 + removeAccount(did: string): Promise<void>; 610 + 611 + // Ephemeral pair-state: raw bytes of the X25519 private half during 612 + // pairing. WASM writes on createPairRequest, reads on tryCompletePair, 613 + // deletes on success or cancel. Never crosses back into JS. 614 + savePairState(did: string, rkey: string, privateKey: Uint8Array): Promise<void>; 615 + loadPairState(did: string, rkey: string): Promise<Uint8Array>; 616 + deletePairState(did: string, rkey: string): Promise<void>; 617 + 618 + // PDS record cache. Not secret; the same ciphertext the PDS would 619 + // serve. Used for fast cold starts and offline reads. 620 + cacheGetRecord<T>(did, collection, uri): Promise<CachedRecord<T> | null>; 621 + cachePutRecords<T>(did, collection, records): Promise<void>; 622 + cacheRemoveRecord(did, collection, uri): Promise<void>; 623 + cacheGetCollection<T>(did, collection): Promise<CachedCollection<T> | null>; 624 + cachePutCollection<T>(did, collection, data): Promise<void>; 625 + cacheInvalidateCollection(did, collection): Promise<void>; 626 + cacheClear(did): Promise<void>; 627 + }`; 628 + 629 + export const storageBuiltIns = `import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb"; 630 + import { MemoryStorage } from "@opake/sdk"; 631 + 632 + // Browsers. Backed by Dexie; survives page reloads; scoped per origin. 633 + const browserStorage = new IndexedDbStorage(); 634 + 635 + // Tests. All in-memory, discarded on process exit. 636 + const testStorage = new MemoryStorage();`; 637 + 638 + export const storageCustomBackend = `import type { Storage, Config, Identity, Session } from "@opake/sdk"; 639 + 640 + class TauriFsStorage implements Storage { 641 + constructor(private readonly dataDir: string) {} 642 + 643 + async loadConfig(): Promise<Config> { 644 + const raw = await fs.readFile(\`\${this.dataDir}/config.json\`); 645 + return JSON.parse(raw.toString()) as Config; 646 + } 647 + 648 + async saveConfig(config: Config): Promise<void> { 649 + await fs.writeFile( 650 + \`\${this.dataDir}/config.json\`, 651 + JSON.stringify(config, null, 2), 652 + ); 653 + } 654 + 655 + // ...identity, session, pair-state, cache methods follow the same 656 + // pattern. Reference: MemoryStorage (src/storage/memory.ts) is a 657 + // clean example of the full interface in one file. 658 + }`;
+121
apps/web/src/content/docs/build/sdk/authentication.mdx
··· 1 + import { 2 + startLogin, 3 + callbackPage, 4 + embeddedLogin, 5 + appPasswordLogin, 6 + routingOnInitErrors, 7 + accountSwitcher, 8 + } from "./_snippets"; 9 + 10 + <ChapterHeader title="Authentication" /> 11 + 12 + <Lead> 13 + Auth in Opake is OAuth by default, with legacy app passwords as an escape hatch for places 14 + OAuth can't reach. Whichever flow you pick, the user's password stays at their PDS and the 15 + resulting tokens stay inside WASM. 16 + </Lead> 17 + 18 + ## Pick a shape 19 + 20 + Two surfaces depending on how your app handles the redirect to the authorization server: 21 + 22 + - `Opake.startLogin` + `Opake.completeLogin` is the explicit two-step. Safe across a browser 23 + navigation, because state persists through `sessionStorage` while the user is off authorizing. 24 + - `Opake.login` is the composed wrapper. One call, given an `authorize` callback you supply for 25 + the round-trip. Useful when JS stays alive through authorization (popup window, Electron 26 + `BrowserWindow`, mobile custom URL scheme). 27 + 28 + SPA that navigates to the AS and comes back: almost always the two-step. Embedded flows where 29 + you can keep the original page alive: either one works. 30 + 31 + ## The two-step flow 32 + 33 + On the page that triggers login: 34 + 35 + <CodeBlock language="typescript" title="start-login.ts" code={startLogin} /> 36 + 37 + On the callback page: 38 + 39 + <CodeBlock language="typescript" title="callback-page.ts" code={callbackPage} /> 40 + 41 + `savePendingLogin` stashes the DPoP key and PKCE verifier in `sessionStorage` under a namespaced 42 + key. `loadPendingLogin` reads that state and clears the storage entry on the same call, so the 43 + DPoP key doesn't linger after the flow completes or fails. The pending state has a ten-minute 44 + TTL; any older entry reads back as `null`. 45 + 46 + Always pass the same `redirectUri` to both calls. It's part of the OAuth exchange that the AS 47 + verifies, and a mismatch rejects the token exchange with no useful error message. 48 + 49 + ## The composed flow 50 + 51 + When JS stays alive through authorization, `Opake.login` wraps both steps around an `authorize` 52 + callback: 53 + 54 + <CodeBlock language="typescript" title="embedded-login.ts" code={embeddedLogin} /> 55 + 56 + The SDK calls your `authorize` function with the AS URL and waits for `{ code, state }` back, 57 + then finishes the exchange internally. You never see the DPoP key or pending state; it never 58 + leaves WASM. 59 + 60 + ## App password fallback 61 + 62 + OAuth isn't always reachable: Obsidian plugins, scripts without a browser, CI jobs, sandboxes 63 + without `fetch` redirects. For those, the PDS exposes app passwords. The user generates one in 64 + their PDS account settings, and you sign in with it once: 65 + 66 + <CodeBlock language="typescript" title="app-password-login.ts" code={appPasswordLogin} /> 67 + 68 + App passwords are scoped credentials, not the user's real password. The user can revoke one 69 + from the PDS settings at any time. The session lands in Storage with `type: "legacy"` instead 70 + of `"oauth"`; everything you do with the `Opake` instance afterwards is identical. Refresh goes 71 + through `com.atproto.server.refreshSession` rather than OAuth's token endpoint, but the SDK 72 + handles that for you. 73 + 74 + ## Token refresh is automatic 75 + 76 + Every authenticated SDK method runs through a token guard before firing. The guard checks 77 + `tokenExpiresAt()` (a sync WASM call that returns only the timestamp, not the token). If the 78 + access token is within 30 seconds of expiry, the guard triggers `proactiveRefresh()` before the 79 + main call. Concurrent callers share a single in-flight refresh promise, so two parallel 80 + operations don't each fire their own refresh round. 81 + 82 + Reactive refresh is the backup. If the PDS returns `ExpiredToken` unexpectedly, the XRPC client 83 + catches it, refreshes inline, and retries the original request. You don't need to write any 84 + refresh logic in application code. 85 + 86 + <Callout type="info"> 87 + **What JS never sees.** `tokenExpiresAt()` returns a number. `proactiveRefresh()` returns 88 + void. Neither exposes the token itself. The only token-shaped values in JS memory are the 89 + session fields that come out of `storage.loadSession(did)` if you call it directly, which 90 + you shouldn't need to. 91 + </Callout> 92 + 93 + ## Post-init error branching 94 + 95 + After `Opake.init({ storage })`, there are three failure modes worth special-casing: 96 + 97 + <CodeBlock 98 + language="typescript" 99 + title="routing-on-init-errors.ts" 100 + code={routingOnInitErrors} 101 + /> 102 + 103 + `IdentityMissing` is the one most apps forget. It fires on a freshly-signed-in device that has 104 + a session but no `Identity` in Storage. That isn't an error in the "something went wrong" sense; 105 + it's the shape of a newly-paired-but-not-yet-bootstrapped account. See [Identity & 106 + pairing](/docs/sdk/identity) for how to resolve it from both ends. 107 + 108 + ## Multiple accounts 109 + 110 + One Storage can hold multiple signed-in accounts. `Opake.listAccounts` returns all of them: 111 + 112 + <CodeBlock language="typescript" title="account-switcher.ts" code={accountSwitcher} /> 113 + 114 + Without `did`, `Opake.init` opens the default account (the one marked default in Config). 115 + Passing an explicit DID overrides that for this instance. 116 + 117 + To sign an account out without removing its identity from storage, clear the session: 118 + `await storage.clearSession(did)`. To forget the account entirely (identity, session, cached 119 + records), call `opake.removeAccount(did)` on an instance that currently holds it. 120 + 121 + <DocsNext slug="authentication" />
+135
apps/web/src/content/docs/build/sdk/events.mdx
··· 1 + import { 2 + startConsumerBasic, 3 + startConsumerCustom, 4 + teardownSequence, 5 + consumerGating, 6 + } from "./_snippets"; 7 + 8 + <ChapterHeader title="Live updates" /> 9 + 10 + <Lead> 11 + Opake ships a WASM-owned Server-Sent Events consumer that keeps the in-memory directory 12 + trees, workspace list, and inbox current without polling. One consumer per `Opake` instance. 13 + JS starts and stops it; parsing, reconnect, and state reconciliation happen in Rust. 14 + </Lead> 15 + 16 + ## Starting the stream 17 + 18 + <CodeBlock language="typescript" title="start.ts" code={startConsumerBasic} /> 19 + 20 + `startSseConsumer` is idempotent. If a consumer is already running (for example, from a 21 + previous call or a React StrictMode double-mount), the second call returns without spawning 22 + a duplicate. A single `Opake` instance can only have one consumer in flight at a time. 23 + 24 + The call resolves the indexer URL from three sources, highest priority first: a runtime 25 + override you passed to an earlier `setIndexerUrl` or this function, the `indexerUrl` field in 26 + the user's `app.opake.accountConfig` record on the PDS, and the `OPAKE_INDEXER_URL` value 27 + baked in at build time. Pass one explicitly when you need to override: 28 + 29 + <CodeBlock language="typescript" title="start-with-override.ts" code={startConsumerCustom} /> 30 + 31 + Gate the call on whether the user has a live session. Without one, the consumer's token 32 + exchange returns `Auth` and never actually connects: 33 + 34 + <CodeBlock language="typescript" title="gating.ts" code={consumerGating} /> 35 + 36 + ## What the stream delivers 37 + 38 + The indexer watches the atproto firehose for `app.opake.*` records and forwards them to any 39 + connected consumer whose DID appears as owner, recipient, or workspace member. Three 40 + event families matter to your code: 41 + 42 + - **Directory tree events.** Document uploads, deletions, metadata changes, directory 43 + creations, moves, renames. Delivered to any `FileManager.watchDirectory` subscribers 44 + targeting affected directories. The tree on-disk cache (IndexedDB on web) is also updated 45 + in the background. 46 + - **Workspace list events.** Keyring creations, member add/remove, workspace rename, key 47 + rotation. Delivered to any `opake.watchWorkspaces` subscribers. The `WorkspaceKeeper` 48 + applies them incrementally; there's no re-fetch until the watcher's own `listWorkspaces` 49 + bootstrap or a full reconnect. 50 + - **Inbox events.** Grant creation, grant deletion. Delivered to any `opake.watchInbox` 51 + subscribers. Fans out to both sides of a grant: sharer and recipient. 52 + 53 + Each event carries only the record's atproto URI and the decrypted fields the indexer can 54 + safely derive. Encrypted fields stay encrypted; the keepers unwrap them inside WASM using 55 + the caller's identity before snapshots go out to handlers. 56 + 57 + ## Keepers, not caches 58 + 59 + The three keepers (`TreeKeeper`, `WorkspaceKeeper`, `InboxKeeper`) live in WASM and hold the 60 + *decrypted* state that matches each watcher you've installed. Calling `listWorkspaces` or 61 + `listInbox`, or acquiring a `FileManager` for a given directory, bootstraps the relevant 62 + keeper with a fresh snapshot. SSE events then patch that state incrementally. 63 + 64 + From JS, you don't talk to keepers directly; you install watchers and receive snapshots. The 65 + keeper is the layer that connects "an event arrived on the stream" to "the snapshot handler 66 + that represents your UI gets called." If nothing is watching a given scope, events for it 67 + are parsed and discarded. 68 + 69 + ## Reconnect and bootstrap 70 + 71 + The consumer maintains a connection to `/api/events` and reconnects with exponential backoff 72 + if the stream closes. On reconnect, it does a full re-sync: `listWorkspaces` / `listInbox` / 73 + the cabinet + workspace tree syncs all fire again, repopulating the keepers from scratch. 74 + Full re-sync is the price of a subscription model that doesn't buffer for offline 75 + subscribers: Phoenix PubSub on the indexer side discards events that had no connected 76 + listener, so the consumer can't just catch up from a cursor. 77 + 78 + In practice this is fine. The full re-sync is fast (tree snapshots are paginated, keyring 79 + listing hits one indexer endpoint, inbox the same) and the window where a device is offline 80 + is usually short. If you see stale state in the UI after network recovery, the issue is the 81 + watchers, not the stream; try an explicit `listWorkspaces` / `listInbox` to kick the 82 + bootstrap. 83 + 84 + ## Token exchange 85 + 86 + The consumer authenticates to `/api/events` with a short-lived, single-use token rather than 87 + an Ed25519 signature on every connection. The flow: 88 + 89 + 1. Consumer wakes up, needs to connect. 90 + 2. `POST /api/events/token` with an Ed25519-signed `Opake-Ed25519` header (timestamp + DID + 91 + signature). Indexer verifies against the caller's published `app.opake.publicKey/self`. 92 + 3. Indexer mints a random opaque token, stores it in ETS against the caller's DID with a 93 + 60-second TTL, returns it in the body. 94 + 4. Consumer opens `GET /api/events?token=…`. Indexer looks up the token in ETS, associates 95 + the stream with the DID, deletes the token from ETS so it can't be reused. 96 + 5. Stream is alive; events flow. 97 + 98 + If the connection drops and reconnects, steps 2-4 happen again. Tokens are one-shot by 99 + design, and short enough that a stolen one has a few-seconds window of replay value before 100 + it either expires or the real consumer consumed it. 101 + 102 + Your code never sees the token directly. `startSseConsumer` triggers the exchange under the 103 + covers. 104 + 105 + ## Teardown 106 + 107 + <CodeBlock language="typescript" title="teardown.ts" code={teardownSequence} /> 108 + 109 + `stopSseConsumer` is lightweight. It flips the consumer's cancellation flag; the next event 110 + parse notices and bails cleanly. No network round-trip, no re-exchange needed if you later 111 + call `startSseConsumer` again. 112 + 113 + `wipeState` is the one that costs you state. It drains every keeper, zeroises cached content 114 + keys (ZeroizeOnDrop fires on the Rust side), and clears decrypted name caches. Anything 115 + holding a `watch*` handler receives one final snapshot with empty entries and `loaded: false` 116 + so your UI can tear down gracefully. Do it on logout, account switch, or anywhere else a 117 + user's crypto state shouldn't bleed into the next session. 118 + 119 + Don't `wipeState` without `stopSseConsumer` first. An event landing mid-wipe would try to 120 + apply against freshly-uninstalled scopes and log a warning. `OpakeProvider` in `@opake/react` 121 + does the pair in order on unmount. 122 + 123 + ## Running multiple accounts 124 + 125 + One consumer per `Opake` instance. If your app supports multi-account switching, construct 126 + one `Opake` per signed-in account (via `Opake.init({ storage, did })` with the specific DID) 127 + and start the consumer on each. Each maintains its own stream and its own keepers. Stopping 128 + one doesn't affect the others. 129 + 130 + In practice most apps render one account at a time and swap the active `Opake`; that's 131 + enough, since the inactive one's keepers sitting quiet in memory cost nothing. If you want to 132 + be aggressive about memory, `stopSseConsumer` + `wipeState` on the inactive account and 133 + restart when the user switches back. 134 + 135 + <DocsNext slug="events" />
+121
apps/web/src/content/docs/build/sdk/files.mdx
··· 1 + import { 2 + getFileManager, 3 + uploadDocument, 4 + downloadDocument, 5 + readingTrees, 6 + structureChanges, 7 + updateMetadataContent, 8 + watchDirectory, 9 + } from "./_snippets"; 10 + 11 + <ChapterHeader title="Files & directories" /> 12 + 13 + <Lead> 14 + Once an identity is loaded, every file operation goes through a `FileManager`. The API is 15 + path-and-URI-based, mutations are atomic, and tree state is subscribable so your UI can 16 + reflect changes from other devices without polling. This page covers the surface as it applies 17 + to your personal cabinet; the same API reaches into shared workspaces, with a few semantics 18 + that carry over into [Workspaces](/docs/sdk/workspaces). 19 + </Lead> 20 + 21 + ## Getting a `FileManager` 22 + 23 + Two constructors on `Opake`, same return type: 24 + 25 + <CodeBlock language="typescript" title="get-file-manager.ts" code={getFileManager} /> 26 + 27 + Each `Opake` instance can hand out multiple `FileManager`s, one per context. They don't share 28 + state, and freeing one doesn't affect the others; create as many as your UI needs and discard 29 + when the user navigates away. 30 + 31 + ## The tree model 32 + 33 + A cabinet or workspace is a tree of **directories** (organisational containers, rendered in the 34 + UI as folders) and **documents** (actual files with encrypted content and metadata). Directories 35 + hold an ordered list of entries, each pointing to either another directory or a document by 36 + AT-URI. 37 + 38 + Nothing in the tree is ever stored in plaintext on the PDS. Directory names and document 39 + metadata (filenames, MIME types, sizes, tags, descriptions) are encrypted with the same content 40 + key that protects the corresponding blob. The PDS sees opaque byte ranges and typed record 41 + shells. 42 + 43 + ## Reading the tree 44 + 45 + Three functions to pick from, trading off freshness for speed: 46 + 47 + <CodeBlock language="typescript" title="reading-trees.ts" code={readingTrees} /> 48 + 49 + For rendering a file browser, `loadTreeWithMetadata(currentDirectoryUri)` is the right choice — 50 + you get the whole structure fast, and the directory the user is looking at has its document 51 + names decrypted. Other directories will render as "N items" placeholders until navigated to. 52 + 53 + `syncAndLoadTree` forces a fresh fetch. Use it right after a mutation you want to confirm, or as 54 + a manual refresh action. Don't use it on every render; the SSE consumer keeps the cache current 55 + on its own (see [Live updates](/docs/sdk/events)). 56 + 57 + ## Upload and download 58 + 59 + <CodeBlock language="typescript" title="upload.ts" code={uploadDocument} /> 60 + 61 + `upload` generates a random AES-256-GCM content key inside WASM, encrypts the bytes, wraps the 62 + key to the caller's X25519 public key, and writes the document record plus blob to the PDS in a 63 + single `applyWrites` call. If you pass `directoryUri`, the same `applyWrites` also adds the new 64 + document as an entry in that directory, so the document never appears in the tree without its 65 + parent reference. 66 + 67 + The `result.proposed` check in the example only matters when the `FileManager` is pointed at a 68 + shared workspace that the caller doesn't own. For cabinet uploads it's always `false`. See 69 + [Workspaces](/docs/sdk/workspaces#proposal-vs-applied) for what "proposed" means and how the 70 + owner side picks those up. 71 + 72 + <CodeBlock language="typescript" title="download.ts" code={downloadDocument} /> 73 + 74 + `download` fetches the blob, unwraps the content key with the caller's private key, decrypts, 75 + and returns the plaintext as a `Uint8Array` plus the decrypted filename. Works identically 76 + whether the document lives in your own cabinet or in a workspace you're a member of. 77 + 78 + ## Structural changes 79 + 80 + <CodeBlock language="typescript" title="structure.ts" code={structureChanges} /> 81 + 82 + All four are atomic for cabinet owners: `createDirectory` writes the new directory and updates 83 + the parent's entries in one `applyWrites`; `move` removes from source and adds to target in one 84 + call; `delete` removes the document record and its entry together; `deleteRecursive` walks the 85 + subtree and tears everything down in one batched operation. 86 + 87 + For workspace members (i.e. non-owners acting on a workspace someone else created), these turn 88 + into `directoryUpdate` proposals. See [Workspaces](/docs/sdk/workspaces) for the proposal flow. 89 + 90 + ## Editing an existing document 91 + 92 + <CodeBlock 93 + language="typescript" 94 + title="update-metadata-content.ts" 95 + code={updateMetadataContent} 96 + /> 97 + 98 + `updateMetadata` patches the encrypted metadata record; the blob is untouched, so shares and 99 + grants stay valid without re-wrapping. `updateContent` re-encrypts the blob with the *same* 100 + content key the document was originally uploaded with. Recipients who already have that key 101 + wrapped to them can decrypt the new version immediately. If you want forward secrecy across the 102 + edit, delete and re-upload instead. 103 + 104 + ## Live updates 105 + 106 + The tree state isn't something you poll. `FileManager.watchDirectory` subscribes you to changes 107 + for a specific directory URI: uploads, deletes, renames, moves, and directory rearrangements 108 + fire the handler with the fresh snapshot. When the directory itself is deleted, the handler 109 + fires with `null`, and the watcher closes automatically. 110 + 111 + <CodeBlock language="typescript" title="watch-directory.ts" code={watchDirectory} /> 112 + 113 + The watcher sits on top of the SSE consumer (see [Live updates](/docs/sdk/events)). When the 114 + consumer isn't started, the watcher still fires the initial snapshot from cache but won't 115 + update on remote changes. 116 + 117 + `@opake/react` wraps `watchDirectory` as the `useDirectory` hook, with the same subscription 118 + semantics and the overlay layer for optimistic mutations folded in. See [React 119 + bindings](/docs/react/overview) when those pages land. 120 + 121 + <DocsNext slug="files" />
+139
apps/web/src/content/docs/build/sdk/identity.mdx
··· 1 + import { 2 + identityShape, 3 + createIdentityFresh, 4 + recoverFromPhrase, 5 + createPairRequestNewDevice, 6 + awaitPairCompletionNewDevice, 7 + approvePairRequestExistingDevice, 8 + } from "./_snippets"; 9 + 10 + <ChapterHeader title="Identity & pairing" /> 11 + 12 + <Lead> 13 + An `Identity` is your encryption keypair. It's separate from your PDS session because the PDS 14 + never sees it. Until one is in Storage for your DID, `Opake.init({ storage })` throws 15 + `IdentityMissing`. Three paths put one there: fresh creation, seed-phrase recovery, and device 16 + pairing. 17 + </Lead> 18 + 19 + ## What's in an `Identity` 20 + 21 + <CodeBlock language="typescript" title="Identity" code={identityShape} /> 22 + 23 + Derived deterministically from a 24-word BIP-39 mnemonic plus the DID. The same phrase produces 24 + the same keys for the same DID, every time, regardless of device. X25519 is for file encryption 25 + and key wrapping; Ed25519 is for signing requests to the Indexer. 26 + 27 + The private fields live in Storage and are read by WASM directly when a crypto operation needs 28 + them. Your code sees the `Identity` type only briefly, during the moments right after generation 29 + or recovery, before handing it off to `storage.saveIdentity`. 30 + 31 + ## Creating a new identity 32 + 33 + First-run path: the user has no existing identity anywhere, and no other device to pair with. 34 + Generate a phrase, show it to the user to back up, derive keys, save, publish the public key. 35 + 36 + <CodeBlock language="typescript" title="create-identity.ts" code={createIdentityFresh} /> 37 + 38 + The phrase is shown once, and only once. You don't hold onto it after `createIdentity` returns, 39 + and neither does WASM. If you want a "show again" feature later, your app has to store the 40 + phrase itself (encrypted under a passphrase, or behind a biometric prompt, or similar). 41 + 42 + `publishPublicKey` writes `app.opake.publicKey/self` on the PDS as an idempotent `putRecord`. 43 + Without it, other users can't encrypt files to this DID. 44 + 45 + ## Recovering from a seed phrase 46 + 47 + User already has a phrase: from another device, a backup, a password manager. Validate before 48 + deriving — garbage input produces garbage keys that fail later at decrypt time with no obvious 49 + connection to the typo two pages back. 50 + 51 + <CodeBlock language="typescript" title="recover.ts" code={recoverFromPhrase} /> 52 + 53 + One gotcha: the SDK doesn't check that the derived public key matches whatever is already 54 + published on the PDS's `publicKey/self` record. If the user types the wrong phrase but it's 55 + still a valid 24-word BIP-39 sequence, validation passes, the derived key is saved, and every 56 + encryption that touches existing documents fails silently later. If you want a matches-the-PDS 57 + check, fetch `publicKey/self` after init and compare the base64-encoded bytes against 58 + `identity.public_key` before you consider the recovery complete. Surfacing this as an in-SDK 59 + helper is on the list. 60 + 61 + ## Pairing: the new device side 62 + 63 + The user has Opake running on another device and would rather not type 24 words. They open the 64 + pair flow on this new device. The SDK generates an ephemeral X25519 keypair, writes an 65 + `app.opake.pairRequest` record to the PDS containing the public half, and stashes the private 66 + half in Storage for `awaitPairCompletion` to read back later. JS never sees the private half. 67 + 68 + <CodeBlock 69 + language="typescript" 70 + title="pair-start.ts" 71 + code={createPairRequestNewDevice} 72 + /> 73 + 74 + Now wait for approval from the other device. `awaitPairCompletion` polls the PDS for a matching 75 + `app.opake.pairResponse` record, decrypts the identity payload inside WASM using the stored 76 + ephemeral private key, writes the resulting `Identity` to Storage, and deletes both the request 77 + and response records. One call covers all of that. 78 + 79 + <CodeBlock 80 + language="typescript" 81 + title="pair-await.ts" 82 + code={awaitPairCompletionNewDevice} 83 + /> 84 + 85 + The `AbortController` / `signal` is how you cancel from the UI. `cancelPairRequest` tears down 86 + both sides of the pair state: wipes the ephemeral private key from Storage and deletes the PDS 87 + request record, so it isn't visible to the approving device anymore. 88 + 89 + ## Pairing: the existing device side 90 + 91 + On the device that already has an identity, list the requests and approve the one the user 92 + recognises. 93 + 94 + <CodeBlock 95 + language="typescript" 96 + title="pair-approve.ts" 97 + code={approvePairRequestExistingDevice} 98 + /> 99 + 100 + `approvePairRequest` wraps this device's identity under the requester's ephemeral public key, 101 + writes the encrypted bundle as `app.opake.pairResponse`, and returns. The requesting device's 102 + `awaitPairCompletion` picks it up on its next poll. 103 + 104 + ## Fingerprints are doing real work 105 + 106 + The pairing handshake is secure against a passive observer. The PDS sees ciphertexts and 107 + ephemeral public keys; that's not enough to recover an identity. Against an *active* attacker 108 + who can write to your PDS, though, it's different. They could inject their own pair request 109 + record with their ephemeral public key, wait for you to tap Approve, and receive your wrapped 110 + identity in the response. 111 + 112 + The fingerprint comparison is what closes that gap. Both devices display the same 8-byte prefix 113 + of the same ephemeral public key. If the user reads them off and they match, the request on the 114 + approving device really is from the new one, not an attacker's forgery. If they don't match, 115 + abort. 116 + 117 + Show fingerprints on both sides. Don't auto-approve. An SDK can't enforce the out-of-band check 118 + for you. 119 + 120 + ## What never crosses the JS boundary 121 + 122 + A quick map of who holds what during pairing: 123 + 124 + - **Ephemeral X25519 private key (new device):** generated inside WASM, written straight to 125 + Storage via the storage adapter, read back by WASM during `awaitPairCompletion`, wiped on 126 + success or cancellation. Your JS sees the public half only, and only for fingerprint display. 127 + - **The received `Identity` bundle (new device):** unwrapped inside WASM from the pair response 128 + body, written to Storage through the adapter, never returned to JS as an object with token 129 + fields populated. 130 + - **The existing device's identity during approval:** read from Storage into WASM, wrapped 131 + under the requester's ephemeral public key inside WASM. JS signs nothing and holds no key 132 + material during this call. 133 + 134 + The only identity-shaped values your application code ever touches directly are the public 135 + `did`, `public_key`, and `verify_key` fields on the `Identity` type during the narrow window 136 + after generation, plus the `ephemeralPublicKey` return from `createPairRequest` for fingerprint 137 + rendering. 138 + 139 + <DocsNext slug="identity" />
+15 -127
apps/web/src/content/docs/build/sdk/overview.mdx
··· 1 + import { 2 + helloCabinet, 3 + staticSurface, 4 + instanceSurface, 5 + storageOptions, 6 + errorHandling, 7 + } from "./_snippets"; 8 + 1 9 <ChapterHeader title="@opake/sdk — Overview" /> 2 10 3 11 <Lead> ··· 32 40 33 41 The minimum it takes to upload and read back one encrypted file: 34 42 35 - <CodeBlock language="typescript" title="example.ts"> 36 - {`import { Opake } from "@opake/sdk"; 37 - import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb"; 38 - 39 - const storage = new IndexedDbStorage(); 40 - 41 - // Assumes the user has already logged in. See authentication.mdx. 42 - const opake = await Opake.init({ storage }); 43 - const fm = await opake.cabinet(); 44 - 45 - // Upload 46 - const bytes = new TextEncoder().encode("hello from an encrypted cabinet"); 47 - await fm.uploadAt(bytes, "greetings.txt", "text/plain"); 48 - 49 - // Read back 50 - const { plaintext, filename } = await fm.downloadAt("/greetings.txt"); 51 - console.log(filename, new TextDecoder().decode(plaintext)); 52 - // → "greetings.txt hello from an encrypted cabinet"`} 53 - </CodeBlock> 43 + <CodeBlock language="typescript" title="example.ts" code={helloCabinet} /> 54 44 55 45 Under those calls: `IndexedDbStorage` persisted the session and the identity keypair, so the 56 46 next page load starts from `Opake.init` without redoing OAuth. `FileManager.uploadAt` generated ··· 67 57 68 58 ### Static surface 69 59 70 - <CodeBlock language="typescript" title="before init"> 71 - {`// OAuth two-step 72 - Opake.startLogin(handle, options); 73 - Opake.completeLogin(code, state, pending, options); 74 - Opake.loginWithAppPassword(options); 75 - 76 - // Seed-phrase recovery 77 - Opake.generateMnemonic(); 78 - Opake.validateSeedPhrase(phrase); 79 - Opake.createIdentity(phrase, did); 80 - 81 - // Device pairing 82 - Opake.createPairRequest(storage, did); 83 - Opake.awaitPairCompletion(storage, did, rkey); 84 - Opake.cancelPairRequest(storage, did, rkey); 85 - 86 - // The one that returns an Opake instance 87 - Opake.init({ storage, did? });`} 88 - </CodeBlock> 60 + <CodeBlock language="typescript" title="before init" code={staticSurface} /> 89 61 90 62 So much of the API is static because those three flows run before any identity exists on a 91 63 device. They can't live on an instance because you haven't got one. Once any of them finishes, ··· 93 65 94 66 ### Instance surface 95 67 96 - <CodeBlock language="typescript" title="after init"> 97 - {`opake.did; // invariant for the instance's lifetime 98 - 99 - // FileManager access 100 - opake.cabinet(); 101 - opake.workspaceByUri(uri); 102 - 103 - // Workspace lifecycle 104 - opake.createWorkspace(name, description?); 105 - opake.listWorkspaces(); 106 - opake.watchWorkspaces(handler); 107 - 108 - // Incoming shares 109 - opake.listInbox(); 110 - opake.watchInbox(handler); 111 - 112 - // Live updates 113 - opake.startSseConsumer(indexerUrl?); 114 - opake.stopSseConsumer(); 115 - 116 - // ...plus pairing (approving side) and maintenance ops`} 117 - </CodeBlock> 68 + <CodeBlock language="typescript" title="after init" code={instanceSurface} /> 118 69 119 70 The `did` field is the caller's DID, set during `init` and constant for the life of the 120 71 `Opake` instance. Safe as a React `useMemo` dependency or a cache key. ··· 125 76 identity keys, ephemeral pair state, the local record cache. Two implementations ship with the 126 77 SDK: 127 78 128 - <CodeBlock language="typescript" title="storage options"> 129 - {`// Browsers. The default choice. 130 - import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb"; 131 - const storage = new IndexedDbStorage(); // persists via Dexie 132 - 133 - // Tests and scripts. In-memory, lost on process exit. 134 - import { MemoryStorage } from "@opake/sdk"; 135 - const storage = new MemoryStorage();`} 136 - </CodeBlock> 79 + <CodeBlock language="typescript" title="storage options" code={storageOptions} /> 137 80 138 81 If you need a different backend (Tauri filesystem, React Native AsyncStorage, something 139 82 server-backed), implement the interface yourself. See [Storage 140 - contract](/docs/sdk/storage) for what each method persists and when it's called. 83 + interface](/docs/sdk/storage) for what each method persists and when it's called. 141 84 142 85 If you build one that's reusable, consider sending it upstream. The `Storage` contract is 143 86 small; most backends are one file plus tests. Keeping your implementation in the main repo ··· 149 92 150 93 Every SDK method throws `OpakeError` on failure. Branch on `.kind`: 151 94 152 - <CodeBlock language="typescript" title="error handling"> 153 - {`import { OpakeError } from "@opake/sdk"; 154 - 155 - try { 156 - const opake = await Opake.init({ storage }); 157 - } catch (err) { 158 - if (err instanceof OpakeError) { 159 - switch (err.kind) { 160 - case "IdentityMissing": 161 - // Normal: the user is signed in but hasn't bootstrapped an 162 - // identity on this device yet. Route them to recovery or pair. 163 - break; 164 - case "NotFound": 165 - // No session at all. Send them to login. 166 - break; 167 - case "Auth": 168 - // Token refresh failed. The session is dead. 169 - break; 170 - default: 171 - throw err; 172 - } 173 - } 174 - }`} 175 - </CodeBlock> 95 + <CodeBlock language="typescript" title="error handling" code={errorHandling} /> 176 96 177 97 `kind` maps 1-to-1 to the `opake_core::Error` variants in Rust, so anything the core library can 178 98 surface is reachable from TS without `any` casts. ··· 183 103 existing device to bootstrap an `Identity` into Storage. Route them to that flow, don't surface 184 104 it as a generic failure. 185 105 186 - ## Where to go next 187 - 188 - <DocsIndexGrid> 189 - <DocsIndexCard href="/docs/sdk/authentication" icon="lock"> 190 - <DocsIndexTitle>Authentication</DocsIndexTitle> 191 - <DocsIndexBody>How `startLogin` and `completeLogin` compose, the app-password fallback, and how token refresh actually happens.</DocsIndexBody> 192 - </DocsIndexCard> 193 - 194 - <DocsIndexCard href="/docs/sdk/identity" icon="seedling"> 195 - <DocsIndexTitle>Identity & pairing</DocsIndexTitle> 196 - <DocsIndexBody>Seed phrases and device pairing, with private key material never touching JS memory.</DocsIndexBody> 197 - </DocsIndexCard> 198 - 199 - <DocsIndexCard href="/docs/sdk/files" icon="folder"> 200 - <DocsIndexTitle>Files & directories</DocsIndexTitle> 201 - <DocsIndexBody>The `FileManager` contract and how to subscribe to tree changes as they arrive.</DocsIndexBody> 202 - </DocsIndexCard> 203 - 204 - <DocsIndexCard href="/docs/sdk/sharing" icon="share"> 205 - <DocsIndexTitle>Sharing & workspaces</DocsIndexTitle> 206 - <DocsIndexBody>One-to-one grants, group workspaces, and the proposal model that lets members edit on other people's PDSes.</DocsIndexBody> 207 - </DocsIndexCard> 208 - 209 - <DocsIndexCard href="/docs/sdk/events" icon="network"> 210 - <DocsIndexTitle>Live updates</DocsIndexTitle> 211 - <DocsIndexBody>Starting the SSE consumer and subscribing to workspace, inbox, and directory changes.</DocsIndexBody> 212 - </DocsIndexCard> 213 - 214 - <DocsIndexCard href="/docs/sdk/storage" icon="book"> 215 - <DocsIndexTitle>Storage contract</DocsIndexTitle> 216 - <DocsIndexBody>Every method on `Storage`, what it persists, and when the SDK reaches for it.</DocsIndexBody> 217 - </DocsIndexCard> 218 - </DocsIndexGrid> 106 + <DocsNext slug="overview" />
+106
apps/web/src/content/docs/build/sdk/sharing.mdx
··· 1 + import { 2 + shareDocument, 3 + handleRecipientNotReady, 4 + listOutgoingShares, 5 + revokeShareSnippet, 6 + listInboxSnippet, 7 + watchInbox, 8 + downloadFromGrantSnippet, 9 + pendingShares, 10 + } from "./_snippets"; 11 + 12 + <ChapterHeader title="Sharing" /> 13 + 14 + <Lead> 15 + A grant is an `app.opake.grant` record on the sharer's PDS that wraps a document's content key 16 + to one specific recipient's public key. One record, one recipient, one document. This is the 17 + minimal sharing primitive — for folders of files shared with multiple people, use 18 + [workspaces](/docs/sdk/workspaces) instead. 19 + </Lead> 20 + 21 + ## Sharing a document 22 + 23 + <CodeBlock language="typescript" title="share.ts" code={shareDocument} /> 24 + 25 + `resolveIdentity` takes a handle or DID, walks the atproto identity chain (handle resolution, 26 + DID document, PDS discovery), and fetches the target's `app.opake.publicKey/self` record. The 27 + result is everything `fm.share` needs: the recipient's DID and their X25519 public key. 28 + 29 + The grant itself lives on *your* PDS, not the recipient's. The recipient discovers it through 30 + the indexer's `/api/inbox` endpoint, which scans the firehose for grants that name them as the 31 + recipient. No write permission to the recipient's repository is needed. 32 + 33 + ## When the recipient hasn't set up Opake yet 34 + 35 + If `resolveIdentity` succeeds at the atproto layer (the handle exists, the DID resolves, the 36 + PDS is reachable) but there's no `app.opake.publicKey/self` record, you get `RecipientNotReady`. 37 + Handle it by queueing: 38 + 39 + <CodeBlock 40 + language="typescript" 41 + title="pending-share.ts" 42 + code={handleRecipientNotReady} 43 + /> 44 + 45 + `createPendingShare` writes an `app.opake.pendingShare` record on your PDS. The daemon 46 + periodically resolves the target handle and tries again. When the recipient eventually logs 47 + into Opake and publishes their public key, the next retry succeeds: a real grant is written 48 + and the pending share is deleted. Pending shares expire after seven days. 49 + 50 + ## Listing outgoing grants 51 + 52 + <CodeBlock language="typescript" title="list-shares.ts" code={listOutgoingShares} /> 53 + 54 + Grants are per-document, per-recipient. A "folder" of shared files is a collection of grants 55 + that happen to target documents in the same directory, not a distinct record type. Grouping 56 + them in your UI is the consumer's job. `listShares` gives you the flat list. 57 + 58 + ## Revoking 59 + 60 + <CodeBlock language="typescript" title="revoke.ts" code={revokeShareSnippet} /> 61 + 62 + Revocation is forward-only. The record is gone from your PDS after revoke, and the indexer 63 + removes it from the recipient's inbox shortly after that, but you can't claw back the content 64 + key the recipient already unwrapped. Opake's crypto model is identical to git-crypt here: 65 + once a recipient has the content key, they have it, and they can decrypt any copy of the 66 + ciphertext they kept. For actual forward secrecy on a single document, delete and re-upload 67 + with a fresh content key, then re-grant to the people who should still have access. 68 + 69 + ## Receiving shares 70 + 71 + <CodeBlock language="typescript" title="inbox.ts" code={listInboxSnippet} /> 72 + 73 + `listInbox` returns all grants visible to you via the indexer. Pair it with a subscription so 74 + your UI reflects new shares in real time: 75 + 76 + <CodeBlock language="typescript" title="watch-inbox.ts" code={watchInbox} /> 77 + 78 + `@opake/react` wraps `watchInbox` as the `useInbox` hook. See [React 79 + bindings](/docs/react/overview) when those pages land. 80 + 81 + ## Downloading shared content 82 + 83 + <CodeBlock language="typescript" title="download-grant.ts" code={downloadFromGrantSnippet} /> 84 + 85 + `downloadFromGrant` walks the full chain: fetches the grant, fetches the document record from 86 + the sharer's PDS, fetches the blob, unwraps the content key with your private X25519 key, and 87 + decrypts. All unauthenticated reads — atproto record and blob endpoints are public by design. 88 + 89 + `resolveGrantMetadata` is the cheaper variant. It fetches the grant and document record but 90 + skips the blob; you get the filename, MIME type, size, and tags decrypted, enough to render a 91 + "shared with me" list without paying download costs for files the user hasn't opened yet. 92 + 93 + ## Pending shares: the queue 94 + 95 + <CodeBlock language="typescript" title="pending.ts" code={pendingShares} /> 96 + 97 + The daemon runs `retryPendingShares` on a schedule (default every 15 minutes), so in most apps 98 + you don't call it manually. Exposing it on the SDK is useful for a "retry now" button in a 99 + settings panel, or for tests that don't want to wait. The outcome shape lets you report 100 + progress: how many pending shares were checked, how many got promoted to real grants, how many 101 + expired, how many are still waiting, and how many errored out for other reasons. 102 + 103 + `cancelPendingShare` is the "I changed my mind" path. Deletes the record, no retry fires 104 + again, nothing lands even if the recipient signs up tomorrow. 105 + 106 + <DocsNext slug="sharing" />
+102
apps/web/src/content/docs/build/sdk/storage.mdx
··· 1 + import { 2 + storageInterface, 3 + storageBuiltIns, 4 + storageCustomBackend, 5 + } from "./_snippets"; 6 + 7 + <ChapterHeader title="Storage interface" /> 8 + 9 + <Lead> 10 + Everything the SDK persists goes through a `Storage` implementation you provide. 11 + `IndexedDbStorage` is the browser default, `MemoryStorage` is for tests, and the interface 12 + is small enough to port to new backends (Tauri filesystem, React Native, a server-backed 13 + sync service) with a few dozen lines. 14 + </Lead> 15 + 16 + ## The interface 17 + 18 + <CodeBlock language="typescript" title="Storage" code={storageInterface} /> 19 + 20 + Every method is async. Implementations can use any storage technology underneath, but the 21 + contract is sequentially consistent per DID: a `saveIdentity` followed by `loadIdentity` 22 + with the same DID must return what was written. 23 + 24 + The four groups of methods serve different lifetime profiles. Config, identity, and session 25 + are small, read frequently, written rarely. Pair-state is ephemeral: short-lived entries 26 + created and deleted within a single pairing flow. The cache is high-volume and 27 + access-pattern-dominant; it's OK for cache implementations to prune aggressively or skip 28 + writes under pressure. 29 + 30 + ## Built-in implementations 31 + 32 + <CodeBlock language="typescript" title="built-ins.ts" code={storageBuiltIns} /> 33 + 34 + `IndexedDbStorage` uses Dexie tables for config, identities, sessions, pair-states, and the 35 + record cache. Database name is `opake` by default; pass a custom name to the constructor if 36 + you need multiple parallel stores (rare, but useful for test isolation). Schema evolution is 37 + Dexie-versioned; existing databases upgrade in place when the SDK schema bumps. 38 + 39 + `MemoryStorage` is simpler: plain `Map` instances, no persistence. Fine for unit tests. Not 40 + fine for anything a user would run, because identity keys vanish when the process exits. 41 + 42 + ## Writing a custom backend 43 + 44 + The contract is small enough to implement from scratch. A rough sketch for a Tauri app using 45 + the file system: 46 + 47 + <CodeBlock language="typescript" title="tauri-fs-storage.ts" code={storageCustomBackend} /> 48 + 49 + The identity, session, and pair-state paths follow the same load-file / save-file pattern. 50 + For the cache, you'd probably want a single SQLite database rather than one file per record; 51 + the cache API is designed around (did, collection, uri) tuples which map cleanly to SQL. 52 + 53 + If you build one that's reusable, consider sending it upstream. The `Storage` contract is 54 + small; most backends are one file plus tests. Keeping your implementation in the main repo 55 + saves you from re-syncing it every time the trait evolves, and means other people on the 56 + same platform find it next to the built-ins instead of re-solving the problem. Issues and 57 + PRs at [tangled.org/opake.app/opake](https://tangled.org/opake.app/opake). 58 + 59 + ## What each method is called for 60 + 61 + - **`loadConfig` / `saveConfig`**: called once during `Opake.init` to resolve the target DID 62 + (or pick the default), and on every account-mutating operation (login, logout, switch 63 + default, remove account). Low-frequency. 64 + - **`loadIdentity` / `saveIdentity`**: `loadIdentity` runs on `Opake.init`. `saveIdentity` 65 + runs during seed-phrase recovery and device-pairing completion. After that, the identity 66 + is in WASM memory for the session's lifetime and doesn't get re-read. 67 + - **`loadSession` / `saveSession` / `clearSession`**: `loadSession` on `Opake.init`. 68 + `saveSession` on every token refresh (proactive or reactive), and on fresh login. 69 + `clearSession` on logout without account removal. 70 + - **`removeAccount`**: deletes everything for one DID atomically. The implementation should 71 + treat this as a transaction; a half-removed account will fail the next `Opake.init` for 72 + that DID in confusing ways. 73 + - **`savePairState` / `loadPairState` / `deletePairState`**: called exclusively from inside 74 + WASM during device pairing. The bytes are the ephemeral X25519 private key for a pending 75 + pair request. Implementations should treat these bytes as secret and clear them from any 76 + intermediate buffers once the save completes. 77 + - **Cache methods**: called opportunistically. The SDK survives partial cache failures; if 78 + `cachePutRecords` silently no-ops on a particular record, the next operation that needs 79 + it just re-fetches from the PDS. The cache is correctness-preserving, not 80 + correctness-dependent. 81 + 82 + ## Invariants implementations must respect 83 + 84 + - **No leaking pair-state to JS userland.** `loadPairState` / `savePairState` return and 85 + accept `Uint8Array`, but your implementation must not log, persist as plaintext in URLs or 86 + query strings, or otherwise serialize these bytes anywhere other than the backing store. 87 + IndexedDbStorage writes them as-is into an IDB row whose key is the `(did, rkey)` tuple; 88 + anything equivalent is fine. 89 + - **Atomic `removeAccount`.** If a user clicks "log out and forget", and your 90 + `removeAccount` crashes halfway through, the partial state becomes a support ticket. Use 91 + whatever transaction primitive your backend offers, even if it's "write to a temp and 92 + rename on success." 93 + - **Sequential consistency per DID.** Multiple in-flight `saveSession` calls for the same 94 + DID during concurrent token refreshes must settle to the latest-written value. The SDK's 95 + `@withTokenGuard` serialises refreshes into a single-flight promise, so you won't see 96 + concurrent writes in practice, but your backend shouldn't rely on that. 97 + - **No in-place mutation of returned objects.** The SDK assumes `loadIdentity` / 98 + `loadSession` / `loadConfig` return fresh copies each call. If your backend caches the 99 + parsed object and returns references to it, a consumer mutating the returned value could 100 + leak into subsequent loads. 101 + 102 + <DocsNext slug="storage" />
+167
apps/web/src/content/docs/build/sdk/workspaces.mdx
··· 1 + import { 2 + createWorkspace, 3 + listAndWatchWorkspaces, 4 + addWorkspaceMember, 5 + removeWorkspaceMember, 6 + leaveWorkspace, 7 + proposalFlow, 8 + mutationResultHandling, 9 + } from "./_snippets"; 10 + 11 + <ChapterHeader title="Workspaces" /> 12 + 13 + <Lead> 14 + A workspace is a shared encrypted folder: a named group that owns a tree of files, with a 15 + roster of members who can read, write, or administer depending on their role. Technically 16 + it's a keyring on the owner's PDS plus a collaborative proposal protocol that lets members 17 + edit without needing write access to the owner's repository. 18 + </Lead> 19 + 20 + ## When to use a workspace (vs. a one-to-one grant) 21 + 22 + If you want to share a single file with one other person, [grants](/docs/sdk/sharing) are the 23 + lighter tool: one record per sharer, recipient decrypts directly from the sharer's PDS, no 24 + group state. Grants scale poorly though. Adding a new person to a folder of 200 files means 25 + 200 new grant records. Removing someone means deleting every grant they received. 26 + 27 + A workspace inverts that. The content key for each file is wrapped under a single **group 28 + key**, and the group key is wrapped to each member individually. Adding a member is one 29 + operation regardless of how many files are in the workspace. Removing a member rotates the 30 + group key once; future uploads are inaccessible to them automatically. 31 + 32 + Use workspaces for anything ongoing and multi-party: a family photo album, a research team's 33 + working files, a writing group's drafts. 34 + 35 + ## Create a workspace 36 + 37 + <CodeBlock language="typescript" title="create.ts" code={createWorkspace} /> 38 + 39 + `createWorkspace` generates a fresh group key inside WASM, wraps it to the caller's public 40 + key with the `manager` role (owners are always managers of their own workspace), writes the 41 + `app.opake.keyring` record to the caller's PDS, and writes the workspace root directory 42 + entry. The returned `key` is the group key bytes; you can hold onto it for immediate use, but 43 + you don't need to store it anywhere. Opake re-resolves via `keyringUri` on every subsequent 44 + operation. 45 + 46 + ## List and watch workspaces 47 + 48 + <CodeBlock language="typescript" title="list.ts" code={listAndWatchWorkspaces} /> 49 + 50 + `listWorkspaces` fetches from the indexer and decrypts workspace names inline. It also 51 + bootstraps the in-memory `WorkspaceKeeper`, so `watchWorkspaces` subscribers receive 52 + incremental updates from SSE events after the first call without the list round-tripping the 53 + indexer again. 54 + 55 + `@opake/react` wraps this pattern as `useWorkspaces` with the subscription lifecycle handled. 56 + See [React bindings](/docs/react/overview) when those pages land. 57 + 58 + ## Add a member 59 + 60 + <CodeBlock language="typescript" title="add-member.ts" code={addWorkspaceMember} /> 61 + 62 + The invitee needs an Opake public key published on their PDS. If `resolveHandleAndPublicKey` 63 + fails with `RecipientNotReady`, it means the target has a valid atproto identity but hasn't 64 + logged into Opake yet and therefore has no `app.opake.publicKey/self` record. You can queue a 65 + pending invite and wait for them to sign up; see [Sharing](/docs/sdk/sharing) for the 66 + pending-share mechanic, which applies the same way to workspace invites. 67 + 68 + Three roles exist: 69 + 70 + - **`manager`**: can add or remove members, change workspace metadata, and edit files like any editor. 71 + - **`editor`**: can read everything, upload new documents, and propose edits to existing ones. 72 + - **`viewer`**: can read but not write. Proposals from viewers are rejected by the owner daemon. 73 + 74 + Roles are enforced at the proposal-apply layer, not at the crypto layer. A viewer has the group 75 + key (that's what membership means) and can decrypt. Role enforcement stops them from *writing* 76 + valid proposals that the owner would accept. If your threat model requires a viewer who can't 77 + see content at all, they shouldn't be a workspace member. 78 + 79 + ## Remove a member 80 + 81 + <CodeBlock language="typescript" title="remove-member.ts" code={removeWorkspaceMember} /> 82 + 83 + Removing a member is the expensive operation by design. When the owner initiates it: 84 + 85 + 1. A fresh group key is generated inside WASM. 86 + 2. Every remaining member's entry in the keyring is re-wrapped to the new key. 87 + 3. The previous rotation's wrapped keys are archived into `keyHistory` on the keyring record 88 + so the remaining members can still decrypt older documents (which were wrapped under the 89 + old key). 90 + 4. The `rotation` counter increments. 91 + 5. Future uploads use the new key. 92 + 93 + The removed member keeps whatever they already decrypted or cached locally. Rotation prevents 94 + *new* access, not retroactive access. If the threat model requires forward secrecy for 95 + existing documents, the owner has to re-upload them after rotation. Opake doesn't do this 96 + automatically. 97 + 98 + ## Leave a workspace 99 + 100 + <CodeBlock language="typescript" title="leave.ts" code={leaveWorkspace} /> 101 + 102 + The member-side counterpart to being removed. Opting out goes through the same proposal path: 103 + the member writes a `keyringUpdate` with `actionType: "leave"` on their own PDS, the owner's 104 + daemon picks it up, and the standard rotate-and-re-wrap flow runs. 105 + 106 + Owners can't `leaveWorkspace` on their own workspace. Depending on the membership list they'd 107 + either leave themselves as the only member, or orphan the workspace entirely. To dissolve an 108 + owned workspace, remove the other members and then delete the keyring record directly. 109 + 110 + ## How proposals work 111 + 112 + Members who aren't the owner can't write to the owner's repository. The PDS auth model doesn't 113 + allow cross-account writes. So when an editor renames a file in a workspace, or uploads a new 114 + document, or moves a directory around, Opake doesn't touch the owner's PDS directly. Instead 115 + it writes a **proposal record** on the member's own PDS, describing the intended change. 116 + 117 + <CodeBlock language="typescript" title="proposal-flow.ts" code={proposalFlow} /> 118 + 119 + The owner's daemon (or wherever the owner's Opake instance is running) periodically scans for 120 + pending proposals against workspaces it owns. For each, it checks the proposer's role 121 + (`viewer` → reject; `editor`/`manager` → accept), applies the change as a canonical record on 122 + the owner's repository, and deletes the proposal from the member's PDS. 123 + 124 + The proposal record types are: 125 + 126 + - **`app.opake.documentUpdate`**: proposed document changes. Content updates, metadata edits, 127 + adoption (claiming an existing member-uploaded document into the owner's canonical tree). 128 + - **`app.opake.directoryUpdate`**: proposed structure changes. Add entry, remove entry, move 129 + entry, create directory, delete directory, rename directory. 130 + - **`app.opake.keyringUpdate`**: proposed membership changes. Add member, remove member, 131 + update role, rename workspace, update description, leave. 132 + 133 + From your application's perspective, this is mostly invisible. You call `fm.upload(...)` or 134 + `fm.move(...)`, the WASM side figures out whether the caller is the owner and routes 135 + accordingly, and you get a `MutationResult` back that tells you whether the write was applied 136 + directly or proposed. 137 + 138 + ## Reading `MutationResult` 139 + 140 + <CodeBlock 141 + language="typescript" 142 + title="mutation-result.ts" 143 + code={mutationResultHandling} 144 + /> 145 + 146 + `proposed: true` means the write is pending apply on the owner's side. The returned URI is the 147 + proposal record on the caller's own PDS, not the target document or directory on the owner's 148 + PDS (which doesn't exist yet). 149 + 150 + For optimistic UI, render the change immediately with a "pending" affordance. When the owner 151 + applies, the SSE consumer fires a tree update on the target directory and the pending marker 152 + can come off. If the owner rejects or if the daemon hasn't run in a while, the pending marker 153 + stays until the proposal is applied or cancelled. 154 + 155 + ## A note on the indexer 156 + 157 + Workspaces are discovered via the Opake [indexer](/docs/glossary#indexer), not the PDS firehose 158 + directly. `listWorkspaces` calls `/api/keyrings` with the caller's DID as a filter; the 159 + indexer returns every keyring where the caller appears in the member list (including 160 + keyrings the caller owns, since owners are always listed as the first member). 161 + 162 + If you're running against a self-hosted indexer instead of the default, set the URL via the 163 + `OPAKE_INDEXER_URL` env var at build time, or call `opake.setIndexerUrl()` at runtime. See 164 + the account config mechanic in [Authentication](/docs/sdk/authentication) for how a user's 165 + preferred indexer syncs across their devices. 166 + 167 + <DocsNext slug="workspaces" />
+13 -13
apps/web/src/content/docs/faq.mdx
··· 14 14 </FaqItem> 15 15 16 16 <FaqItem question="Where is my data actually stored?"> 17 - Opake uses a **bring-your-own-storage** design. Your encrypted files live on your PDS. In the 18 - future, we may offer managed storage options, but the goal is always to keep you in control of 19 - where your bits live. 17 + Opake is bring-your-own-storage. Your encrypted files live on your Personal Data Server 18 + (PDS) — either self-hosted or run by a provider you trust. The app is the interface; the 19 + storage is yours to move, copy, or walk away with. 20 20 </FaqItem> 21 21 22 22 <FaqItem question="Can Opake see my files?"> 23 - **No.** Encryption happens entirely on your device (browser or CLI) before any data is sent to 24 - internet. We never see the contents of your files, your filenames, your tags, or your private 25 - keys. To us, your data is just opaque noise. 23 + **No.** Encryption happens entirely on your device (browser or CLI) before any data is sent 24 + over the network. We never see the contents of your files, your filenames, your tags, or 25 + your private keys. From our perspective, your data is opaque bytes. 26 26 </FaqItem> 27 27 28 28 <FaqItem question="Can I share files with people who don't use Opake?"> 29 - Because of how our [Encryption & Keys](/docs/encryption) work, the recipient *must* have an 30 - AT Protocol identity (a DID) to receive a secure sharing [Grant](/docs/sharing). This is a 31 - feature, not a bug. It ensures that the file key is wrapped specifically to their public key. If 32 - you need to share with a total outsider, you'll need to help them join the network first—it's 33 - worth it for the privacy. 29 + The recipient needs an AT Protocol identity (a DID) to receive a [Grant](/docs/sharing) — the 30 + grant wraps the file's content key to their published public key, and there's no public key to 31 + wrap to if they've never set up an account. If you want to share with someone outside the 32 + network, help them sign up first. 34 33 </FaqItem> 35 34 36 35 <FaqItem question="What happens if I lose my device?"> ··· 47 46 </FaqItem> 48 47 49 48 <FaqItem question="How do I contribute?"> 50 - You can [View the Sourcecode on Tangled](https://tangled.org/opake.app/opake) or [report issues](https://tangled.org/opake.app/opake/issues) there. We are built for the community, by the community. 49 + The source is on [Tangled](https://tangled.org/opake.app/opake) — browse the code, open 50 + issues, or send a pull request. Everything happens in the open there. 51 51 </FaqItem> 52 52 </FaqSection> 53 53 54 54 <CenterAction 55 55 headline="Still curious?" 56 - subtext="The handbook covers everything from encryption to keyrings to device pairing." 56 + subtext="The handbook covers encryption, sharing, workspaces, and device pairing in detail." 57 57 > 58 58 <TextLink href="/docs"> 59 59 Read the full Handbook
+13
apps/web/src/content/docs/understand/_snippets.ts
··· 1 + // Code snippets for the /understand/ docs pages. 2 + // See apps/web/src/content/docs/build/sdk/_snippets.ts for the rationale 3 + // (MDX 3 dedents template literals inside .mdx files; imports bypass it). 4 + 5 + export const encryptBlob = `// The core primitive. 6 + pub fn encrypt_blob( 7 + plaintext: &[u8], 8 + key: &ContentKey 9 + ) -> Result<Vec<u8>, CryptoError> { 10 + // Generates 12-byte random nonce 11 + // Applies AES-GCM 12 + // Returns ciphertext with appended authentication tag 13 + }`;
+23 -31
apps/web/src/content/docs/understand/at-protocol.mdx
··· 1 1 <ChapterHeader title="The Open Network: Your Data, Anywhere" /> 2 2 3 3 <Lead> 4 - Opake isn't a walled garden; it's a resident of the Atmosphere — the open network built on the 5 - AT Protocol. Your data doesn't sit in a silo; it lives on a foundation you control, alongside 6 - everyone else's. 4 + Opake is built on the AT Protocol — the open, federated network behind Bluesky and the wider 5 + Atmosphere. Your data lives on infrastructure you control, interoperable with every other 6 + atproto-native app. 7 7 </Lead> 8 8 9 9 ## Why Opake was built on the Atmosphere 10 10 11 - The social web most of us grew up with is broken. For years we lived in digital fiefdoms where a 12 - single company could decide the fate of our data, our identities, and our connections. When Elon 13 - Musk bought Twitter and spent two years dismantling its safety and moderation systems, the urgency 14 - of a real alternative became undeniable. 11 + Most cloud products lock identity and storage into a single company. When that company's 12 + priorities shift, so does your access to your own data. When Elon Musk bought Twitter and spent 13 + two years dismantling its safety and moderation systems, a lot of people were reminded how much 14 + of their digital life depended on a single company's choices. 15 15 16 - The AT Protocol — and the wider Atmosphere around it — is one answer. It's an open, federated 17 - protocol: no single person or corporation gets to own your digital life. Opake didn't invent any 18 - of that. We picked it as our foundation because it already solves the hard parts of identity, 19 - portability, and federation, and because building on top of an ecosystem is a better long-term bet 20 - than trying to run our own silo. 16 + The AT Protocol is one answer to that problem. It's an open, federated protocol: identity, 17 + storage, and data portability are specified in the open, and no single company owns the pieces. 18 + Opake didn't invent any of it. We picked it as our foundation because it already solves the hard 19 + parts — identity, portability, federation — and because building on an existing ecosystem is a 20 + better long-term bet than running our own silo. 21 21 22 22 ### Breaking the silo 23 23 ··· 43 43 log in with your handle and your files will be right there. Our code and algorithms are 44 44 public; the "vault" belongs to you, we just provide the keys. 45 45 46 - <Callout type="info"> 47 - **Our Guarantee:** We don't want to trap you. We want to be the best way for you to manage your 48 - private cabinet, but we believe you should always have the right to leave and take your data with 49 - you. 50 - </Callout> 46 + We'd rather be the best way to manage your private cabinet than a lock-in. Leaving is a 47 + first-class path, not an afterthought — your data walks with you. 51 48 52 49 --- 53 50 54 - ## Effortless sharing across the network 51 + ## Sharing across the network 55 52 56 - The real advantage of an open network is how easily you can connect. In traditional encrypted 57 - apps, you have to exchange complex "invite codes" or "public keys" just to see a single file. 58 - 59 - Since everyone in the Atmosphere already has a digital identity, sharing is built-in. 53 + Most encrypted apps make sharing painful — you exchange invite codes or copy public keys around 54 + to see a single file. On the Atmosphere, every user already has a cryptographic identity, so 55 + sharing works off of handles you already know. 60 56 61 57 ### No more invite codes 62 58 ··· 68 64 2. It identifies their public lock on the network. 69 65 3. It creates a secure "Grant" that only their key can open. 70 66 71 - ### Collaboration Without Borders 67 + ### Across providers 72 68 73 - It doesn't matter if you are with one provider and your friend is with another. The servers 74 - talk to each other seamlessly. You can share a 1GB encrypted video with a friend on a 75 - different host, and they can stream it directly from your cabinet—safely, privately, 76 - and without any "middleman" watching from the sidelines. 69 + You and your friend don't need to be on the same PDS. The servers relay records and blobs to 70 + each other as part of the protocol — you can share a 1 GB encrypted video with someone on a 71 + different host and they stream it straight from your cabinet. The PDS on each side only ever 72 + sees ciphertext; the encryption is the access control. 77 73 78 74 --- 79 75 ··· 93 89 - **[Introduction to AT Protocol](https://mackuba.eu/2024/02/20/atproto-intro/):** A great "from scratch" deep dive into how the network functions. 94 90 - **[A Social Filesystem](https://overreacted.io/a-social-filesystem/):** Dan Abramov's vision of the AT Protocol as a decentralized filesystem—the very vision Opake is building. 95 91 96 - <Callout type="info"> 97 - **The Network is the App:** Opake is just a lens. The AT Protocol is the foundation. This means 98 - your digital life is no longer at the mercy of a single company's terms of service. 99 - </Callout> 100 92 101 93 Ready to learn about how we keep this open network private? Read about [Encryption & Keys](/docs/encryption).
+14 -18
apps/web/src/content/docs/understand/encryption.mdx
··· 1 + import { encryptBlob } from "./_snippets"; 2 + 1 3 <ChapterHeader title="Encryption & Keys" /> 2 4 3 5 <Lead> ··· 5 7 key wrapping for sharing and identity. 6 8 </Lead> 7 9 8 - ## The Cryptographic Reality 10 + ## What actually happens 9 11 10 - We don't use "bank-level security" buzzwords. We use standard, audited cryptographic primitives. If you're a developer, here is exactly what happens when you upload a file. 12 + Standard, audited cryptographic primitives. Here's what runs when you upload a file. 11 13 12 14 ### 1. Content Encryption 13 15 ··· 15 17 16 18 The file is then encrypted using **AES-256-GCM**. This algorithm is fast and handles arbitrary-size data efficiently. This produces the ciphertext blob that will actually be uploaded to your PDS. 17 19 18 - <CodeBlock language="rust" title="opake-core/src/crypto/content.rs"> 19 - {`// The core primitive. 20 - pub fn encrypt_blob( 21 - plaintext: &[u8], 22 - key: &ContentKey 23 - ) -> Result<Vec<u8>, CryptoError> { 24 - // Generates 12-byte random nonce 25 - // Applies AES-GCM 26 - // Returns ciphertext with appended authentication tag 27 - }`} 28 - </CodeBlock> 20 + <CodeBlock language="rust" title="opake-core/src/crypto/content.rs" code={encryptBlob} /> 29 21 30 22 ### 2. Key Wrapping (The Lockbox) 31 23 ··· 46 38 preventing replay attacks across different users or protocol versions. 47 39 </Callout> 48 40 49 - ## Cryptographic Heritage 41 + ## Influences 50 42 51 - Opake doesn't reinvent the wheel. We stand on the shoulders of giants who have spent decades perfecting modern, easy-to-use, and highly secure encryption. Our design is heavily influenced by the same primitives used by the **Signal Protocol**. 43 + Opake's primitives and patterns come from a few specific places: 52 44 53 - - **Signal's X25519 & Ed25519:** Just like Signal, we use Curve25519 (X25519) for our identity and key wrapping. It is the modern gold standard for elliptic curve cryptography—fast, secure, and designed to be resistant to many types of side-channel attacks. 54 - - **[Age (Actually Good Encryption)](https://github.com/FiloSottile/age):** Our file-based encryption model (encrypting for specific public key recipients) is conceptually very similar to the `age` tool. We believe in "static" encryption that is robust and doesn't rely on complex state-machine handshakes. 55 - - **[The Noise Protocol Framework](http://noiseprotocol.org/):** While we aren't a messaging protocol, we follow the Noise philosophy: using simple, composable primitives (Diffie-Hellman + HKDF + AEAD) to build secure systems. 45 + - **Signal Protocol** — Curve25519 (X25519) for identity and key wrapping. Used here for the 46 + same reasons Signal picked it: fast, well-understood, resistant to many side-channel attacks. 47 + - **[age](https://github.com/FiloSottile/age)** — the file-for-recipients model (one content key, 48 + wrapped to each recipient's public key) is borrowed directly. Same "static" encryption — 49 + nothing to keep in sync between sessions. 50 + - **[Noise Protocol Framework](http://noiseprotocol.org/)** — Opake isn't a messaging protocol, 51 + but the underlying composition (Diffie-Hellman + HKDF + AEAD) is the Noise recipe. 56 52 57 53 --- 58 54
+10 -5
apps/web/src/content/docs/understand/glossary.mdx
··· 8 8 9 9 ### AES-256-GCM 10 10 11 - The symmetric encryption algorithm we use for file contents. It's fast, secure, and industry-standard. 11 + The symmetric encryption algorithm used for file content. Authenticated (detects tampering), 12 + fast on modern hardware, and well-studied. 12 13 13 14 ### Indexer 14 15 ··· 16 17 17 18 ### Cabinet 18 19 19 - Our name for the Opake interface—the place where you manage your files, keys, and sharing grants. 20 + The Opake interface for your personal (non-shared) files. Where you manage uploads, keys, and 21 + outgoing sharing grants. 20 22 21 23 ### Content Key 22 24 ··· 34 36 35 37 A record that "grants" a specific person access to a file by wrapping the file's [Content Key](#content-key) for their [Public Key](#public-key). 36 38 37 - ### Keyring 39 + ### Workspace 38 40 39 - A named group of users that shares a common Group Key. It makes sharing folders with teams or families effortless. 41 + A named group of users sharing a common group key. Lets you share folders with teams or families 42 + without wrapping every file to every member individually. The underlying atproto record is named 43 + `app.opake.keyring` for legacy reasons; the app calls it a workspace everywhere it's user-facing. 40 44 41 45 ### PDS (Personal Data Server) 42 46 ··· 52 56 53 57 ### X25519 54 58 55 - The specific elliptic curve we use for asymmetric encryption and key wrapping. It's modern, fast, and secure. 59 + The elliptic curve used for asymmetric encryption and key wrapping. Same curve Signal and age 60 + use; well-understood, fast, resistant to many side-channel attacks. 56 61 57 62 --- 58 63
+25 -23
apps/web/src/content/docs/use/getting-started.mdx
··· 1 1 <ChapterHeader title="Getting Started" /> 2 2 3 3 <Lead> 4 - Though we've designed Opake to be as intuitive as possible, these documents are for if you want to 5 - go a little more in-depth about our technology. Feel free to just get started, or to read on. 6 - </Lead> 7 - 8 - <Lead> 9 - Opake is a radical departure from traditional cloud storage. You are not creating an account on 10 - our servers; you are bringing your own identity and storage to our interface. 4 + Opake works differently from most cloud storage. There's no account on our servers — you bring 5 + your own identity and your own storage, and the Opake app is the interface that ties them 6 + together. This page walks you through setting up. 11 7 </Lead> 12 8 13 - ## The Burden of Ownership 14 - 15 - Before we begin, an uncomfortable truth: **owning your data means owning your keys**. 9 + ## Owning your data means owning your keys 16 10 17 - In traditional systems, if you lose your password, a helpful corporation resets it for you. They can do this because they ultimately hold the master keys to your kingdom. Opake cannot do this. We do not have your keys. Your Personal Data Server (PDS) does not have your keys. 11 + In a traditional system, losing a password means a support team resets it for you. They can do that 12 + because they hold the master keys to your account. Opake can't. We don't have your keys, and 13 + neither does your Personal Data Server (PDS). 18 14 19 - When you first set up Opake, you'll receive a **24-word secret phrase** — a backup that can reconstruct your keys on any device. If you lose both your seed phrase and all your devices, your encrypted files remain mathematically opaque forever. This is not a flaw; it is the fundamental guarantee of end-to-end encryption. 15 + When you first set up Opake, you'll receive a **24-word secret phrase** — a backup that can 16 + reconstruct your keys on any device. If you lose both the phrase and every device you're signed 17 + in on, your encrypted files stay permanently unreadable. That's how end-to-end encryption works; 18 + there's no recovery channel to exploit. 20 19 21 20 <Callout type="warning"> 22 - Write down your 24 words and store it somewhere safe. It is the only way to recover your identity 21 + Write down your 24 words and store it somewhere safe. It is the only way to recover your files 23 22 if you lose access to all your devices. Read more in [Your Seed Phrase](/docs/seed-phrase). 24 23 </Callout> 25 24 ··· 35 34 1. Navigate to the login screen. 36 35 2. Enter your handle (e.g., `you.bsky.social`). 37 36 3. You will be redirected to your PDS (such as Bluesky) to authorize Opake. 38 - 4. Once authorized, Opake generates your encryption identity from a 24-word seed phrase. Write these words down — they're your backup. 37 + 4. Once authorized, Opake generates your encryption key from a 24-word seed phrase. Write these words down — they're your backup. 39 38 40 39 </PlatformTab> 41 40 <PlatformTab name="CLI"> ··· 48 47 49 48 ## 2. Keeping private what's in your drawers 50 49 51 - Inside your cabinet, everything looks familiar: files, folders, and organized grids. But this is a structure built entirely on your device. 50 + Inside your cabinet, everything looks familiar — files, folders, the usual grid view. That 51 + structure is built entirely on your device. 52 52 53 - On the network, your data is hidden in plain sight. Because Opake encrypts your file names, tags, and folder paths before they ever leave your hand, the storage provider sees only a collection of nameless, scrambled records. They can see that something exists, but they have no way of knowing it’s a "Project" folder or a "Financial" PDF. 53 + On the network, your data is hidden in plain sight. File names, tags, and folder paths are all 54 + encrypted before they leave your device, so the storage provider only sees a flat list of 55 + nameless scrambled records. They can tell something exists; they can't tell whether it's a 56 + "Project" folder or a "Financial" PDF. 54 57 55 - Your cabinet only "assembles" itself once you provide your keys. Opake pulls these scrambled pieces from the network and organizes them back into the familiar hierarchy you expect—instantly and only for you.downloads these encrypted records, decrypts the metadata locally, and reconstructs the folder hierarchy on the fly. 58 + Your cabinet assembles itself once you provide your keys. Opake pulls the encrypted records from 59 + the network, decrypts the metadata locally, and reconstructs the folder hierarchy — instantly, 60 + and only for you. 56 61 57 - <Callout type="info"> 58 - **Why this matters:** Even if your server is compromised by a malicious admin, they will only see 59 - a flat list of opaque data and encrypted records. They cannot even tell if a file is a PDF or an 60 - image, nor what folder you've stored it in. 61 - </Callout> 62 + Even if your server is compromised, an admin only sees opaque blobs and encrypted records. They 63 + can't tell which file is a PDF or an image, or what folder it sits in. 62 64 63 - Ready to dive deeper into how the math actually works? Read about [Encryption & Keys](/docs/encryption). 65 + Ready to go deeper into how the math actually works? Read about [Encryption & Keys](/docs/encryption).
+26 -22
apps/web/src/content/docs/use/pairing.mdx
··· 1 1 <ChapterHeader title="A Secure Handshake" /> 2 2 3 3 <Lead> 4 - Your identity in Opake isn't a username and password; it's a private key that stays with you. When 5 - you get a new phone or laptop, you need a way to pass that key to your new device without 6 - anyone—including the network—ever catching a glimpse. 4 + Your Opake identity is a private key, not a username and password. When you get a new phone or 5 + laptop, you need a way to hand that key over without the network — or anyone watching it — 6 + ever seeing it in plaintext. 7 7 </Lead> 8 8 9 - ## The Problem: Identity vs. Access 10 - 11 - Traditional apps "sync" your data by sending a master copy to a central server. If that server is compromised, your privacy vanishes. 9 + ## Identity vs. access 12 10 13 - Opake uses **Device Pairing**. Think of this as a private, one-on-one conversation between two of your own devices. They perform a cryptographic handshake to securely transfer your encryption identity so it never touches the internet in a readable form. 11 + Traditional apps sync your data by copying everything to a central server. If that server is 12 + compromised, so is your privacy. 14 13 15 - <Callout type="info"> 16 - **The Silent Messenger:** We use your Personal Data Server (PDS) as a "dumb relay." It passes the 17 - encrypted messages back and forth, but it doesn't speak the language and has no way of knowing 18 - what’s inside the envelopes. 19 - </Callout> 14 + Opake uses **Device Pairing** instead: a direct exchange between two of your own devices. They 15 + perform a cryptographic handshake that transfers your encryption identity through the PDS as 16 + ciphertext. The PDS relays the messages without understanding what's inside them. 20 17 21 18 --- 22 19 23 20 ## The Pairing Process 24 21 25 - This process ensures that your private keys are only ever unlocked on the devices you personally hold. 22 + Your private keys only leave one device to arrive on another. At no point are they readable by 23 + anyone but the two devices in the handshake. 26 24 27 - ### 1. The Request (New Device) 25 + ### 1. Requesting access (New Device) 28 26 29 27 On your new phone or laptop, you'll initiate the pairing. Your device generates a temporary "invitation" and publishes it to your PDS. This invitation includes a one-time lock that only this specific new device can open. 30 28 ··· 39 37 </PlatformTab> 40 38 </PlatformToggle> 41 39 42 - ### 2. The Approval (Existing Device) 40 + ### 2. Approvaling access (Existing Device) 43 41 44 42 Your current device will see a notification that a new guest is asking to join your cabinet. 45 43 ··· 50 48 3. Wraps your private identity keys inside that secret. 51 49 4. Posts the locked package back to your PDS. 52 50 53 - ### 3. The Completion (New Device) 51 + ### 3. Finishing up (New Device) 54 52 55 - The new device picks up the package, unlocks it using its temporary key, and saves your identity. It then "shreds" the invitation and response records from your PDS, leaving no trail behind. 53 + The new device picks up the response, unlocks it with its temporary key, and saves your 54 + identity. Then it deletes the request and response records from your PDS. 56 55 57 56 --- 58 57 59 58 ## Why is this safe? 60 59 61 - Even if someone were monitoring your PDS at the exact moment of the handshake, they could not steal your keys. 60 + Even if someone is actively watching your PDS during the handshake, they can't recover the keys. 62 61 63 - The package is scrambled using high-grade encryption that is physically unreadable to anyone except the device that started the request. Only the two devices involved in the handshake hold the necessary "strength" to decode the identity. 62 + The "temporary lock" your new device made in step 1 is unique to that device — only it holds 63 + the matching key. When your existing device wraps your identity inside that lock, the only 64 + party who can unlock the package is the new device itself. The PDS shuttles the locked package 65 + between the two devices; it never holds the key. 66 + 67 + For the specific algorithms Opake uses underneath, see [Encryption & Keys](/docs/encryption). 64 68 65 69 <Callout type="warning"> 66 - **The Human Element:** Always verify that the pairing request you are approving is actually from 67 - your own device. Approving a request is like handing over a physical key to your cabinet—only do 68 - it for devices you own and trust. 70 + Verify that the pairing request you're approving is actually from your own device. Approving a 71 + request is like handing over a physical key to your cabinet — only do it for devices you own 72 + and trust. 69 73 </Callout> 70 74 71 75 Ready to learn about the foundation of all this? Read about the [AT Protocol](/docs/at-protocol).
+26 -20
apps/web/src/content/docs/use/sharing.mdx
··· 1 1 <ChapterHeader title="Sharing & DIDs" /> 2 2 3 3 <Lead> 4 - In the traditional cloud, sharing a file means granting a server permission to show your data to 5 - someone else. In Opake, sharing means giving a key to your file to another user. 4 + Sharing on a traditional cloud means granting a server permission to show your data to someone. 5 + Sharing in Opake means handing someone a key to your file. Different model, different threat 6 + profile. 6 7 </Lead> 7 8 8 - ## The "Grant" Model 9 + ## The Grant model 9 10 10 - When you share a file with someone in Opake, you are creating a **Grant**. 11 - 12 - Think of a Grant as a small, specialized lockbox. Inside this box is the `Content Key` for your file. This lockbox is specially designed so that only the recipient's private key can open it. Once you've created this box, you leave it on the network. The recipient can then pick it up, open it with their key, and use the `Content Key` to decrypt the file directly from your PDS. 11 + When you share a file, you create a **Grant** — a small record containing the file's content 12 + key, encrypted so that only the recipient's private key can open it. You publish the grant 13 + record to your own PDS. The recipient finds it via the indexer, unwraps the key with their 14 + private key, and then fetches the encrypted file directly from your PDS. 13 15 14 - <Callout type="info"> 15 - **Zero Server Involvement:** Your PDS (and the recipient's PDS) act only as couriers. They never 16 - see the key, and they never see the file content. 17 - </Callout> 16 + Neither PDS sees the key or the file content. They're just relaying ciphertext between two 17 + clients. 18 18 19 19 --- 20 20 ··· 36 36 </PlatformTab> 37 37 </PlatformToggle> 38 38 39 - ## 2. Why DIDs Matter 39 + ## 2. Why DIDs matter 40 40 41 - You might know your friend as `@bob.bsky.social`, but Opake knows them as `did:plc:z724xy...`. 41 + You might know your friend as `@bob.bsky.social`, but Opake stores them internally as 42 + `did:plc:z724xy...`. 42 43 43 - A handle is just a nickname that can change. A **DID (Decentralized Identifier)** is a permanent, cryptographic ID. By sharing to a DID, the access remains valid even if your friend moves to a different PDS or changes their handle. 44 + A handle is a nickname that can change. A **DID (Decentralized Identifier)** is a permanent, 45 + cryptographic ID. Grants are keyed to DIDs, so access stays valid if your friend moves to a 46 + different PDS or swaps their handle. 44 47 45 - <Callout type="warning"> 46 - **Public Keys are Public:** Opake automatically publishes your public encryption key to your PDS 47 - when you log in. This is how others can wrap files to you without needing to ask for your 48 - "address" first. 48 + <Callout type="info"> 49 + Opake publishes your public encryption key to your PDS when you first log in. That's how 50 + someone else's client can wrap a file to you without you having to exchange an address 51 + out-of-band. 49 52 </Callout> 50 53 51 54 --- 52 55 53 - ## 3. Revoking Access 56 + ## 3. Revoking access 54 57 55 - If you want to stop sharing a file, you simply delete the Grant record. 58 + To stop sharing a file, delete the grant record. 56 59 57 - However, because Opake is truly decentralized, there is a nuance to revocation. Once a recipient has decrypted a file, they have a copy of the plaintext. Deleting a Grant prevents them from fetching _future_ updates or re-downloading the file if they lose their local copy, but it cannot "reach out" and delete the data from their physical device. 60 + There's an important nuance. Once a recipient has decrypted a file, they have the plaintext 61 + locally. Deleting the grant prevents them from fetching future updates or re-downloading if 62 + they lose their copy, but it can't reach out across the network to delete what they already 63 + have on disk. 58 64 59 65 Ready to see how to manage your identity across multiple devices? Read about [Multi-Device Magic](/docs/pairing).
+18 -14
apps/web/src/content/docs/use/troubleshooting.mdx
··· 1 1 <ChapterHeader title="Troubleshooting & Common Issues" /> 2 2 3 3 <Lead> 4 - Opake is a decentralized protocol, which means sometimes things can get a little messy. Here is 5 - how to fix the most common issues you might run into. 4 + Federated systems have more moving parts than centralised ones, so occasionally things break in 5 + novel ways. Here's a field guide to the issues we see most often. 6 6 </Lead> 7 7 8 8 ## Login & Authentication ··· 26 26 27 27 ## Files & Uploads 28 28 29 - ### Upload Fails halfway (Storage Limits) 29 + ### Upload fails halfway (storage limits) 30 30 31 - Large files can be tricky. Opake uploads your files as single blobs to the AT Protocol. 31 + Opake uploads files as single blobs to the AT Protocol, so large files stress both your 32 + connection and the PDS's blob limits. 32 33 33 - - **Politeness Limits:** By default, Opake limits the size of blobs it will upload to respect PDS 34 - providers we don't own. 35 - - **Increasing Storage:** You (or your PDS administrator) can increase these limits by uploading 36 - specific configuration records to your repository. Check our [Technical Spec](/docs/protocol) 37 - for the exact schema. 38 - - **Network Stability:** If your connection drops, the upload may fail. Try again when you have a 39 - more stable connection. 34 + - **Default size cap:** Opake caps upload size to stay within what most PDS providers accept 35 + without prior coordination. 36 + - **Increasing the cap:** You (or your PDS administrator) can raise it by publishing 37 + configuration records to your repository. See the [Lexicon reference](/docs/lexicons) for the 38 + schema. 39 + - **Network stability:** If your connection drops mid-upload, the upload fails and you have to 40 + re-send. Resume support is on the roadmap, not there yet. 40 41 41 42 ### "Unable to Decrypt File" 42 43 43 - This is the most serious error. It means Opake cannot open the "lockbox" for that file. 44 + The most serious error. Opake can't unwrap the key for this file under the identity you're 45 + signed in with. Two common causes: 44 46 45 - - **Wrong Account:** Ensure you are logged into the correct account (the one the file was shared with). 46 - - **Missing Keys:** If you recently logged in on a new device but didn't perform a [Device Pairing](/docs/pairing), you won't have the private keys necessary to decrypt existing files. 47 + - **Wrong account.** Check you're logged into the account the file was shared with. 48 + - **Missing keys.** If you recently signed in on a new device but haven't recovered from your 49 + seed phrase or completed a [Device Pairing](/docs/pairing), this device doesn't yet hold the 50 + private keys needed to decrypt. Go through recovery or pairing first. 47 51 48 52 --- 49 53
+35 -40
apps/web/src/content/docs/use/workspaces.mdx
··· 1 - <ChapterHeader title="Keyrings: Group Privacy" /> 2 - 3 - ## (Coming soon) 1 + <ChapterHeader title="Workspaces: group sharing" /> 4 2 5 3 <Lead> 6 - Direct sharing is great for one-off files, but what if you have a folder for your family, your 7 - coworkers, or your research group? This is where **Keyrings** come in. 4 + Direct sharing works for one-off files. For a folder shared with a family, a team, or a 5 + research group, you want something that scales past pairwise encryption. 8 6 </Lead> 9 7 10 - ## The "Group Key" Concept 8 + ## The group-key model 11 9 12 - In a traditional encrypted app, if you want to share a folder with 10 people, you have to encrypt every file for 10 different public keys. If you add an 11th person, you have to re-encrypt everything. This is slow, inefficient, and doesn't scale. 10 + A naive encrypted file-sharing app wraps every file to every recipient's public key. Ten members 11 + means ten copies of each content key. An eleventh person joining means re-encrypting everything. 13 12 14 - Opake solves this with **Keyrings**. 15 - 16 - A Keyring is a named group that has its own **Group Key (GK)**. Instead of encrypting files for individuals, you encrypt them for the Keyring. 13 + Opake uses **workspaces** instead — a named group that owns a single shared symmetric key (the 14 + "group key"). Files in the workspace have their content keys wrapped under the group key, not 15 + directly under each member's key. 17 16 18 - 1. The file's `Content Key` is wrapped under the **Group Key**. 19 - 2. The **Group Key** itself is then wrapped for each individual member of the group. 17 + 1. The file's content key is wrapped once under the group key. 18 + 2. The group key is wrapped once per member, under each member's X25519 public key. 20 19 21 - <Callout type="info"> 22 - **The Result:** When a new member joins the group, we only need to wrap the Group Key for them 23 - *once*. They immediately gain access to every file ever encrypted for that Keyring. 24 - </Callout> 20 + Adding a member means wrapping the group key for them once. They immediately gain access to 21 + every file ever uploaded under that workspace — no re-encryption of the files themselves. 25 22 26 23 --- 27 24 28 - ## 1. Creating a Keyring 25 + ## 1. Creating a workspace 29 26 30 - A Keyring is its own record on the AT Protocol (`app.opake.keyring`). 27 + A workspace is a single record on the AT Protocol (`app.opake.keyring`). 31 28 32 29 <PlatformToggle> 33 30 <PlatformTab name="Web App"> 34 31 35 - 1. Click the **(+)** icon in the Sidebar and select **New Keyring**. 32 + 1. Click the **(+)** icon in the Sidebar and select **New Workspace**. 36 33 2. Give it a name (e.g., "Family Photos"). 37 - 3. Opake generates a new Group Key, wraps it to your public key, and publishes the record. 34 + 3. Opake generates a group key, wraps it to your public key, and publishes the record. 38 35 39 36 </PlatformTab> 40 37 <PlatformTab name="CLI"> ··· 42 39 </PlatformTab> 43 40 </PlatformToggle> 44 41 45 - ## 2. Managing Membership 42 + ## 2. Managing membership 46 43 47 - Adding and removing members is a cryptographic operation. 44 + ### Adding a member 48 45 49 - ### Adding a Member 46 + Adding someone means wrapping the current group key to their public key and updating the 47 + workspace record. They can then decrypt anything stored under that workspace. 50 48 51 - When you add someone to a Keyring, you are simply taking the Group Key and wrapping it to their public key. 49 + ### Removing a member (key rotation) 52 50 53 - - **Effect:** They can now open the "Group Lockbox" and decrypt any file associated with that Keyring. 51 + When you remove a member, Opake rotates the group key so the removed member can't decrypt 52 + files uploaded after their removal: 54 53 55 - ### Removing a Member (Key Rotation) 56 - 57 - This is the most complex part of the system. To ensure the removed member can't access _future_ files, Opake performs a **Key Rotation**: 58 - 59 - 1. It generates a **NEW** Group Key (`GK_n+1`). 60 - 2. it wraps this new key for all _remaining_ members. 61 - 3. It keeps a history of the old keys so that older files can still be decrypted by the remaining group. 54 + 1. Generate a new group key. 55 + 2. Wrap it to all remaining members. 56 + 3. Archive the previous group key into the workspace's key history so remaining members can 57 + still decrypt files uploaded under older rotations. 62 58 63 59 <Callout type="warning"> 64 - **Forward Secrecy Reality:** Just like with [Grants](/docs/sharing), removing someone from a 65 - Keyring prevents them from accessing *future* files. If they already downloaded and decrypted old 66 - files, those files remain in their possession. 60 + Key rotation prevents the removed member from decrypting _future_ files. Anything they already 61 + downloaded and decrypted locally is theirs — rotation can't reach across the network to 62 + delete copies. 67 63 </Callout> 68 64 69 65 --- 70 66 71 - ## 3. Uploading to a Keyring 72 - 73 - When you upload a file, you can choose to associate it with a Keyring instead of an individual person. 67 + ## 3. Uploading to a workspace 74 68 75 69 <CodeBlock language="sh">opake upload "budget.pdf" --workspace "Family Photos"</CodeBlock> 76 70 77 - Every member of that Keyring will see the file in their [Inbox](/docs/sharing) and can decrypt it instantly. 71 + Every member of that workspace sees the file in their directory tree and can decrypt it 72 + without extra steps. 78 73 79 - Ready for a quick reference on all these terms? Check the [Glossary](/docs/glossary). 74 + Ready for a quick reference on the terminology? Check the [Glossary](/docs/glossary).
+189
apps/web/src/lib/docs-registry.ts
··· 29 29 } 30 30 31 31 /** 32 + * Human-facing labels for the sub-group keys used inside a category. Pages 33 + * register with `group: "sdk"`; the sidebar renders it as `@opake/sdk`. 34 + */ 35 + export const GROUP_META: Readonly<Record<string, string>> = { 36 + sdk: "@opake/sdk", 37 + react: "@opake/react", 38 + }; 39 + 40 + /** 32 41 * Audience metadata rendered on the docs landing and sidebar. "Under the 33 42 * hood" intentionally breaks the "For X" pattern — the section covers the 34 43 * crypto + protocol model, which anyone with curiosity can read regardless ··· 144 153 description: 145 154 "Install, initialise, and ship your first encrypted upload with the TypeScript SDK.", 146 155 }, 156 + { 157 + slug: "authentication", 158 + group: "sdk", 159 + category: "build", 160 + title: "Authentication", 161 + icon: "lock", 162 + description: 163 + "OAuth redirect flow, app-password fallback, proactive token refresh, session states.", 164 + }, 165 + { 166 + slug: "identity", 167 + group: "sdk", 168 + category: "build", 169 + title: "Identity & pairing", 170 + icon: "seedling", 171 + description: 172 + "Fresh creation, seed-phrase recovery, and device pairing. Private keys never touch JS.", 173 + }, 174 + { 175 + slug: "files", 176 + group: "sdk", 177 + category: "build", 178 + title: "Files & directories", 179 + icon: "folder", 180 + description: 181 + "The FileManager contract: upload, download, tree reads, structure changes, metadata and content edits, live subscriptions.", 182 + }, 183 + { 184 + slug: "sharing", 185 + group: "sdk", 186 + category: "build", 187 + title: "Sharing", 188 + icon: "share", 189 + description: 190 + "One-to-one grants, pending shares for recipients who haven't set up yet, inbox reads, and revocation semantics.", 191 + }, 192 + { 193 + slug: "workspaces", 194 + group: "sdk", 195 + category: "build", 196 + title: "Workspaces", 197 + icon: "group", 198 + description: 199 + "Create, list, and manage shared encrypted folders. Membership roles, key rotation, and the proposal model.", 200 + }, 201 + { 202 + slug: "events", 203 + group: "sdk", 204 + category: "build", 205 + title: "Live updates", 206 + icon: "lightning", 207 + description: 208 + "The SSE consumer lifecycle, bootstrap + reconnect semantics, token exchange, and keeper-backed watchers.", 209 + }, 210 + { 211 + slug: "storage", 212 + group: "sdk", 213 + category: "build", 214 + title: "Storage interface", 215 + icon: "book", 216 + description: 217 + "The Storage interface, built-in implementations, and how to write your own backend.", 218 + }, 219 + { 220 + slug: "overview", 221 + group: "react", 222 + category: "build", 223 + title: "@opake/react — Overview", 224 + icon: "book", 225 + description: 226 + "Provider, hook anatomy, and the optimistic overlay that keeps the UI in sync during in-flight mutations.", 227 + }, 228 + { 229 + slug: "queries", 230 + group: "react", 231 + category: "build", 232 + title: "Reading hooks", 233 + icon: "folder", 234 + description: 235 + "Subscription-backed and react-query-backed read hooks — directory trees, workspaces, inbox, shares, metadata.", 236 + }, 237 + { 238 + slug: "mutations", 239 + group: "react", 240 + category: "build", 241 + title: "Writing hooks", 242 + icon: "share", 243 + description: 244 + "Upload, delete, move, directory operations, sharing mutations, and how the optimistic overlay behaves.", 245 + }, 246 + { 247 + slug: "live-updates", 248 + group: "react", 249 + category: "build", 250 + title: "Live updates", 251 + icon: "lightning", 252 + description: 253 + "Gating the SSE auto-start, running the daemon, manually invalidating cached queries, account-switch semantics.", 254 + }, 255 + { 256 + slug: "lexicons", 257 + category: "build", 258 + title: "Lexicon reference", 259 + icon: "network", 260 + description: 261 + "The atproto collections, record schemas, and encryption envelope Opake publishes to a PDS.", 262 + }, 147 263 148 264 // -- Cross-cutting --------------------------------------------------------- 149 265 { ··· 171 287 export function docPath(doc: DocMeta): string { 172 288 return doc.group ? `/docs/${doc.group}/${doc.slug}` : `/docs/${doc.slug}`; 173 289 } 290 + 291 + export interface DocGroupBlock { 292 + readonly key: string; 293 + readonly label: string; 294 + readonly docs: readonly DocMeta[]; 295 + } 296 + 297 + /** 298 + * Split a category's docs into (ungrouped, grouped) for sidebar rendering. 299 + * Flat docs render as a straight list; grouped docs render nested under 300 + * their group label from {@link GROUP_META}. 301 + */ 302 + /** 303 + * Next doc in the registry that shares either the current doc's group 304 + * (nested pages like sdk/*) or its category (flat pages). The registry 305 + * order is the canonical reading order, so "next" is literally the next 306 + * entry that matches. `null` when the current doc is the last in its 307 + * sequence, which the UI should render as nothing. 308 + */ 309 + export function nextDoc(currentSlug: string): DocMeta | null { 310 + const index = DOCS_REGISTRY.findIndex((d) => d.slug === currentSlug); 311 + if (index === -1) return null; 312 + const current = DOCS_REGISTRY[index]!; 313 + for (let i = index + 1; i < DOCS_REGISTRY.length; i++) { 314 + const candidate = DOCS_REGISTRY[i]!; 315 + if (candidate.slug === "faq") continue; // FAQ is cross-cutting, not a chapter 316 + const sameGroup = current.group !== undefined && candidate.group === current.group; 317 + const sameFlatCategory = 318 + current.group === undefined && 319 + candidate.group === undefined && 320 + candidate.category === current.category; 321 + if (sameGroup || sameFlatCategory) return candidate; 322 + // Stop walking once we leave the current group or flat-category band — 323 + // we don't want `sdk/identity` to point at `react/overview` just because 324 + // it comes later in the array. 325 + if (current.group !== undefined && candidate.group !== current.group) return null; 326 + if (current.group === undefined && candidate.category !== current.category) return null; 327 + } 328 + return null; 329 + } 330 + 331 + export function partitionCategoryForSidebar(category: DocCategory): { 332 + readonly ungrouped: readonly DocMeta[]; 333 + readonly groups: readonly DocGroupBlock[]; 334 + } { 335 + const docs = docsByCategory(category); 336 + const ungrouped: DocMeta[] = []; 337 + const groupMap = new Map<string, DocMeta[]>(); 338 + 339 + for (const doc of docs) { 340 + if (doc.group === undefined) { 341 + ungrouped.push(doc); 342 + continue; 343 + } 344 + const existing = groupMap.get(doc.group); 345 + if (existing) { 346 + existing.push(doc); 347 + } else { 348 + groupMap.set(doc.group, [doc]); 349 + } 350 + } 351 + 352 + const groups: DocGroupBlock[] = []; 353 + for (const [key, groupDocs] of groupMap) { 354 + groups.push({ 355 + key, 356 + label: GROUP_META[key] ?? key, 357 + docs: groupDocs, 358 + }); 359 + } 360 + 361 + return { ungrouped, groups }; 362 + }
+32 -2
apps/web/src/routes/_public/docs/$category/$slug.tsx
··· 1 1 import type { ComponentType } from "react"; 2 2 import { createFileRoute } from "@tanstack/react-router"; 3 3 import { MdxContent } from "@/components/content/MdxProvider"; 4 + import { DocsSidebar } from "@/components/content/docs-sidebar"; 4 5 import { findDoc } from "@/lib/docs-registry"; 5 6 import { ogMeta } from "@/lib/og-meta"; 6 7 7 8 import SdkOverview from "@/content/docs/build/sdk/overview.mdx"; 9 + import SdkAuthentication from "@/content/docs/build/sdk/authentication.mdx"; 10 + import SdkIdentity from "@/content/docs/build/sdk/identity.mdx"; 11 + import SdkFiles from "@/content/docs/build/sdk/files.mdx"; 12 + import SdkSharing from "@/content/docs/build/sdk/sharing.mdx"; 13 + import SdkWorkspaces from "@/content/docs/build/sdk/workspaces.mdx"; 14 + import SdkEvents from "@/content/docs/build/sdk/events.mdx"; 15 + import SdkStorage from "@/content/docs/build/sdk/storage.mdx"; 16 + import ReactOverview from "@/content/docs/build/react/overview.mdx"; 17 + import ReactQueries from "@/content/docs/build/react/queries.mdx"; 18 + import ReactMutations from "@/content/docs/build/react/mutations.mdx"; 19 + import ReactLiveUpdates from "@/content/docs/build/react/live-updates.mdx"; 8 20 9 21 /** 10 22 * Nested docs route: /docs/{group}/{slug} — used by pages inside a ··· 16 28 Record<string, ComponentType<{ readonly components?: Record<string, ComponentType<never>> }>> 17 29 > = { 18 30 "sdk/overview": SdkOverview, 31 + "sdk/authentication": SdkAuthentication, 32 + "sdk/identity": SdkIdentity, 33 + "sdk/files": SdkFiles, 34 + "sdk/sharing": SdkSharing, 35 + "sdk/workspaces": SdkWorkspaces, 36 + "sdk/events": SdkEvents, 37 + "sdk/storage": SdkStorage, 38 + "react/overview": ReactOverview, 39 + "react/queries": ReactQueries, 40 + "react/mutations": ReactMutations, 41 + "react/live-updates": ReactLiveUpdates, 19 42 }; 20 43 21 44 function NestedDocChapterPage() { ··· 31 54 } 32 55 33 56 return ( 34 - <div className="mx-auto max-w-3xl px-6 pt-28 pb-20 sm:px-10"> 35 - <MdxContent Content={Content} className="prose" /> 57 + <div className="mx-auto flex w-full max-w-6xl gap-10 px-6 pt-28 pb-20 sm:px-10"> 58 + <aside className="hidden shrink-0 lg:block lg:w-60"> 59 + <div className="sticky top-24"> 60 + <DocsSidebar currentSlug={slug} currentGroup={category} /> 61 + </div> 62 + </aside> 63 + <main className="min-w-0 flex-1"> 64 + <MdxContent Content={Content} className="prose max-w-3xl" /> 65 + </main> 36 66 </div> 37 67 ); 38 68 }
+12 -2
apps/web/src/routes/_public/docs/$slug.tsx
··· 1 1 import type { ComponentType } from "react"; 2 2 import { createFileRoute } from "@tanstack/react-router"; 3 3 import { MdxContent } from "@/components/content/MdxProvider"; 4 + import { DocsSidebar } from "@/components/content/docs-sidebar"; 4 5 import { findDoc } from "@/lib/docs-registry"; 5 6 import { ogMeta } from "@/lib/og-meta"; 6 7 ··· 14 15 import AtProtocol from "@/content/docs/understand/at-protocol.mdx"; 15 16 import Glossary from "@/content/docs/understand/glossary.mdx"; 16 17 import Cli from "@/content/docs/build/cli.mdx"; 18 + import Lexicons from "@/content/docs/build/lexicons.mdx"; 17 19 import Faq from "@/content/docs/faq.mdx"; 18 20 19 21 const CONTENT_BY_SLUG: Partial< ··· 29 31 "at-protocol": AtProtocol, 30 32 glossary: Glossary, 31 33 cli: Cli, 34 + lexicons: Lexicons, 32 35 faq: Faq, 33 36 }; 34 37 ··· 45 48 } 46 49 47 50 return ( 48 - <div className="mx-auto max-w-3xl px-6 pt-28 pb-20 sm:px-10"> 49 - <MdxContent Content={Content} className="prose" /> 51 + <div className="mx-auto flex w-full max-w-6xl gap-10 px-6 pt-28 pb-20 sm:px-10"> 52 + <aside className="hidden shrink-0 lg:block lg:w-60"> 53 + <div className="sticky top-24"> 54 + <DocsSidebar currentSlug={slug} /> 55 + </div> 56 + </aside> 57 + <main className="min-w-0 flex-1"> 58 + <MdxContent Content={Content} className="prose max-w-3xl" /> 59 + </main> 50 60 </div> 51 61 ); 52 62 }
+10 -2
apps/web/src/routes/_public/docs/index.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 2 import { ogMeta } from "@/lib/og-meta"; 3 3 import { MdxContent } from "@/components/content/MdxProvider"; 4 + import { DocsSidebar } from "@/components/content/docs-sidebar"; 4 5 import DocsIndexContent from "@/content/docs/index.mdx"; 5 6 6 7 function DocsIndexPage() { 7 8 return ( 8 - <div className="px-6 pt-28 pb-20 sm:px-10"> 9 - <MdxContent Content={DocsIndexContent} /> 9 + <div className="mx-auto flex w-full max-w-6xl gap-10 px-6 pt-28 pb-20 sm:px-10"> 10 + <aside className="hidden shrink-0 lg:block lg:w-60"> 11 + <div className="sticky top-24"> 12 + <DocsSidebar /> 13 + </div> 14 + </aside> 15 + <main className="min-w-0 flex-1"> 16 + <MdxContent Content={DocsIndexContent} /> 17 + </main> 10 18 </div> 11 19 ); 12 20 }
+40 -10
apps/web/src/routes/cabinet/docs/$category/$slug.lazy.tsx
··· 3 3 import { ArrowLeftIcon } from "@phosphor-icons/react"; 4 4 import { PanelShell } from "@/components/cabinet/PanelShell"; 5 5 import { MdxContent } from "@/components/content/MdxProvider"; 6 + import { DocsSidebarCabinet } from "@/components/content/docs-sidebar"; 6 7 import { findDoc } from "@/lib/docs-registry"; 7 8 8 9 import SdkOverview from "@/content/docs/build/sdk/overview.mdx"; 10 + import SdkAuthentication from "@/content/docs/build/sdk/authentication.mdx"; 11 + import SdkIdentity from "@/content/docs/build/sdk/identity.mdx"; 12 + import SdkFiles from "@/content/docs/build/sdk/files.mdx"; 13 + import SdkSharing from "@/content/docs/build/sdk/sharing.mdx"; 14 + import SdkWorkspaces from "@/content/docs/build/sdk/workspaces.mdx"; 15 + import SdkEvents from "@/content/docs/build/sdk/events.mdx"; 16 + import SdkStorage from "@/content/docs/build/sdk/storage.mdx"; 17 + import ReactOverview from "@/content/docs/build/react/overview.mdx"; 18 + import ReactQueries from "@/content/docs/build/react/queries.mdx"; 19 + import ReactMutations from "@/content/docs/build/react/mutations.mdx"; 20 + import ReactLiveUpdates from "@/content/docs/build/react/live-updates.mdx"; 9 21 10 22 /** 11 23 * Nested cabinet docs route: /cabinet/docs/{group}/{slug}. ··· 18 30 19 31 const CONTENT_BY_PATH: Partial<Record<string, MdxComponent>> = { 20 32 "sdk/overview": SdkOverview, 33 + "sdk/authentication": SdkAuthentication, 34 + "sdk/identity": SdkIdentity, 35 + "sdk/files": SdkFiles, 36 + "sdk/sharing": SdkSharing, 37 + "sdk/workspaces": SdkWorkspaces, 38 + "sdk/events": SdkEvents, 39 + "sdk/storage": SdkStorage, 40 + "react/overview": ReactOverview, 41 + "react/queries": ReactQueries, 42 + "react/mutations": ReactMutations, 43 + "react/live-updates": ReactLiveUpdates, 21 44 }; 22 45 23 46 function NestedDocChapterPage() { ··· 55 78 56 79 return ( 57 80 <PanelShell depth={1} breadcrumbs={breadcrumbs} footer={`${meta.title} · Opake`}> 58 - <div className="overflow-y-auto p-6"> 59 - <MdxContent Content={Content} className="prose max-w-none text-sm leading-relaxed" /> 60 - <div className="border-border-accent/30 mt-10 border-t pt-4"> 61 - <Link 62 - to="/cabinet/docs" 63 - className="text-text-muted hover:text-primary text-ui inline-flex items-center gap-1.5 transition-colors" 64 - > 65 - <ArrowLeftIcon size={12} /> 66 - Back to docs 67 - </Link> 81 + <div className="flex gap-6 p-6"> 82 + <aside className="hidden w-44 shrink-0 md:block"> 83 + <div className="sticky top-0"> 84 + <DocsSidebarCabinet currentSlug={meta.slug} currentGroup={category} /> 85 + </div> 86 + </aside> 87 + <div className="min-w-0 flex-1"> 88 + <MdxContent Content={Content} className="prose max-w-none text-sm leading-relaxed" /> 89 + <div className="border-border-accent/30 mt-10 border-t pt-4"> 90 + <Link 91 + to="/cabinet/docs" 92 + className="text-text-muted hover:text-primary text-ui inline-flex items-center gap-1.5 transition-colors" 93 + > 94 + <ArrowLeftIcon size={12} /> 95 + Back to docs 96 + </Link> 97 + </div> 68 98 </div> 69 99 </div> 70 100 </PanelShell>
+20 -10
apps/web/src/routes/cabinet/docs/$slug.lazy.tsx
··· 3 3 import { ArrowLeftIcon } from "@phosphor-icons/react"; 4 4 import { PanelShell } from "@/components/cabinet/PanelShell"; 5 5 import { MdxContent } from "@/components/content/MdxProvider"; 6 + import { DocsSidebarCabinet } from "@/components/content/docs-sidebar"; 6 7 import { findDoc } from "@/lib/docs-registry"; 7 8 8 9 import GettingStarted from "@/content/docs/use/getting-started.mdx"; ··· 15 16 import AtProtocol from "@/content/docs/understand/at-protocol.mdx"; 16 17 import Glossary from "@/content/docs/understand/glossary.mdx"; 17 18 import Cli from "@/content/docs/build/cli.mdx"; 19 + import Lexicons from "@/content/docs/build/lexicons.mdx"; 18 20 import Faq from "@/content/docs/faq.mdx"; 19 21 20 22 type MdxComponent = ComponentType<{ ··· 32 34 "at-protocol": AtProtocol, 33 35 glossary: Glossary, 34 36 cli: Cli, 37 + lexicons: Lexicons, 35 38 faq: Faq, 36 39 }; 37 40 ··· 70 73 71 74 return ( 72 75 <PanelShell depth={1} breadcrumbs={breadcrumbs} footer={`${meta.title} · Opake`}> 73 - <div className="overflow-y-auto p-6"> 74 - <MdxContent Content={Content} className="prose max-w-none text-sm leading-relaxed" /> 75 - <div className="border-border-accent/30 mt-10 border-t pt-4"> 76 - <Link 77 - to="/cabinet/docs" 78 - className="text-text-muted hover:text-primary text-ui inline-flex items-center gap-1.5 transition-colors" 79 - > 80 - <ArrowLeftIcon size={12} /> 81 - Back to docs 82 - </Link> 76 + <div className="flex gap-6 p-6"> 77 + <aside className="hidden w-44 shrink-0 md:block"> 78 + <div className="sticky top-0"> 79 + <DocsSidebarCabinet currentSlug={meta.slug} /> 80 + </div> 81 + </aside> 82 + <div className="min-w-0 flex-1"> 83 + <MdxContent Content={Content} className="prose max-w-none text-sm leading-relaxed" /> 84 + <div className="border-border-accent/30 mt-10 border-t pt-4"> 85 + <Link 86 + to="/cabinet/docs" 87 + className="text-text-muted hover:text-primary text-ui inline-flex items-center gap-1.5 transition-colors" 88 + > 89 + <ArrowLeftIcon size={12} /> 90 + Back to docs 91 + </Link> 92 + </div> 83 93 </div> 84 94 </div> 85 95 </PanelShell>
+2
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 + | "IdentityMissing" 10 11 | "RecipientNotReady" 11 12 | "Auth" 12 13 | "Encryption" ··· 49 50 50 51 const KNOWN_KINDS = new Set<string>([ 51 52 "NotFound", 53 + "IdentityMissing", 52 54 "RecipientNotReady", 53 55 "Auth", 54 56 "Encryption",