···22import { Link } from "@tanstack/react-router";
33import { ArrowRightIcon } from "@phosphor-icons/react";
44import { resolveIcon } from "./icons";
55+import { nextDoc } from "@/lib/docs-registry";
5667/* ─── Docs index header ────────────────────────────────────────────────────── */
78···141142 />
142143 </Link>
143144 </div>
145145+ );
146146+}
147147+148148+/* ─── Linear "Next" chapter link ─────────────────────────────────────────── */
149149+150150+interface DocsNextProps {
151151+ /** Slug of the current page; the next doc in the same group/category is resolved from the registry. */
152152+ readonly slug: string;
153153+}
154154+155155+/**
156156+ * Small "Next chapter" link at the end of a doc page. Reads from
157157+ * {@link nextDoc} so the reading order is always the registry order —
158158+ * there's nothing to maintain per-page beyond the component usage.
159159+ * Renders nothing when the current doc is the last in its sequence.
160160+ */
161161+export function DocsNext({ slug }: DocsNextProps) {
162162+ const next = nextDoc(slug);
163163+ if (!next) return null;
164164+165165+ // `no-underline` defeats Tailwind Typography's default link styling; the
166166+ // card is its own visual affordance and doesn't need an underline on top.
167167+ const className =
168168+ "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";
169169+ const body = (
170170+ <>
171171+ <div className="flex flex-col">
172172+ <span className="text-text-muted text-ui">Next</span>
173173+ <span className="text-base-content text-[1.05rem] font-medium">{next.title}</span>
174174+ </div>
175175+ <ArrowRightIcon
176176+ size={18}
177177+ className="text-primary transition-transform group-hover:translate-x-1"
178178+ />
179179+ </>
180180+ );
181181+182182+ return next.group ? (
183183+ <Link to="/docs/$category/$slug" params={{ category: next.group, slug: next.slug }} className={className}>
184184+ {body}
185185+ </Link>
186186+ ) : (
187187+ <Link to="/docs/$slug" params={{ slug: next.slug }} className={className}>
188188+ {body}
189189+ </Link>
144190 );
145191}
146192
···11<ChapterHeader title="The CLI Manual" />
2233<Lead>
44- For the power users, the keyboard-bound, and the automation-obsessed. The Opake CLI is the
55- reference implementation of our protocol.
44+ The Opake CLI is the reference implementation of the protocol. Everything the web app and SDK
55+ can do, the CLI can do first — identity, files, sharing, workspaces, device pairing, daemon
66+ maintenance.
67</Lead>
7889## Installation
···8283---
83848485## 3. Sharing & Collaboration
8585-8686-Cryptographic access control at your fingertips.
87868887### Direct Sharing (Grants)
8988
+207
apps/web/src/content/docs/build/lexicons.mdx
···11+import {
22+ wrappedKeyShape,
33+ encryptionEnvelopeShape,
44+ keyringRefShape,
55+ encryptedMetadataShape,
66+ documentRecordShape,
77+ grantRecordShape,
88+ keyringRecordShape,
99+ documentUpdateShape,
1010+ directoryUpdateShape,
1111+} from "./lexicons/_snippets";
1212+1313+<ChapterHeader title="Lexicon reference" />
1414+1515+<Lead>
1616+ Opake publishes `app.opake.*` records to the caller's PDS. This page is a working
1717+ reference for the schemas — what a document record looks like on the wire, how the
1818+ encryption envelope is shaped, what fields the indexer relies on. The authoritative
1919+ schemas live in [`/lexicons`](https://tangled.org/opake.app/opake/tree/main/lexicons)
2020+ in the repo.
2121+</Lead>
2222+2323+## Collections
2424+2525+| NSID | Type | Purpose |
2626+|---|---|---|
2727+| `app.opake.accountConfig` | record (singleton) | Per-account preferences, indexer URL pin |
2828+| `app.opake.publicKey` | record (singleton) | X25519 encryption public key for key discovery |
2929+| `app.opake.document` | record | An encrypted file |
3030+| `app.opake.directory` | record | Organisational grouping of documents + sub-directories |
3131+| `app.opake.grant` | record | One-to-one share grant |
3232+| `app.opake.keyring` | record | Workspace (named group with a shared symmetric key) |
3333+| `app.opake.pendingShare` | record | Queued share waiting for recipient to publish their key |
3434+| `app.opake.documentUpdate` | record | Proposed content or metadata change to another member's document |
3535+| `app.opake.directoryUpdate` | record | Proposed structural change to a workspace directory |
3636+| `app.opake.keyringUpdate` | record | Proposed membership / role / metadata change to a keyring |
3737+| `app.opake.invitation` | record | Workspace invitation with token and role |
3838+| `app.opake.invitationAcceptance` | record | Acceptance of a workspace invitation |
3939+| `app.opake.pairRequest` | record | New-device identity-transfer request |
4040+| `app.opake.pairResponse` | record | Existing-device encrypted identity payload |
4141+| `app.opake.defs` | defs | Shared types (wrappedKey, encryptionEnvelope, keyringRef, encryptedMetadata) |
4242+| `app.opake.authFullAccess` | permission-set | OAuth scope bundle covering every `app.opake.*` collection |
4343+4444+Everything record-typed lives in the PDS's repo. The only blobs are the encrypted file
4545+contents referenced from `app.opake.document.blob` — up to 50 MB per the default PDS
4646+config.
4747+4848+## Encryption primitives
4949+5050+Three small shapes underpin every opaque field on every record. They live in
5151+[`app.opake.defs`](https://tangled.org/opake.app/opake/blob/main/lexicons/app.opake.defs.json)
5252+as shared refs.
5353+5454+### `wrappedKey`
5555+5656+<CodeBlock language="json" title="wrappedKey" code={wrappedKeyShape} />
5757+5858+A symmetric key encrypted to a specific recipient's X25519 public key. `algo` is
5959+`x25519-hkdf-a256kw` — ephemeral-sender Diffie-Hellman, HKDF-derived KEK, AES-KeyWrap
6060+over the content key. `ciphertext` is `[32-byte ephemeral pubkey || 40-byte wrapped
6161+key]`.
6262+6363+### `encryptionEnvelope`
6464+6565+<CodeBlock language="json" title="encryptionEnvelope" code={encryptionEnvelopeShape} />
6666+6767+Full shape for direct-sharing: the content key is wrapped once per recipient, all
6868+copies listed in `keys`. AES-256-GCM on the blob with the given `nonce`. One content
6969+key, many wrapped copies. Rotating a recipient's wrap doesn't affect the others.
7070+7171+### `keyringRef`
7272+7373+<CodeBlock language="json" title="keyringRef" code={keyringRefShape} />
7474+7575+The workspace alternative to `encryptionEnvelope`. Instead of wrapping the content
7676+key to each member directly, wrap it under the keyring's group key once. Members
7777+unwrap the group key from their keyring entry, then use the group key to unwrap the
7878+content key. `rotation` identifies which generation of the group key was used — the
7979+keyring record keeps historical rotations in `keyHistory` so remaining members can
8080+still decrypt pre-rotation documents.
8181+8282+### `encryptedMetadata`
8383+8484+<CodeBlock language="json" title="encryptedMetadata" code={encryptedMetadataShape} />
8585+8686+Record-level metadata (filename, MIME type, size, tags, description) is encrypted in
8787+one blob with the same key protecting the record's payload. The PDS only ever sees
8888+ciphertext for these fields — there's no server-side search over real filenames.
8989+Record-level fields that ARE in plaintext (the `blob.mimeType`, a placeholder `name`)
9090+are always dummy values.
9191+9292+## Record shapes
9393+9494+### `app.opake.document`
9595+9696+<CodeBlock language="json" title="document" code={documentRecordShape} />
9797+9898+The `encryption` field is a tagged union. `directEncryption` carries a full
9999+`encryptionEnvelope` — used for cabinet documents and one-to-one shares. Workspace
100100+documents use `keyringEncryption` instead: just the `keyringRef`, the algo, and the
101101+nonce. The content key isn't stored on the document at all; it's protected by the
102102+keyring's group key.
103103+104104+`opakeVersion` lets clients reject records they don't understand. Bumping it on a
105105+schema change means old clients see a decryption failure, not a silent mis-parse.
106106+107107+### `app.opake.grant`
108108+109109+<CodeBlock language="json" title="grant" code={grantRecordShape} />
110110+111111+One grant per (document, recipient) pair. The grant lives on the **sharer's** PDS,
112112+not the recipient's — the recipient discovers the grant by subscribing to the indexer.
113113+Grants reference the document by AT-URI; there's no keyring involved. Revoking a
114114+grant is a `deleteRecord` call; there's no key rotation because the document was
115115+already decryptable by the sharer's content key.
116116+117117+### `app.opake.keyring`
118118+119119+<CodeBlock language="json" title="keyring" code={keyringRecordShape} />
120120+121121+A workspace. `members` is keyed by DID, with each entry carrying a wrapped copy of
122122+the current group key plus the member's role (`manager` / `editor` / `viewer`).
123123+`keyHistory` retains the wrapped copies from prior rotations so members still in the
124124+workspace can decrypt documents uploaded under older generations of the group key.
125125+126126+Removing a member atomically:
127127+128128+1. Generates a new group key, bumps `rotation`.
129129+2. Archives the old `members` dict into `keyHistory[rotation - 1]`.
130130+3. Writes the new `members` dict (without the removed member, re-wrapped to the rest).
131131+132132+Per-document content keys and blobs aren't touched. The removed member could still
133133+decrypt anything uploaded under a prior rotation with a cached copy of the old group
134134+key — "no revocation guarantee for historical access" is called out up front; apps
135135+that need forward-secret removal must re-upload documents after rotation.
136136+137137+### `app.opake.documentUpdate`
138138+139139+<CodeBlock language="json" title="documentUpdate" code={documentUpdateShape} />
140140+141141+Workspace proposals. When a member who isn't the owner wants to update a document,
142142+they write this record to their own PDS. The indexer sees it via firehose, validates
143143+the proposer's role against the target keyring, and surfaces it for the owner to
144144+apply. Once the owner applies (writing the updated document to their own PDS), the
145145+proposal record is deleted from the member's PDS.
146146+147147+`actionType` distinguishes `replaceContent` (new blob + nonce) from `replaceMetadata`
148148+(only the encrypted metadata envelope changed).
149149+150150+### `app.opake.directoryUpdate`
151151+152152+<CodeBlock language="json" title="directoryUpdate" code={directoryUpdateShape} />
153153+154154+Same pattern as `documentUpdate` but for structural changes — move entries between
155155+directories, rename, create, delete. The indexer validates, the owner applies,
156156+proposal gets cleaned up.
157157+158158+## Indexing
159159+160160+The indexer subscribes to the atproto firehose and consumes seven `app.opake.*`
161161+collections. For each event, it:
162162+163163+1. Parses the record against the lexicon schema (rejects malformed records).
164164+2. Resolves the keyring membership if the record is workspace-scoped (rejects
165165+ records from non-members).
166166+3. Writes to its own Postgres index.
167167+4. Fans out to SSE subscribers via Phoenix PubSub.
168168+169169+The indexer never decrypts anything. All the filtering is over plaintext metadata
170170+(DIDs, AT-URIs, timestamps, roles) — the encrypted payload passes through opaque.
171171+172172+See [Live updates](/docs/sdk/events) for the client-side stream consumer, and
173173+[docs/indexer.md](https://tangled.org/opake.app/opake/blob/main/docs/indexer.md)
174174+for the indexer's own configuration and API.
175175+176176+## Schema versioning
177177+178178+Every record carries `opakeVersion: integer`. Current version is `1` across the board.
179179+The compatibility contract:
180180+181181+- **Minor additions** (optional fields, new known values for string enums) don't bump
182182+ the version. Old clients ignore unknown fields.
183183+- **Breaking changes** (required field additions, envelope restructures, algorithm
184184+ swaps) bump the version. Old clients are expected to reject.
185185+- **Decryption-relevant changes** always bump the version, even if the wire format
186186+ looks backward-compatible. We'd rather an old client fail fast than silently
187187+ decrypt under the wrong assumption.
188188+189189+If you're building a client that needs to survive across Opake versions, validate
190190+`opakeVersion` up front and fall back to "please upgrade" messaging for anything past
191191+your supported set.
192192+193193+## Getting the authoritative schemas
194194+195195+The JSON schema files in [`/lexicons`](https://tangled.org/opake.app/opake/tree/main/lexicons)
196196+are the canonical source. Every field description, constraint, and `knownValues` list is
197197+there. Clone the repo and wire them into your codegen:
198198+199199+```sh
200200+git clone https://tangled.org/opake.app/opake
201201+# then point your lexicon codegen at opake/lexicons/
202202+```
203203+204204+The Rust core uses `atproto-rs` codegen over these same files. The TypeScript SDK
205205+re-exports the field-level types via `@opake/sdk` — check
206206+[Files & directories](/docs/sdk/files) and [Sharing](/docs/sdk/sharing) for how they
207207+surface in the high-level API.
···11+import {
22+ useStartSseGated,
33+ useStartSseOverride,
44+ useDaemonExample,
55+ manualInvalidation,
66+ keyFactoriesShape,
77+} from "./_snippets";
88+99+<ChapterHeader title="@opake/react — Live updates" />
1010+1111+<Lead>
1212+ The provider auto-starts the SSE consumer, so most apps don't need to think about live
1313+ updates at all. This page covers the escape hatches: gating the stream on runtime
1414+ conditions, running the daemon for background maintenance, and invalidating the few
1515+ queries that don't flow through SSE.
1616+</Lead>
1717+1818+## What's live by default
1919+2020+Render an `OpakeProvider` and you get:
2121+2222+- `useDirectory` — directory trees update as `document:upsert`, `directory:upsert`,
2323+ `document:delete`, `directory:delete` events arrive from the indexer.
2424+- `useWorkspaces` — workspace list updates on `keyring:upsert` / `keyring:delete`.
2525+- `useInbox` — incoming shares update on `grant:upsert` / `grant:delete` for the caller's
2626+ personal topic.
2727+- Mutations — the optimistic overlay reflects writes in the same render tick, then dedups
2828+ against the echo when it arrives.
2929+3030+No polling, no timers, no manual refetching. A typical PDS-write-to-SSE-echo round-trip is
3131+under a second, so remote changes from other devices surface within that window.
3232+3333+## Gating the auto-start
3434+3535+The provider's auto-start is unconditional: it fires a `startSseConsumer` call on mount.
3636+That's fine when you only render the provider after the user is authenticated. It's a
3737+problem when you wrap the whole app in the provider and rely on internal auth state to
3838+decide who's logged in — an unauthenticated start triggers a token-exchange request that
3939+fails with `Auth`.
4040+4141+Two options. The clean one: render the provider conditionally. Unmount when the user signs
4242+out, mount when they sign in. `OpakeProvider` already cleans up on unmount (stops the
4343+stream, wipes keeper state).
4444+4545+The escape hatch: set `disableSseAutoStart` and drive the start yourself with
4646+`useStartSseConsumer`:
4747+4848+<CodeBlock language="tsx" title="sse-gate.tsx" code={useStartSseGated} />
4949+5050+`useStartSseConsumer` is deliberately start-only. There's no corresponding stop — it
5151+exists to let you delay the start past provider mount, not to toggle the stream on and
5252+off during a session. If you need stop-on-condition, use the conditional-provider pattern
5353+above.
5454+5555+### Override the indexer URL
5656+5757+The consumer resolves its URL from a priority chain: runtime override →
5858+`accountConfig.indexerUrl` on the user's PDS → the compile-time default. Pass an explicit
5959+URL to `useStartSseConsumer` to win the chain:
6060+6161+<CodeBlock language="tsx" title="override.ts" code={useStartSseOverride} />
6262+6363+Typical use: pinning to a specific indexer for testing, or overriding the default in a
6464+development build.
6565+6666+## The daemon
6767+6868+`@opake/react` ships a side-effect hook for the background daemon:
6969+7070+<CodeBlock language="tsx" title="daemon-runner.tsx" code={useDaemonExample} />
7171+7272+The daemon is pure maintenance now — SSE replaced the tree-sync job, but the daemon still
7373+runs:
7474+7575+- Pending-share retries (when a recipient publishes their encryption key, the daemon
7676+ promotes queued pending shares to real grants).
7777+- Stale pair-request cleanup (requests past their TTL get deleted from the PDS).
7878+- Grant-healing (finding and repairing grants whose wrapped key is missing or
7979+ invalidated by a keyring rotation).
8080+8181+`useDaemon` is imperative: it starts the daemon on mount, stops it on unmount, and returns
8282+nothing. Task state lives in the `TaskStore` you pass in — implement the interface with
8383+IndexedDB (for persistence across reloads) or an in-memory Map (for tests / SSR). The
8484+daemon's own data flow is opaque to React; if you want to surface task status, subscribe
8585+to the `TaskStore` from your own component state.
8686+8787+`onSessionExpired` is called exactly once when a task fails with an `Auth` error. Wire it
8888+to your app's logout path — by the time this fires the session is dead and no further
8989+Opake calls will succeed without re-auth.
9090+9191+## Manual invalidation
9292+9393+Most state is SSE-driven. The few queries that aren't:
9494+9595+- `useShares(documentUri)` / `usePendingShares()` — react-query backed, invalidated
9696+ automatically by `useShareFile` / `useRevokeShare` / `useCancelPendingShare` on success.
9797+ If you know an external write landed (e.g. a CLI on another device revoked a grant),
9898+ invalidate manually.
9999+- `useDirectoryMetadata(keyringUri, directoryUri)` — invalidated by every tree mutation
100100+ the React layer runs. Not invalidated for writes from other devices.
101101+- Daemon task state — lives in your `TaskStore`, separate from react-query.
102102+103103+For the first two, `opakeKeys` gives you stable query-key factories:
104104+105105+<CodeBlock language="tsx" title="refresh-button.tsx" code={manualInvalidation} />
106106+107107+The full list of key factories:
108108+109109+<CodeBlock language="typescript" title="opakeKeys" code={keyFactoriesShape} />
110110+111111+Invalidating `opakeKeys.all()` nukes every Opake-owned query, which forces a cold refetch
112112+of everything react-query caches. Useful as a "something's really off, start over" button;
113113+heavy otherwise.
114114+115115+## Account switching
116116+117117+Passing a new `Opake` instance to the provider's `opake` prop creates a fresh cache. The
118118+overlay, FileManager cache, and keeper state all rebuild from the new instance; patches
119119+and cached FileManagers from the old identity don't leak across.
120120+121121+If your app supports multiple signed-in accounts, the typical pattern is: keep one
122122+`OpakeProvider` at the root, re-key it when the active account changes. The WASM module
123123+stops the old stream + wipes keeper state on unmount, then starts fresh on the new mount.
124124+125125+<DocsNext slug="live-updates" />
···11+import {
22+ useUploadExample,
33+ useDownloadExample,
44+ useDeleteExample,
55+ useMoveExample,
66+ useDirectoryMutationsExample,
77+ useShareMutationsExample,
88+ useCreateWorkspaceExample,
99+ overlayMechanics,
1010+} from "./_snippets";
1111+1212+<ChapterHeader title="@opake/react — Writing hooks" />
1313+1414+<Lead>
1515+ Mutation hooks wrap the SDK's write operations. Every tree-level mutation is paired with
1616+ an optimistic overlay patch so the UI reflects the change synchronously; hooks that
1717+ don't affect the tree (sharing, workspace creation, downloads) go through plain
1818+ react-query mutations.
1919+</Lead>
2020+2121+## The keyringUri parameter
2222+2323+Every tree mutation hook takes a `keyringUri: string | null`. Pass `null` for the cabinet
2424+or a workspace's keyring URI otherwise. The keyringUri determines:
2525+2626+- Which `FileManager` the hook acquires from the provider's cache.
2727+- Which optimistic overlay scope receives the patch (so `useDirectory` watching the same
2828+ scope re-renders optimistically).
2929+- Which query-cache entry gets invalidated on settle.
3030+3131+If your component is rendered for a single context, thread the keyringUri in via props or
3232+a context value and pass it once. Don't rebuild it per call — each distinct keyringUri
3333+builds a separate overlay scope.
3434+3535+## File operations
3636+3737+### `useUpload(keyringUri)`
3838+3939+<CodeBlock language="tsx" title="upload-button.tsx" code={useUploadExample} />
4040+4141+The input carries `{ data: Uint8Array, filename, mimeType, description?, tags?,
4242+directoryUri? }`. Omit `directoryUri` to upload to the context root. The optimistic
4343+overlay inserts a placeholder entry in the target directory right away; the real entry
4444+replaces it when the SSE echo arrives (~1s after PDS write).
4545+4646+Results are `UploadResult = { uri, proposed }` from the SDK. In a workspace you don't own,
4747+`proposed: true` means the upload landed as a `documentUpdate` proposal on your own PDS;
4848+the workspace owner's daemon applies it. See [workspaces](/docs/sdk/workspaces) for the
4949+proposal flow.
5050+5151+### `useDownload(keyringUri)`
5252+5353+<CodeBlock language="tsx" title="download-button.tsx" code={useDownloadExample} />
5454+5555+Downloads aren't cached — the hook is a `useMutation`, not a `useQuery`, because caching
5656+decrypted plaintext in react-query would leak it into memory for longer than necessary.
5757+Each call re-fetches and re-decrypts.
5858+5959+Returns `{ filename, data: Uint8Array }`. `filename` is the decrypted original name;
6060+`data` is the plaintext. Both are safe to drop from memory as soon as you've handed them
6161+off (to a `Blob`, a worker, whatever).
6262+6363+### `useDelete(keyringUri)`
6464+6565+<CodeBlock language="tsx" title="delete-button.tsx" code={useDeleteExample} />
6666+6767+`parentDirectoryUri` is required. The delete is atomic with removing the entry from its
6868+parent, so the hook needs to know which directory to patch. The optimistic overlay filters
6969+the entry out of the parent immediately; the SSE echo confirms.
7070+7171+### `useMove(keyringUri)`
7272+7373+<CodeBlock language="tsx" title="drag-drop.tsx" code={useMoveExample} />
7474+7575+Moves work across directories in the same context only. A cabinet document can move
7676+between cabinet directories; a workspace document can move between directories in that
7777+workspace. Cross-context moves (workspace → cabinet, cabinet → workspace) aren't a move —
7878+they're a download + upload + delete, and the SDK deliberately doesn't hide that cost
7979+behind a single call.
8080+8181+The input is `{ entryUri, sourceDirUri, targetDirUri }`. Works for both documents and
8282+sub-directories — the tree operation is uniform.
8383+8484+## Directory operations
8585+8686+<CodeBlock language="tsx" title="directory-actions.tsx" code={useDirectoryMutationsExample} />
8787+8888+- `useCreateDirectory(keyringUri)` takes `{ name, parentUri? }`. Omit `parentUri` to
8989+ create at the root. The optimistic overlay inserts a placeholder directory entry
9090+ immediately; the real entry replaces it on echo.
9191+- `useRenameDirectory(keyringUri)` takes `{ directoryUri, newName }`. Renames stay
9292+ optimistic: the tree reflects the new name synchronously.
9393+- `useDeleteDirectory(keyringUri)` takes `{ directoryUri }` and recursively deletes the
9494+ directory and every document inside it. Returns a `{ documentsDeleted,
9595+ directoriesDeleted }` count on success.
9696+9797+## Sharing
9898+9999+<CodeBlock language="tsx" title="share-buttons.tsx" code={useShareMutationsExample} />
100100+101101+`useShareFile` takes `{ documentUri, handleOrDid, note? }`. All grants are read-only;
102102+there's no permissions parameter. On success the mutation returns `{ pending: false }` for
103103+a direct share, or `{ pending: true }` when the recipient has a valid atproto identity but
104104+hasn't published an Opake encryption key yet — at which point it's been queued as a
105105+pending share for the daemon to retry.
106106+107107+On success the hook invalidates both the per-document `useShares(documentUri)` query and
108108+the top-level `usePendingShares()` query, so the lists refresh automatically.
109109+110110+`useRevokeShare` takes a grant URI. Deletes the grant record from your PDS; the recipient
111111+sees the grant disappear from their `useInbox()` on the next firehose round-trip. All
112112+`useShares` queries are invalidated on success.
113113+114114+## Workspaces
115115+116116+<CodeBlock language="tsx" title="new-workspace-form.tsx" code={useCreateWorkspaceExample} />
117117+118118+`useCreateWorkspace` takes `{ name, description? }` and returns `{ keyringUri, key }`
119119+(the `key` is the group key; you don't usually need it — subsequent calls re-resolve via
120120+the URI and the caller's Identity).
121121+122122+The new workspace appears in `useWorkspaces()` automatically via the SSE `keyring:upsert`
123123+echo — no cache invalidation needed. Membership management hooks (add/remove member,
124124+leave workspace) aren't wrapped yet; call `opake.addWorkspaceMember(...)` /
125125+`opake.removeWorkspaceMember(...)` / `opake.leaveWorkspace(...)` directly from `useOpake`.
126126+127127+## How the optimistic overlay behaves
128128+129129+<CodeBlock language="typescript" title="timeline" code={overlayMechanics} />
130130+131131+Under the hood:
132132+133133+- Each mutation hook wraps `useTreeMutation`, which takes an optional `optimisticUpdate`
134134+ function `(snapshot, input) => snapshot`. That function is called on `onMutate` to
135135+ compute the patched snapshot; the patch gets pushed onto the provider's `OptimisticOverlay`.
136136+- `useDirectory` reads `snapshot.project(scope, base)` — the base from the keeper, with
137137+ overlay patches composed on top. The projection is a no-op when there are no patches,
138138+ so there's no per-render cost when nothing is in flight.
139139+- Patches release 2 seconds after the mutation settles. The delay spans the typical
140140+ PDS-write-to-SSE-echo round-trip; dropping the patch earlier would briefly reveal the
141141+ pre-mutation tree between the SDK promise resolving and the echo arriving.
142142+- On error, the patch releases immediately (no server-side state to wait for) and the UI
143143+ snaps back.
144144+145145+If you build a custom mutation that needs the same behaviour, `useTreeMutation` is
146146+exported. Pass your own `mutationFn` and `optimisticUpdate` — see the source of
147147+`useUpload` / `useMove` for real examples.
148148+149149+<DocsNext slug="mutations" />
···11+import {
22+ providerSetup,
33+ helloHook,
44+ providerWithQueryClient,
55+ providerDisableSse,
66+} from "./_snippets";
77+88+<ChapterHeader title="@opake/react — Overview" />
99+1010+<Lead>
1111+ React bindings on top of `@opake/sdk`. One provider at the root, a handful of hooks for
1212+ common read/write patterns, and an optimistic overlay that keeps the UI in sync during
1313+ in-flight mutations.
1414+</Lead>
1515+1616+## Install
1717+1818+<CodeBlock language="sh" code={`bun add @opake/react @opake/sdk @tanstack/react-query react`} />
1919+2020+`@opake/react` depends on `@opake/sdk` and `@tanstack/react-query`. React 18+ is required;
2121+the hooks use modern concurrent features.
2222+2323+## The provider
2424+2525+<CodeBlock language="tsx" title="provider-setup.tsx" code={providerSetup} />
2626+2727+`OpakeProvider` does three things:
2828+2929+- Exposes the passed `Opake` instance via `useOpake()`.
3030+- Holds a refcounted `FileManagerCache` so multiple components asking for the same
3131+ `FileManager` share one handle. The cache clears when the provider unmounts.
3232+- Starts the WASM SSE consumer on mount and stops it + wipes keepers on unmount. This is
3333+ why the provider is a *component*: its lifecycle is where the live-update stream lives.
3434+3535+All hooks described on the other pages assume they're rendered inside this provider.
3636+3737+## A first hook
3838+3939+<CodeBlock language="tsx" title="hello-hook.tsx" code={helloHook} />
4040+4141+`useDirectory(keyringUri, directoryUri)` is a subscription. The first argument selects the
4242+context (null for the cabinet, a workspace keyring URI otherwise); the second picks the
4343+directory to watch (null means "resolve the root of this context"). The initial snapshot
4444+comes from cache plus a delta sync; subsequent snapshots come from SSE events. No polling,
4545+no manual invalidation.
4646+4747+The returned shape is `{ snapshot, isReady, error, resolvedDirectoryUri, retry }`. While
4848+the watcher is installing, `snapshot` is `null` and `isReady` is `false`. Once the first
4949+fire lands, `snapshot` becomes a `DirectoryTreeSnapshot` and `isReady` flips to `true`.
5050+If the watched directory gets deleted (by a move, a recursive delete, or a remote peer),
5151+the watcher fires one last time with a null snapshot and closes — at that point `isReady`
5252+is `false` again and `snapshot` stays null. If you need to distinguish "still loading"
5353+from "was here, now gone", track a "have I ever been ready" latch in local state.
5454+5555+## Custom QueryClient
5656+5757+Most apps already have a `@tanstack/react-query` client. Pass it in so Opake's queries share
5858+the same cache:
5959+6060+<CodeBlock
6161+ language="tsx"
6262+ title="with-query-client.tsx"
6363+ code={providerWithQueryClient}
6464+/>
6565+6666+Without the `queryClient` prop, `OpakeProvider` creates its own default. That's fine for
6767+small apps but means Opake and the rest of your app run on separate caches; invalidation
6868+from one side doesn't cascade to the other.
6969+7070+## Disabling the auto-start
7171+7272+Most apps want the SSE consumer to start automatically with the provider. A few don't: apps
7373+with an offline mode, apps behind a feature flag, apps that want to start the stream only
7474+after the user explicitly goes online.
7575+7676+<CodeBlock
7777+ language="tsx"
7878+ title="disable-sse.tsx"
7979+ code={providerDisableSse}
8080+/>
8181+8282+`disableSseAutoStart` turns off the provider's built-in start. Use `useStartSseConsumer`
8383+wherever you want to control the timing. Passing `null` to it skips the start entirely;
8484+passing `undefined` (or any string URL) triggers it. The WASM consumer is idempotent, so
8585+calling from multiple places is safe.
8686+8787+## The optimistic overlay
8888+8989+When a mutation hook fires (e.g. `useMove`), Opake doesn't wait for the round-trip to
9090+update your UI. It applies a local patch to the directory-tree snapshot, synchronously.
9191+Components subscribed via `useDirectory` re-render with the moved entry in its new
9292+location within the same render tick.
9393+9494+When the real event arrives from SSE (or the mutation resolves, whichever is first), the
9595+overlay dedups the patch against the real state and drops it. If the mutation fails, the
9696+overlay rolls back.
9797+9898+The overlay is per-`Opake`-instance. Account switching (passing a new `Opake` as
9999+`opake={...}`) creates a fresh overlay, so patches from one user don't project onto the
100100+next user's trees.
101101+102102+You don't interact with the overlay directly; it's transparent. Mutation hooks return
103103+`react-query` mutation objects with the usual `mutate` / `mutateAsync` / `isPending` /
104104+`error` API, and reads happen through `useDirectory` which folds overlay patches in
105105+automatically.
106106+107107+<DocsNext slug="overview" />
+131
apps/web/src/content/docs/build/react/queries.mdx
···11+import {
22+ useDirectoryExample,
33+ useDirectoryMetadataExample,
44+ useWorkspacesExample,
55+ useInboxExample,
66+ useSharesExample,
77+ usePendingSharesExample,
88+} from "./_snippets";
99+1010+<ChapterHeader title="@opake/react — Reading hooks" />
1111+1212+<Lead>
1313+ Read-only hooks split by delivery method. Three are subscriptions wired to the WASM
1414+ keepers and re-render on SSE events within a firehose round-trip. Three are react-query
1515+ queries that cache and re-fetch on invalidation — useful for state that doesn't flow
1616+ through the live stream.
1717+</Lead>
1818+1919+## Subscriptions (SSE-backed)
2020+2121+These hooks install a watcher on the WASM side, get one eager fire with current state, and
2222+then re-fire on every relevant SSE event. The `OpakeProvider` auto-starts the consumer, so
2323+everything below is "just works" inside a provider — no bootstrap plumbing.
2424+2525+### `useDirectory(keyringUri, directoryUri)`
2626+2727+<CodeBlock language="tsx" title="directory-view.tsx" code={useDirectoryExample} />
2828+2929+The primary hook for rendering a directory tree. Pass `keyringUri = null` for the cabinet
3030+or a workspace's keyring URI. Pass `directoryUri = null` to watch the root of that context,
3131+or a specific directory URI otherwise.
3232+3333+- `snapshot` is a `DirectoryTreeSnapshot` once the initial load lands, `null` before that
3434+ (and `null` again if the watched directory was deleted).
3535+- `isReady` is the recommended "do I have data" check. It's `true` exactly when `snapshot`
3636+ is non-null.
3737+- `error` is populated if `loadTree` or the watcher install threw. Show a retry UI and
3838+ call `retry()` to rerun the acquisition.
3939+- `resolvedDirectoryUri` tells you the URI that's actually being watched — useful when you
4040+ passed `null` and need to know where the root landed.
4141+4242+Snapshots fold in the optimistic overlay automatically, so a mutation you fire shows up in
4343+the same render tick. See the [mutations page](/docs/react/mutations) for details.
4444+4545+### `useWorkspaces()`
4646+4747+<CodeBlock language="tsx" title="workspace-list.tsx" code={useWorkspacesExample} />
4848+4949+Subscribes to the `WorkspaceKeeper`. First call bootstraps the keeper via `listWorkspaces`;
5050+subsequent SSE `keyring:upsert` / `keyring:delete` events patch the list in place.
5151+5252+`data` is a `readonly WorkspaceEntry[]` with `{ uri, ownerDid, rotation, memberCount,
5353+createdAt, name, description, icon }`. `isLoading` is `true` until the keeper has
5454+bootstrapped once; after that it stays `false` even when the list is empty. The raw
5555+`snapshot.loaded` flag is also exposed if you need to distinguish the cold-start state from
5656+"loaded, currently empty."
5757+5858+### `useInbox()`
5959+6060+<CodeBlock language="tsx" title="inbox.tsx" code={useInboxExample} />
6161+6262+Same subscription shape as `useWorkspaces`. Backed by the `InboxKeeper`, which the indexer
6363+fans into the caller's personal topic for both owner-side (you just shared something) and
6464+recipient-side (someone shared with you) events.
6565+6666+Entries are `InboxGrant` values: `{ uri, ownerDid, documentUri, createdAt }`. To actually
6767+open an incoming share, call `opake.downloadFromGrant(grantUri)` or
6868+`opake.resolveGrantMetadata(grantUri)` — see the [SDK sharing page](/docs/sdk/sharing).
6969+7070+## Cached reads (react-query)
7171+7272+These hooks don't subscribe to SSE. They fetch once per query key and cache the result. The
7373+`OpakeProvider` invalidates them automatically when a local mutation changes the relevant
7474+state; updates from other devices surface on the next manual invalidation or refetch
7575+trigger.
7676+7777+### `useDirectoryMetadata(keyringUri, directoryUri)`
7878+7979+<CodeBlock
8080+ language="tsx"
8181+ title="directory-filenames.tsx"
8282+ code={useDirectoryMetadataExample}
8383+/>
8484+8585+Thin read for a directory's document metadata — filenames, MIME types, sizes, tags,
8686+descriptions. Useful when a list view needs names but you don't want the full subtree that
8787+`useDirectory` carries.
8888+8989+Returns a react-query `{ data, isLoading, ... }` where `data` is
9090+`Readonly<Record<string, DocumentMetadata>>` keyed by document URI. The query is
9191+invalidated whenever any tree mutation fires (to keep things simple, the mutation hook
9292+invalidates the `["opake", "metadata"]` prefix), which in practice means this is live for
9393+the user's own writes but not for writes from other devices.
9494+9595+### `useShares(documentUri)`
9696+9797+<CodeBlock language="tsx" title="shares-list.tsx" code={useSharesExample} />
9898+9999+List every active grant for a specific document. The underlying `listShares` call
100100+enumerates the caller's entire grant collection; the hook filters client-side to the single
101101+document you asked about. Cabinet-only — workspace sharing doesn't use grants.
102102+103103+`useShareFile` and `useRevokeShare` invalidate this query automatically on success.
104104+105105+### `usePendingShares()`
106106+107107+<CodeBlock
108108+ language="tsx"
109109+ title="pending-shares.tsx"
110110+ code={usePendingSharesExample}
111111+/>
112112+113113+Pending shares are the queue of "I wanted to share this with Bob, but Bob hasn't published
114114+an encryption key yet" entries. The daemon retries them on a schedule; the hook reads the
115115+current queue, and `useCancelPendingShare` drops one.
116116+117117+The query is local-only — pending shares don't flow through SSE, they only exist in the
118118+caller's cabinet until the daemon completes or expires them.
119119+120120+## Composing with suspense / error boundaries
121121+122122+The subscription hooks don't throw. Errors are surfaced on the `error` field (for
123123+`useDirectory`) or swallowed (for `useWorkspaces` / `useInbox`, since a bootstrap failure
124124+just leaves `isLoading` pinned true). Wire your own error boundary around the components
125125+that call these hooks if you want suspense-style flow.
126126+127127+The react-query hooks behave like any other `useQuery` — they respect `QueryClient`-level
128128+`defaultOptions.queries.useErrorBoundary`, `retry`, and `suspense` if you opt in. See
129129+[react-query's docs](https://tanstack.com/query/latest) for the boundary patterns.
130130+131131+<DocsNext slug="queries" />
+658
apps/web/src/content/docs/build/sdk/_snippets.ts
···11+// Code snippets for the SDK overview and authentication pages.
22+//
33+// MDX 3 dedents multi-line template literals that appear inside .mdx files
44+// (both in JSX children and in attribute positions). Moving the snippets
55+// into a plain .ts file bypasses the MDX parser — template literals here
66+// preserve indentation verbatim.
77+88+// -- overview.mdx -----------------------------------------------------------
99+1010+export const helloCabinet = `import { Opake } from "@opake/sdk";
1111+import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb";
1212+1313+const storage = new IndexedDbStorage();
1414+1515+// Assumes the user has already logged in. See authentication.mdx.
1616+const opake = await Opake.init({ storage });
1717+const fm = await opake.cabinet();
1818+1919+// Upload
2020+const bytes = new TextEncoder().encode("hello from an encrypted cabinet");
2121+await fm.uploadAt(bytes, "greetings.txt", "text/plain");
2222+2323+// Read back
2424+const { plaintext, filename } = await fm.downloadAt("/greetings.txt");
2525+console.log(filename, new TextDecoder().decode(plaintext));
2626+// → "greetings.txt hello from an encrypted cabinet"`;
2727+2828+export const staticSurface = `// OAuth two-step
2929+Opake.startLogin(handle, options);
3030+Opake.completeLogin(code, state, pending, options);
3131+Opake.loginWithAppPassword(options);
3232+3333+// Seed-phrase recovery
3434+Opake.generateMnemonic();
3535+Opake.validateSeedPhrase(phrase);
3636+Opake.createIdentity(phrase, did);
3737+3838+// Device pairing
3939+Opake.createPairRequest(storage, did);
4040+Opake.awaitPairCompletion(storage, did, rkey);
4141+Opake.cancelPairRequest(storage, did, rkey);
4242+4343+// The one that returns an Opake instance
4444+Opake.init({ storage, did? });`;
4545+4646+export const instanceSurface = `opake.did; // invariant for the instance's lifetime
4747+4848+// FileManager access
4949+opake.cabinet();
5050+opake.workspaceByUri(uri);
5151+5252+// Workspace lifecycle
5353+opake.createWorkspace(name, description?);
5454+opake.listWorkspaces();
5555+opake.watchWorkspaces(handler);
5656+5757+// Incoming shares
5858+opake.listInbox();
5959+opake.watchInbox(handler);
6060+6161+// Live updates
6262+opake.startSseConsumer(indexerUrl?);
6363+opake.stopSseConsumer();
6464+6565+// ...plus pairing (approving side) and maintenance ops`;
6666+6767+export const storageOptions = `// Browsers. The default choice.
6868+import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb";
6969+const storage = new IndexedDbStorage(); // persists via Dexie
7070+7171+// Tests and scripts. In-memory, lost on process exit.
7272+import { MemoryStorage } from "@opake/sdk";
7373+const storage = new MemoryStorage();`;
7474+7575+export const errorHandling = `import { OpakeError } from "@opake/sdk";
7676+7777+try {
7878+ const opake = await Opake.init({ storage });
7979+} catch (err) {
8080+ if (err instanceof OpakeError) {
8181+ switch (err.kind) {
8282+ case "IdentityMissing":
8383+ // Normal: the user is signed in but hasn't bootstrapped an
8484+ // identity on this device yet. Route them to creation, recovery,
8585+ // or pairing.
8686+ break;
8787+ case "NotFound":
8888+ // No session at all. Send them to login.
8989+ break;
9090+ case "Auth":
9191+ // Token refresh failed. The session is dead.
9292+ break;
9393+ default:
9494+ throw err;
9595+ }
9696+ }
9797+}`;
9898+9999+// -- authentication.mdx -----------------------------------------------------
100100+101101+export const startLogin = `import { Opake } from "@opake/sdk";
102102+import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb";
103103+104104+const storage = new IndexedDbStorage();
105105+106106+const { authUrl, pending } = await Opake.startLogin("alice.bsky.social", {
107107+ redirectUri: "https://myapp.com/callback",
108108+});
109109+110110+Opake.savePendingLogin(pending);
111111+window.location.href = authUrl;`;
112112+113113+export const callbackPage = `import { Opake } from "@opake/sdk";
114114+import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb";
115115+116116+const storage = new IndexedDbStorage();
117117+118118+const pending = Opake.loadPendingLogin();
119119+if (!pending) {
120120+ // No pending state, or expired (10-minute TTL). Send back to login.
121121+ window.location.href = "/login";
122122+}
123123+124124+const params = new URLSearchParams(window.location.search);
125125+await Opake.completeLogin(
126126+ params.get("code")!,
127127+ params.get("state")!,
128128+ pending!,
129129+ { storage, redirectUri: "https://myapp.com/callback" },
130130+);
131131+132132+const opake = await Opake.init({ storage });`;
133133+134134+export const embeddedLogin = `await Opake.login("alice.bsky.social", {
135135+ storage,
136136+ redirectUri: "https://myapp.com/callback",
137137+ authorize: async (authUrl) => {
138138+ const popup = window.open(authUrl, "_blank", "width=600,height=800");
139139+ // Resolve with { code, state } however your host surfaces the callback —
140140+ // postMessage from the popup, main-window listener on custom URL scheme,
141141+ // Electron BrowserWindow webContents event, etc.
142142+ return await waitForCallback(popup);
143143+ },
144144+});`;
145145+146146+export const appPasswordLogin = `import { Opake } from "@opake/sdk";
147147+148148+await Opake.loginWithAppPassword(
149149+ "alice.bsky.social",
150150+ "xxxx-xxxx-xxxx-xxxx",
151151+ { storage },
152152+);
153153+154154+const opake = await Opake.init({ storage });`;
155155+156156+export const routingOnInitErrors = `import { Opake, OpakeError } from "@opake/sdk";
157157+158158+try {
159159+ const opake = await Opake.init({ storage });
160160+ // Signed in, identity loaded. Proceed.
161161+} catch (err) {
162162+ if (err instanceof OpakeError) {
163163+ switch (err.kind) {
164164+ case "NotFound":
165165+ // No session in storage for this DID. Send to login.
166166+ window.location.href = "/login";
167167+ break;
168168+ case "IdentityMissing":
169169+ // Session is live, but no encryption identity yet on this device.
170170+ // Route to identity creation, seed-phrase recovery, or device pairing.
171171+ window.location.href = "/recover-or-pair";
172172+ break;
173173+ case "Auth":
174174+ // Session exists but refresh failed. Credentials are stale.
175175+ await storage.clearSession(accounts[0]!.did);
176176+ window.location.href = "/login";
177177+ break;
178178+ default:
179179+ throw err;
180180+ }
181181+ } else {
182182+ throw err;
183183+ }
184184+}`;
185185+186186+export const accountSwitcher = `const accounts = await Opake.listAccounts(storage);
187187+for (const { did, handle, pdsUrl } of accounts) {
188188+ console.log(handle, did, pdsUrl);
189189+}
190190+191191+// Open a specific account:
192192+const opake = await Opake.init({ storage, did: accounts[0]!.did });`;
193193+194194+// -- identity.mdx -----------------------------------------------------------
195195+196196+export const identityShape = `interface Identity {
197197+ did: string;
198198+ public_key: string; // X25519, base64
199199+ private_key: string; // X25519, base64. Lives in Storage, read by WASM only.
200200+ signing_key?: string; // Ed25519, base64. Lives in Storage, read by WASM only.
201201+ verify_key?: string; // Ed25519, base64.
202202+}`;
203203+204204+export const createIdentityFresh = `// Generate a new 24-word BIP-39 phrase.
205205+const phrase = await Opake.generateSeedPhrase();
206206+207207+// UX: show \`phrase\` to the user and require confirmation it's written
208208+// down BEFORE you call createIdentity. Once the derived identity is
209209+// saved to Storage, the phrase is the only recovery path if Storage
210210+// is ever wiped.
211211+await requireUserConfirmedBackup(phrase);
212212+213213+// Derive the X25519 + Ed25519 keypairs from the phrase.
214214+const identity = await Opake.createIdentity(phrase, did);
215215+await storage.saveIdentity(did, identity);
216216+217217+// Publish the public key so others can encrypt to you.
218218+const opake = await Opake.init({ storage, did });
219219+await opake.publishPublicKey();`;
220220+221221+export const recoverFromPhrase = `// Validate before deriving. Garbage input would silently produce
222222+// garbage keys that only fail later at decrypt time.
223223+if (!(await Opake.validateSeedPhrase(phrase))) {
224224+ throw new Error("That doesn't look like a valid 24-word phrase.");
225225+}
226226+227227+const identity = await Opake.createIdentity(phrase, did);
228228+await storage.saveIdentity(did, identity);
229229+230230+const opake = await Opake.init({ storage, did });
231231+232232+// publishPublicKey is idempotent. If the user has used Opake before on
233233+// another device, the record is already there and the putRecord
234234+// overwrites with the same content. Safe to call either way.
235235+await opake.publishPublicKey();`;
236236+237237+export const createPairRequestNewDevice = `// New device. User has a live session (from login) but no Identity yet.
238238+239239+const { uri, rkey, ephemeralPublicKey } = await Opake.createPairRequest(
240240+ storage,
241241+ did,
242242+);
243243+244244+// Show a fingerprint so the user can verify on their other device that
245245+// the request really is from this one. First 8 bytes of the ephemeral
246246+// public key, hex-paired.
247247+const fingerprint = Array.from(ephemeralPublicKey.slice(0, 8))
248248+ .map((b) => b.toString(16).padStart(2, "0"))
249249+ .join(":");
250250+displayToUser(\`Fingerprint: \${fingerprint}\`);`;
251251+252252+export const awaitPairCompletionNewDevice = `const controller = new AbortController();
253253+254254+try {
255255+ await Opake.awaitPairCompletion(storage, did, rkey, {
256256+ pollIntervalMs: 3000,
257257+ timeoutMs: 10 * 60 * 1000, // 10 minutes
258258+ signal: controller.signal,
259259+ });
260260+261261+ // Identity is in Storage. Opake.init now succeeds.
262262+ const opake = await Opake.init({ storage, did });
263263+} catch (err) {
264264+ if (err instanceof DOMException && err.name === "AbortError") {
265265+ // User cancelled before approval arrived. Wipe the request.
266266+ await Opake.cancelPairRequest(storage, did, rkey);
267267+ return;
268268+ }
269269+ throw err;
270270+}`;
271271+272272+export const approvePairRequestExistingDevice = `// Existing device. Already has an Identity in Storage, so \`opake\` exists.
273273+274274+const requests = await opake.listPairRequests();
275275+// requests: { uri: string; ephemeralKey: Uint8Array; createdAt: string }[]
276276+277277+// Present them with fingerprints. The user picks the one matching what
278278+// their new device is showing. Do NOT auto-approve — the fingerprint
279279+// check is the only defense against a MITM injecting a fake request.
280280+const selected = await askUserToPickRequest(requests);
281281+282282+await opake.approvePairRequest(selected.uri, selected.ephemeralKey);
283283+// The new device's awaitPairCompletion resolves within one poll.`;
284284+285285+// -- files.mdx --------------------------------------------------------------
286286+287287+export const getFileManager = `// Personal files. One FileManager per Opake instance.
288288+const cabinet = await opake.cabinet();
289289+290290+// Shared workspace. Resolved by URI; the WASM side unwraps the group
291291+// key internally using the caller's Identity, and the resulting
292292+// FileManager routes reads to the owner's PDS, writes to proposals
293293+// if the caller isn't the owner.
294294+const workspace = await opake.workspaceByUri(workspaceUri);`;
295295+296296+export const uploadDocument = `const data = await readAsBytes(userFile); // from your UI
297297+298298+const result = await fm.upload(data, "budget.pdf", "application/pdf", {
299299+ description: "Q4 planning doc",
300300+ tags: ["finance", "q4"],
301301+ directoryUri: currentDirectoryUri, // where to place it in the tree
302302+});
303303+304304+if (result.proposed) {
305305+ // Workspace member: the upload was written as a documentUpdate proposal.
306306+ // The owner's daemon will apply it on their next sync.
307307+ notify("Upload queued — waiting for the workspace owner to apply.");
308308+} else {
309309+ // Cabinet, or a workspace you own: applied immediately.
310310+ notify("Uploaded.");
311311+}`;
312312+313313+export const downloadDocument = `const { filename, data } = await fm.download(documentUri);
314314+315315+// data is a Uint8Array — decrypted plaintext ready for use.
316316+const blob = new Blob([data], { type: "application/octet-stream" });
317317+saveAsFile(filename, blob);`;
318318+319319+export const readingTrees = `// Fast load. Uses whatever's in the local cache plus a delta sync;
320320+// returns the directory structure only, no document metadata.
321321+const tree = await fm.loadTree();
322322+323323+// Same tree, plus decrypted metadata for one directory's documents.
324324+// Use this when rendering a directory view — the extra round-trips
325325+// only happen for that one directory.
326326+const withNames = await fm.loadTreeWithMetadata(currentDirectoryUri);
327327+328328+// Force a fresh PDS fetch before returning. Use sparingly; this bypasses
329329+// the cache. Typically called after a write that invalidates state the
330330+// indexer hasn't caught up to yet.
331331+const fresh = await fm.syncAndLoadTree(currentDirectoryUri);`;
332332+333333+export const structureChanges = `// Create a directory.
334334+await fm.createDirectory("Photos", parentDirectoryUri);
335335+336336+// Move an entry (document or sub-directory) between directories.
337337+await fm.move(entryUri, sourceDirectoryUri, targetDirectoryUri);
338338+339339+// Delete a document. parentDirectoryUri is required: the delete is
340340+// atomic with removing this entry from the parent.
341341+await fm.delete(documentUri, parentDirectoryUri);
342342+343343+// Recursively delete a directory and everything inside it.
344344+await fm.deleteRecursive(directoryUri);`;
345345+346346+export const updateMetadataContent = `// Change metadata without re-uploading the blob.
347347+await fm.updateMetadata(documentUri, {
348348+ filename: "budget-final.pdf",
349349+ description: "Approved version",
350350+ tags: ["finance", "q4", "approved"],
351351+});
352352+353353+// Replace document contents in place. Existing grants stay valid —
354354+// updateContent re-encrypts with the same content key, so recipients
355355+// don't need re-wrapping.
356356+await fm.updateContent(documentUri, newBytes);`;
357357+358358+export const watchDirectory = `const watcher = fm.watchDirectory(directoryUri, (snapshot) => {
359359+ if (snapshot === null) {
360360+ // The directory was deleted (by the owner, or recursively from a
361361+ // parent). Stop reading state that references it.
362362+ handleDirectoryGone();
363363+ return;
364364+ }
365365+ renderDirectory(snapshot);
366366+});
367367+368368+// Stop listening when you're done — on UI teardown, logout, or before
369369+// switching to a different directory.
370370+watcher.close();`;
371371+372372+// -- sharing.mdx ------------------------------------------------------------
373373+374374+export const shareDocument = `// Resolve the recipient first. This returns { did, pdsUrl, publicKey },
375375+// where publicKey is the recipient's X25519 public encryption key.
376376+const recipient = await opake.resolveIdentity("bob.bsky.social");
377377+378378+// Direct share. Writes an app.opake.grant record on YOUR PDS that wraps
379379+// the document's content key to the recipient's public key. The grant
380380+// lives under your repo; the recipient discovers it via the indexer.
381381+const fm = await opake.cabinet();
382382+await fm.share(
383383+ documentUri,
384384+ recipient.did,
385385+ recipient.publicKey,
386386+ "read",
387387+ "For your review — draft v2",
388388+);`;
389389+390390+export const handleRecipientNotReady = `import { OpakeError } from "@opake/sdk";
391391+392392+try {
393393+ const recipient = await opake.resolveIdentity(handleOrDid);
394394+ await fm.share(documentUri, recipient.did, recipient.publicKey, "read");
395395+} catch (err) {
396396+ if (err instanceof OpakeError && err.kind === "RecipientNotReady") {
397397+ // The target has a valid atproto identity but hasn't published an
398398+ // Opake public key yet (hasn't used Opake). Queue a pending share;
399399+ // the daemon will retry until they sign up or it expires (7 days).
400400+ await fm.createPendingShare(documentUri, handleOrDid, "read", null);
401401+ return;
402402+ }
403403+ throw err;
404404+}`;
405405+406406+export const listOutgoingShares = `const grants = await fm.listShares();
407407+for (const g of grants) {
408408+ // g: { uri, document, recipient, createdAt, expiresAt }
409409+ console.log(\`Shared \${g.document} with \${g.recipient}\`);
410410+}
411411+412412+// Grants don't carry the document filename directly; metadata stays
413413+// encrypted on the wire. If you need names, do a separate
414414+// getDocumentMetadata lookup per document URI.`;
415415+416416+export const revokeShareSnippet = `await fm.revokeShare(grantUri);
417417+// The grant record is deleted from your PDS. The indexer removes it
418418+// from its index on the next firehose event; the recipient's
419419+// watchInbox fires with the updated (shorter) list.
420420+//
421421+// Caveat: revocation is forward-only. If the recipient already
422422+// decrypted and saved the key, you can't take that back. If the
423423+// document needs to be unreadable to the ex-recipient going forward,
424424+// delete-and-reupload instead of revoke.`;
425425+426426+export const listInboxSnippet = `const grants = await opake.listInbox();
427427+for (const g of grants) {
428428+ // g: { uri, ownerDid, documentUri, createdAt }
429429+ // ownerDid is the DID of whoever shared with you.
430430+ console.log(\`Shared with you: \${g.documentUri} from \${g.ownerDid}\`);
431431+}`;
432432+433433+export const watchInbox = `const watcher = opake.watchInbox((snapshot) => {
434434+ // snapshot.loaded is false while the keeper is bootstrapping. Once
435435+ // the initial listInbox completes, it flips to true and fires again
436436+ // with the current entries.
437437+ renderInbox(snapshot.entries, snapshot.loaded);
438438+});
439439+440440+// Stop listening when you're done.
441441+watcher.close();`;
442442+443443+export const downloadFromGrantSnippet = `// Peek at the metadata (filename, MIME type, size, etc.) without
444444+// downloading the blob. Useful for rendering a "shared with me" list.
445445+const { filename, metadata } = await opake.resolveGrantMetadata(grantUri);
446446+447447+// Download and decrypt in full.
448448+const { filename: f, data } = await opake.downloadFromGrant(grantUri);
449449+saveAsFile(f, new Blob([data]));`;
450450+451451+export const pendingShares = `// List queued shares that haven't landed as grants yet.
452452+const pending = await opake.listPendingShares();
453453+for (const p of pending) {
454454+ // p: { uri, document, recipient, createdAt }
455455+ console.log(\`Pending: \${p.document} → \${p.recipient}\`);
456456+}
457457+458458+// Manually kick the retry loop. The daemon runs this on a schedule too.
459459+const outcome = await opake.retryPendingShares();
460460+// { checked, completed, expired, still_pending, failed }
461461+462462+// Cancel a specific pending share.
463463+await opake.cancelPendingShare(pendingShareUri);`;
464464+465465+// -- workspaces.mdx ---------------------------------------------------------
466466+467467+export const createWorkspace = `const { keyringUri, key } = await opake.createWorkspace(
468468+ "Family Photos",
469469+ "Shared vacation + milestone photos",
470470+);
471471+472472+// key is the group key for this workspace. You don't usually hold onto
473473+// it; subsequent operations re-resolve via keyringUri and the caller's
474474+// Identity unwraps the member entry each time.
475475+const fm = await opake.workspaceByUri(keyringUri);`;
476476+477477+export const listAndWatchWorkspaces = `// One-shot list.
478478+const workspaces = await opake.listWorkspaces();
479479+for (const ws of workspaces) {
480480+ console.log(ws.name, ws.role, ws.keyringUri);
481481+}
482482+483483+// Subscription. Fires once immediately with the current snapshot,
484484+// then again on every keyring:upsert / keyring:delete SSE event.
485485+const watcher = opake.watchWorkspaces((snapshot) => {
486486+ renderWorkspaceList(snapshot.entries, snapshot.loaded);
487487+});
488488+489489+// Stop listening when you're done.
490490+watcher.close();`;
491491+492492+export const addWorkspaceMember = `// Look up the invitee's published public encryption key first.
493493+// You need both their DID and the X25519 public key bytes.
494494+const { did, publicKey } = await resolveHandleAndPublicKey(handle);
495495+496496+await opake.addWorkspaceMember(keyringUri, did, publicKey, "editor");
497497+498498+// The workspace keyring record on the owner's PDS now has an extra
499499+// member entry containing the group key wrapped to the invitee's
500500+// public key. They'll see the workspace in their next listWorkspaces.`;
501501+502502+export const removeWorkspaceMember = `// Owner removing a member rotates the group key in place.
503503+const result = await opake.removeWorkspaceMember(keyringUri, memberDid);
504504+505505+if (result.proposed) {
506506+ // The caller isn't the owner; this was written as a keyringUpdate
507507+ // proposal. The owner's daemon applies it.
508508+ return;
509509+}
510510+511511+// Owner path: result.rotation is the new rotation number. Existing
512512+// documents stay readable by remaining members because keyHistory
513513+// retains the prior rotation's member entries. Documents uploaded
514514+// AFTER this point are wrapped under the new key, which the removed
515515+// member doesn't have.
516516+console.log("Rotated to", result.rotation);`;
517517+518518+export const leaveWorkspace = `// Opt out of a workspace you're a member of (not the owner of).
519519+// Writes a keyringUpdate proposal with actionType "leave"; the owner's
520520+// daemon processes it and triggers a normal remove-member rotation.
521521+await opake.leaveWorkspace(keyringUri);`;
522522+523523+export const proposalFlow = `// Member writing to a workspace they don't own.
524524+const result = await fm.upload(data, "notes.md", "text/markdown", {
525525+ directoryUri: someWorkspaceDir,
526526+});
527527+// result.uri points at a documentUpdate proposal record on the CALLER's
528528+// PDS, NOT at a new document on the owner's PDS.
529529+// result.proposed === true
530530+531531+// Owner side (running anywhere the owner's Opake instance is alive):
532532+// syncWorkspaceByUri picks up pending proposals, validates the
533533+// proposer's role, applies them as canonical records on the owner's
534534+// PDS, and deletes the proposal from the member's PDS.
535535+await opake.syncWorkspaceByUri(keyringUri);`;
536536+537537+export const mutationResultHandling = `// Every write returns MutationResult:
538538+// { uri: string; proposed: boolean }
539539+//
540540+// proposed: true means the write is a documentUpdate/directoryUpdate
541541+// record on the caller's own PDS, waiting for the workspace owner to
542542+// apply it. The URI in that case points at the proposal record, not the
543543+// target document/directory, so don't treat it as a new document URI.
544544+//
545545+// proposed: false means the write was applied directly — either the
546546+// caller owns the workspace (or it's their cabinet), or the owner is
547547+// acting on their own records.
548548+549549+const result = await fm.updateMetadata(documentUri, { filename: "v2.pdf" });
550550+if (result.proposed) {
551551+ // Optimistic UI: mark the row as "pending apply" but show the new name.
552552+ markPending(documentUri, "v2.pdf");
553553+} else {
554554+ // Direct apply: the change is already on PDS.
555555+ updateLocalTree(documentUri, "v2.pdf");
556556+}`;
557557+558558+// -- events.mdx -------------------------------------------------------------
559559+560560+export const startConsumerBasic = `// Start the stream with the indexer URL resolved via the priority chain
561561+// (runtime override → user's accountConfig on PDS → compile-time default).
562562+// Idempotent: a second call while one is running is a no-op.
563563+await opake.startSseConsumer();`;
564564+565565+export const startConsumerCustom = `// Override the indexer URL at runtime. Wins over whatever's in the user's
566566+// accountConfig for the rest of the Opake instance's lifetime.
567567+await opake.startSseConsumer("https://indexer.example.com");`;
568568+569569+export const teardownSequence = `// Logout / account switch teardown. Do it in this order.
570570+571571+// 1. Stop the stream so no events land against state you're about to drop.
572572+opake.stopSseConsumer();
573573+574574+// 2. Drain the in-memory keepers. ContentKeys are zeroized; the decrypted
575575+// directory-name cache is cleared. Anything sitting on an \`opake.watch*\`
576576+// handler receives one final snapshot (empty, loaded=false) and closes.
577577+opake.wipeState();
578578+579579+// OpakeProvider in @opake/react does this pair on unmount for you.`;
580580+581581+export const consumerGating = `// Hold off on starting the stream until the app has a live session.
582582+// Typical: inside a useEffect / onMount that depends on auth state.
583583+584584+if (session.status === "active") {
585585+ await opake.startSseConsumer();
586586+}
587587+588588+// If the user hasn't logged in yet, starting anyway would trigger a
589589+// token-exchange request that fails with Auth. Cleaner to gate on
590590+// authentication state.`;
591591+592592+// -- storage.mdx ------------------------------------------------------------
593593+594594+export const storageInterface = `interface Storage {
595595+ // Config — account roster and default DID.
596596+ loadConfig(): Promise<Config>;
597597+ saveConfig(config: Config): Promise<void>;
598598+599599+ // Identity — X25519 + Ed25519 keypairs, per DID.
600600+ loadIdentity(did: string): Promise<Identity>;
601601+ saveIdentity(did: string, identity: Identity): Promise<void>;
602602+603603+ // Session — OAuth or legacy tokens, DPoP keys, per DID.
604604+ loadSession(did: string): Promise<Session>;
605605+ saveSession(did: string, session: Session): Promise<void>;
606606+ clearSession(did: string): Promise<void>;
607607+608608+ // Full account removal (identity + session + cache + config entry).
609609+ removeAccount(did: string): Promise<void>;
610610+611611+ // Ephemeral pair-state: raw bytes of the X25519 private half during
612612+ // pairing. WASM writes on createPairRequest, reads on tryCompletePair,
613613+ // deletes on success or cancel. Never crosses back into JS.
614614+ savePairState(did: string, rkey: string, privateKey: Uint8Array): Promise<void>;
615615+ loadPairState(did: string, rkey: string): Promise<Uint8Array>;
616616+ deletePairState(did: string, rkey: string): Promise<void>;
617617+618618+ // PDS record cache. Not secret; the same ciphertext the PDS would
619619+ // serve. Used for fast cold starts and offline reads.
620620+ cacheGetRecord<T>(did, collection, uri): Promise<CachedRecord<T> | null>;
621621+ cachePutRecords<T>(did, collection, records): Promise<void>;
622622+ cacheRemoveRecord(did, collection, uri): Promise<void>;
623623+ cacheGetCollection<T>(did, collection): Promise<CachedCollection<T> | null>;
624624+ cachePutCollection<T>(did, collection, data): Promise<void>;
625625+ cacheInvalidateCollection(did, collection): Promise<void>;
626626+ cacheClear(did): Promise<void>;
627627+}`;
628628+629629+export const storageBuiltIns = `import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb";
630630+import { MemoryStorage } from "@opake/sdk";
631631+632632+// Browsers. Backed by Dexie; survives page reloads; scoped per origin.
633633+const browserStorage = new IndexedDbStorage();
634634+635635+// Tests. All in-memory, discarded on process exit.
636636+const testStorage = new MemoryStorage();`;
637637+638638+export const storageCustomBackend = `import type { Storage, Config, Identity, Session } from "@opake/sdk";
639639+640640+class TauriFsStorage implements Storage {
641641+ constructor(private readonly dataDir: string) {}
642642+643643+ async loadConfig(): Promise<Config> {
644644+ const raw = await fs.readFile(\`\${this.dataDir}/config.json\`);
645645+ return JSON.parse(raw.toString()) as Config;
646646+ }
647647+648648+ async saveConfig(config: Config): Promise<void> {
649649+ await fs.writeFile(
650650+ \`\${this.dataDir}/config.json\`,
651651+ JSON.stringify(config, null, 2),
652652+ );
653653+ }
654654+655655+ // ...identity, session, pair-state, cache methods follow the same
656656+ // pattern. Reference: MemoryStorage (src/storage/memory.ts) is a
657657+ // clean example of the full interface in one file.
658658+}`;
···11+import {
22+ startLogin,
33+ callbackPage,
44+ embeddedLogin,
55+ appPasswordLogin,
66+ routingOnInitErrors,
77+ accountSwitcher,
88+} from "./_snippets";
99+1010+<ChapterHeader title="Authentication" />
1111+1212+<Lead>
1313+ Auth in Opake is OAuth by default, with legacy app passwords as an escape hatch for places
1414+ OAuth can't reach. Whichever flow you pick, the user's password stays at their PDS and the
1515+ resulting tokens stay inside WASM.
1616+</Lead>
1717+1818+## Pick a shape
1919+2020+Two surfaces depending on how your app handles the redirect to the authorization server:
2121+2222+- `Opake.startLogin` + `Opake.completeLogin` is the explicit two-step. Safe across a browser
2323+ navigation, because state persists through `sessionStorage` while the user is off authorizing.
2424+- `Opake.login` is the composed wrapper. One call, given an `authorize` callback you supply for
2525+ the round-trip. Useful when JS stays alive through authorization (popup window, Electron
2626+ `BrowserWindow`, mobile custom URL scheme).
2727+2828+SPA that navigates to the AS and comes back: almost always the two-step. Embedded flows where
2929+you can keep the original page alive: either one works.
3030+3131+## The two-step flow
3232+3333+On the page that triggers login:
3434+3535+<CodeBlock language="typescript" title="start-login.ts" code={startLogin} />
3636+3737+On the callback page:
3838+3939+<CodeBlock language="typescript" title="callback-page.ts" code={callbackPage} />
4040+4141+`savePendingLogin` stashes the DPoP key and PKCE verifier in `sessionStorage` under a namespaced
4242+key. `loadPendingLogin` reads that state and clears the storage entry on the same call, so the
4343+DPoP key doesn't linger after the flow completes or fails. The pending state has a ten-minute
4444+TTL; any older entry reads back as `null`.
4545+4646+Always pass the same `redirectUri` to both calls. It's part of the OAuth exchange that the AS
4747+verifies, and a mismatch rejects the token exchange with no useful error message.
4848+4949+## The composed flow
5050+5151+When JS stays alive through authorization, `Opake.login` wraps both steps around an `authorize`
5252+callback:
5353+5454+<CodeBlock language="typescript" title="embedded-login.ts" code={embeddedLogin} />
5555+5656+The SDK calls your `authorize` function with the AS URL and waits for `{ code, state }` back,
5757+then finishes the exchange internally. You never see the DPoP key or pending state; it never
5858+leaves WASM.
5959+6060+## App password fallback
6161+6262+OAuth isn't always reachable: Obsidian plugins, scripts without a browser, CI jobs, sandboxes
6363+without `fetch` redirects. For those, the PDS exposes app passwords. The user generates one in
6464+their PDS account settings, and you sign in with it once:
6565+6666+<CodeBlock language="typescript" title="app-password-login.ts" code={appPasswordLogin} />
6767+6868+App passwords are scoped credentials, not the user's real password. The user can revoke one
6969+from the PDS settings at any time. The session lands in Storage with `type: "legacy"` instead
7070+of `"oauth"`; everything you do with the `Opake` instance afterwards is identical. Refresh goes
7171+through `com.atproto.server.refreshSession` rather than OAuth's token endpoint, but the SDK
7272+handles that for you.
7373+7474+## Token refresh is automatic
7575+7676+Every authenticated SDK method runs through a token guard before firing. The guard checks
7777+`tokenExpiresAt()` (a sync WASM call that returns only the timestamp, not the token). If the
7878+access token is within 30 seconds of expiry, the guard triggers `proactiveRefresh()` before the
7979+main call. Concurrent callers share a single in-flight refresh promise, so two parallel
8080+operations don't each fire their own refresh round.
8181+8282+Reactive refresh is the backup. If the PDS returns `ExpiredToken` unexpectedly, the XRPC client
8383+catches it, refreshes inline, and retries the original request. You don't need to write any
8484+refresh logic in application code.
8585+8686+<Callout type="info">
8787+ **What JS never sees.** `tokenExpiresAt()` returns a number. `proactiveRefresh()` returns
8888+ void. Neither exposes the token itself. The only token-shaped values in JS memory are the
8989+ session fields that come out of `storage.loadSession(did)` if you call it directly, which
9090+ you shouldn't need to.
9191+</Callout>
9292+9393+## Post-init error branching
9494+9595+After `Opake.init({ storage })`, there are three failure modes worth special-casing:
9696+9797+<CodeBlock
9898+ language="typescript"
9999+ title="routing-on-init-errors.ts"
100100+ code={routingOnInitErrors}
101101+/>
102102+103103+`IdentityMissing` is the one most apps forget. It fires on a freshly-signed-in device that has
104104+a session but no `Identity` in Storage. That isn't an error in the "something went wrong" sense;
105105+it's the shape of a newly-paired-but-not-yet-bootstrapped account. See [Identity &
106106+pairing](/docs/sdk/identity) for how to resolve it from both ends.
107107+108108+## Multiple accounts
109109+110110+One Storage can hold multiple signed-in accounts. `Opake.listAccounts` returns all of them:
111111+112112+<CodeBlock language="typescript" title="account-switcher.ts" code={accountSwitcher} />
113113+114114+Without `did`, `Opake.init` opens the default account (the one marked default in Config).
115115+Passing an explicit DID overrides that for this instance.
116116+117117+To sign an account out without removing its identity from storage, clear the session:
118118+`await storage.clearSession(did)`. To forget the account entirely (identity, session, cached
119119+records), call `opake.removeAccount(did)` on an instance that currently holds it.
120120+121121+<DocsNext slug="authentication" />
+135
apps/web/src/content/docs/build/sdk/events.mdx
···11+import {
22+ startConsumerBasic,
33+ startConsumerCustom,
44+ teardownSequence,
55+ consumerGating,
66+} from "./_snippets";
77+88+<ChapterHeader title="Live updates" />
99+1010+<Lead>
1111+ Opake ships a WASM-owned Server-Sent Events consumer that keeps the in-memory directory
1212+ trees, workspace list, and inbox current without polling. One consumer per `Opake` instance.
1313+ JS starts and stops it; parsing, reconnect, and state reconciliation happen in Rust.
1414+</Lead>
1515+1616+## Starting the stream
1717+1818+<CodeBlock language="typescript" title="start.ts" code={startConsumerBasic} />
1919+2020+`startSseConsumer` is idempotent. If a consumer is already running (for example, from a
2121+previous call or a React StrictMode double-mount), the second call returns without spawning
2222+a duplicate. A single `Opake` instance can only have one consumer in flight at a time.
2323+2424+The call resolves the indexer URL from three sources, highest priority first: a runtime
2525+override you passed to an earlier `setIndexerUrl` or this function, the `indexerUrl` field in
2626+the user's `app.opake.accountConfig` record on the PDS, and the `OPAKE_INDEXER_URL` value
2727+baked in at build time. Pass one explicitly when you need to override:
2828+2929+<CodeBlock language="typescript" title="start-with-override.ts" code={startConsumerCustom} />
3030+3131+Gate the call on whether the user has a live session. Without one, the consumer's token
3232+exchange returns `Auth` and never actually connects:
3333+3434+<CodeBlock language="typescript" title="gating.ts" code={consumerGating} />
3535+3636+## What the stream delivers
3737+3838+The indexer watches the atproto firehose for `app.opake.*` records and forwards them to any
3939+connected consumer whose DID appears as owner, recipient, or workspace member. Three
4040+event families matter to your code:
4141+4242+- **Directory tree events.** Document uploads, deletions, metadata changes, directory
4343+ creations, moves, renames. Delivered to any `FileManager.watchDirectory` subscribers
4444+ targeting affected directories. The tree on-disk cache (IndexedDB on web) is also updated
4545+ in the background.
4646+- **Workspace list events.** Keyring creations, member add/remove, workspace rename, key
4747+ rotation. Delivered to any `opake.watchWorkspaces` subscribers. The `WorkspaceKeeper`
4848+ applies them incrementally; there's no re-fetch until the watcher's own `listWorkspaces`
4949+ bootstrap or a full reconnect.
5050+- **Inbox events.** Grant creation, grant deletion. Delivered to any `opake.watchInbox`
5151+ subscribers. Fans out to both sides of a grant: sharer and recipient.
5252+5353+Each event carries only the record's atproto URI and the decrypted fields the indexer can
5454+safely derive. Encrypted fields stay encrypted; the keepers unwrap them inside WASM using
5555+the caller's identity before snapshots go out to handlers.
5656+5757+## Keepers, not caches
5858+5959+The three keepers (`TreeKeeper`, `WorkspaceKeeper`, `InboxKeeper`) live in WASM and hold the
6060+*decrypted* state that matches each watcher you've installed. Calling `listWorkspaces` or
6161+`listInbox`, or acquiring a `FileManager` for a given directory, bootstraps the relevant
6262+keeper with a fresh snapshot. SSE events then patch that state incrementally.
6363+6464+From JS, you don't talk to keepers directly; you install watchers and receive snapshots. The
6565+keeper is the layer that connects "an event arrived on the stream" to "the snapshot handler
6666+that represents your UI gets called." If nothing is watching a given scope, events for it
6767+are parsed and discarded.
6868+6969+## Reconnect and bootstrap
7070+7171+The consumer maintains a connection to `/api/events` and reconnects with exponential backoff
7272+if the stream closes. On reconnect, it does a full re-sync: `listWorkspaces` / `listInbox` /
7373+the cabinet + workspace tree syncs all fire again, repopulating the keepers from scratch.
7474+Full re-sync is the price of a subscription model that doesn't buffer for offline
7575+subscribers: Phoenix PubSub on the indexer side discards events that had no connected
7676+listener, so the consumer can't just catch up from a cursor.
7777+7878+In practice this is fine. The full re-sync is fast (tree snapshots are paginated, keyring
7979+listing hits one indexer endpoint, inbox the same) and the window where a device is offline
8080+is usually short. If you see stale state in the UI after network recovery, the issue is the
8181+watchers, not the stream; try an explicit `listWorkspaces` / `listInbox` to kick the
8282+bootstrap.
8383+8484+## Token exchange
8585+8686+The consumer authenticates to `/api/events` with a short-lived, single-use token rather than
8787+an Ed25519 signature on every connection. The flow:
8888+8989+1. Consumer wakes up, needs to connect.
9090+2. `POST /api/events/token` with an Ed25519-signed `Opake-Ed25519` header (timestamp + DID +
9191+ signature). Indexer verifies against the caller's published `app.opake.publicKey/self`.
9292+3. Indexer mints a random opaque token, stores it in ETS against the caller's DID with a
9393+ 60-second TTL, returns it in the body.
9494+4. Consumer opens `GET /api/events?token=…`. Indexer looks up the token in ETS, associates
9595+ the stream with the DID, deletes the token from ETS so it can't be reused.
9696+5. Stream is alive; events flow.
9797+9898+If the connection drops and reconnects, steps 2-4 happen again. Tokens are one-shot by
9999+design, and short enough that a stolen one has a few-seconds window of replay value before
100100+it either expires or the real consumer consumed it.
101101+102102+Your code never sees the token directly. `startSseConsumer` triggers the exchange under the
103103+covers.
104104+105105+## Teardown
106106+107107+<CodeBlock language="typescript" title="teardown.ts" code={teardownSequence} />
108108+109109+`stopSseConsumer` is lightweight. It flips the consumer's cancellation flag; the next event
110110+parse notices and bails cleanly. No network round-trip, no re-exchange needed if you later
111111+call `startSseConsumer` again.
112112+113113+`wipeState` is the one that costs you state. It drains every keeper, zeroises cached content
114114+keys (ZeroizeOnDrop fires on the Rust side), and clears decrypted name caches. Anything
115115+holding a `watch*` handler receives one final snapshot with empty entries and `loaded: false`
116116+so your UI can tear down gracefully. Do it on logout, account switch, or anywhere else a
117117+user's crypto state shouldn't bleed into the next session.
118118+119119+Don't `wipeState` without `stopSseConsumer` first. An event landing mid-wipe would try to
120120+apply against freshly-uninstalled scopes and log a warning. `OpakeProvider` in `@opake/react`
121121+does the pair in order on unmount.
122122+123123+## Running multiple accounts
124124+125125+One consumer per `Opake` instance. If your app supports multi-account switching, construct
126126+one `Opake` per signed-in account (via `Opake.init({ storage, did })` with the specific DID)
127127+and start the consumer on each. Each maintains its own stream and its own keepers. Stopping
128128+one doesn't affect the others.
129129+130130+In practice most apps render one account at a time and swap the active `Opake`; that's
131131+enough, since the inactive one's keepers sitting quiet in memory cost nothing. If you want to
132132+be aggressive about memory, `stopSseConsumer` + `wipeState` on the inactive account and
133133+restart when the user switches back.
134134+135135+<DocsNext slug="events" />
+121
apps/web/src/content/docs/build/sdk/files.mdx
···11+import {
22+ getFileManager,
33+ uploadDocument,
44+ downloadDocument,
55+ readingTrees,
66+ structureChanges,
77+ updateMetadataContent,
88+ watchDirectory,
99+} from "./_snippets";
1010+1111+<ChapterHeader title="Files & directories" />
1212+1313+<Lead>
1414+ Once an identity is loaded, every file operation goes through a `FileManager`. The API is
1515+ path-and-URI-based, mutations are atomic, and tree state is subscribable so your UI can
1616+ reflect changes from other devices without polling. This page covers the surface as it applies
1717+ to your personal cabinet; the same API reaches into shared workspaces, with a few semantics
1818+ that carry over into [Workspaces](/docs/sdk/workspaces).
1919+</Lead>
2020+2121+## Getting a `FileManager`
2222+2323+Two constructors on `Opake`, same return type:
2424+2525+<CodeBlock language="typescript" title="get-file-manager.ts" code={getFileManager} />
2626+2727+Each `Opake` instance can hand out multiple `FileManager`s, one per context. They don't share
2828+state, and freeing one doesn't affect the others; create as many as your UI needs and discard
2929+when the user navigates away.
3030+3131+## The tree model
3232+3333+A cabinet or workspace is a tree of **directories** (organisational containers, rendered in the
3434+UI as folders) and **documents** (actual files with encrypted content and metadata). Directories
3535+hold an ordered list of entries, each pointing to either another directory or a document by
3636+AT-URI.
3737+3838+Nothing in the tree is ever stored in plaintext on the PDS. Directory names and document
3939+metadata (filenames, MIME types, sizes, tags, descriptions) are encrypted with the same content
4040+key that protects the corresponding blob. The PDS sees opaque byte ranges and typed record
4141+shells.
4242+4343+## Reading the tree
4444+4545+Three functions to pick from, trading off freshness for speed:
4646+4747+<CodeBlock language="typescript" title="reading-trees.ts" code={readingTrees} />
4848+4949+For rendering a file browser, `loadTreeWithMetadata(currentDirectoryUri)` is the right choice —
5050+you get the whole structure fast, and the directory the user is looking at has its document
5151+names decrypted. Other directories will render as "N items" placeholders until navigated to.
5252+5353+`syncAndLoadTree` forces a fresh fetch. Use it right after a mutation you want to confirm, or as
5454+a manual refresh action. Don't use it on every render; the SSE consumer keeps the cache current
5555+on its own (see [Live updates](/docs/sdk/events)).
5656+5757+## Upload and download
5858+5959+<CodeBlock language="typescript" title="upload.ts" code={uploadDocument} />
6060+6161+`upload` generates a random AES-256-GCM content key inside WASM, encrypts the bytes, wraps the
6262+key to the caller's X25519 public key, and writes the document record plus blob to the PDS in a
6363+single `applyWrites` call. If you pass `directoryUri`, the same `applyWrites` also adds the new
6464+document as an entry in that directory, so the document never appears in the tree without its
6565+parent reference.
6666+6767+The `result.proposed` check in the example only matters when the `FileManager` is pointed at a
6868+shared workspace that the caller doesn't own. For cabinet uploads it's always `false`. See
6969+[Workspaces](/docs/sdk/workspaces#proposal-vs-applied) for what "proposed" means and how the
7070+owner side picks those up.
7171+7272+<CodeBlock language="typescript" title="download.ts" code={downloadDocument} />
7373+7474+`download` fetches the blob, unwraps the content key with the caller's private key, decrypts,
7575+and returns the plaintext as a `Uint8Array` plus the decrypted filename. Works identically
7676+whether the document lives in your own cabinet or in a workspace you're a member of.
7777+7878+## Structural changes
7979+8080+<CodeBlock language="typescript" title="structure.ts" code={structureChanges} />
8181+8282+All four are atomic for cabinet owners: `createDirectory` writes the new directory and updates
8383+the parent's entries in one `applyWrites`; `move` removes from source and adds to target in one
8484+call; `delete` removes the document record and its entry together; `deleteRecursive` walks the
8585+subtree and tears everything down in one batched operation.
8686+8787+For workspace members (i.e. non-owners acting on a workspace someone else created), these turn
8888+into `directoryUpdate` proposals. See [Workspaces](/docs/sdk/workspaces) for the proposal flow.
8989+9090+## Editing an existing document
9191+9292+<CodeBlock
9393+ language="typescript"
9494+ title="update-metadata-content.ts"
9595+ code={updateMetadataContent}
9696+/>
9797+9898+`updateMetadata` patches the encrypted metadata record; the blob is untouched, so shares and
9999+grants stay valid without re-wrapping. `updateContent` re-encrypts the blob with the *same*
100100+content key the document was originally uploaded with. Recipients who already have that key
101101+wrapped to them can decrypt the new version immediately. If you want forward secrecy across the
102102+edit, delete and re-upload instead.
103103+104104+## Live updates
105105+106106+The tree state isn't something you poll. `FileManager.watchDirectory` subscribes you to changes
107107+for a specific directory URI: uploads, deletes, renames, moves, and directory rearrangements
108108+fire the handler with the fresh snapshot. When the directory itself is deleted, the handler
109109+fires with `null`, and the watcher closes automatically.
110110+111111+<CodeBlock language="typescript" title="watch-directory.ts" code={watchDirectory} />
112112+113113+The watcher sits on top of the SSE consumer (see [Live updates](/docs/sdk/events)). When the
114114+consumer isn't started, the watcher still fires the initial snapshot from cache but won't
115115+update on remote changes.
116116+117117+`@opake/react` wraps `watchDirectory` as the `useDirectory` hook, with the same subscription
118118+semantics and the overlay layer for optimistic mutations folded in. See [React
119119+bindings](/docs/react/overview) when those pages land.
120120+121121+<DocsNext slug="files" />
+139
apps/web/src/content/docs/build/sdk/identity.mdx
···11+import {
22+ identityShape,
33+ createIdentityFresh,
44+ recoverFromPhrase,
55+ createPairRequestNewDevice,
66+ awaitPairCompletionNewDevice,
77+ approvePairRequestExistingDevice,
88+} from "./_snippets";
99+1010+<ChapterHeader title="Identity & pairing" />
1111+1212+<Lead>
1313+ An `Identity` is your encryption keypair. It's separate from your PDS session because the PDS
1414+ never sees it. Until one is in Storage for your DID, `Opake.init({ storage })` throws
1515+ `IdentityMissing`. Three paths put one there: fresh creation, seed-phrase recovery, and device
1616+ pairing.
1717+</Lead>
1818+1919+## What's in an `Identity`
2020+2121+<CodeBlock language="typescript" title="Identity" code={identityShape} />
2222+2323+Derived deterministically from a 24-word BIP-39 mnemonic plus the DID. The same phrase produces
2424+the same keys for the same DID, every time, regardless of device. X25519 is for file encryption
2525+and key wrapping; Ed25519 is for signing requests to the Indexer.
2626+2727+The private fields live in Storage and are read by WASM directly when a crypto operation needs
2828+them. Your code sees the `Identity` type only briefly, during the moments right after generation
2929+or recovery, before handing it off to `storage.saveIdentity`.
3030+3131+## Creating a new identity
3232+3333+First-run path: the user has no existing identity anywhere, and no other device to pair with.
3434+Generate a phrase, show it to the user to back up, derive keys, save, publish the public key.
3535+3636+<CodeBlock language="typescript" title="create-identity.ts" code={createIdentityFresh} />
3737+3838+The phrase is shown once, and only once. You don't hold onto it after `createIdentity` returns,
3939+and neither does WASM. If you want a "show again" feature later, your app has to store the
4040+phrase itself (encrypted under a passphrase, or behind a biometric prompt, or similar).
4141+4242+`publishPublicKey` writes `app.opake.publicKey/self` on the PDS as an idempotent `putRecord`.
4343+Without it, other users can't encrypt files to this DID.
4444+4545+## Recovering from a seed phrase
4646+4747+User already has a phrase: from another device, a backup, a password manager. Validate before
4848+deriving — garbage input produces garbage keys that fail later at decrypt time with no obvious
4949+connection to the typo two pages back.
5050+5151+<CodeBlock language="typescript" title="recover.ts" code={recoverFromPhrase} />
5252+5353+One gotcha: the SDK doesn't check that the derived public key matches whatever is already
5454+published on the PDS's `publicKey/self` record. If the user types the wrong phrase but it's
5555+still a valid 24-word BIP-39 sequence, validation passes, the derived key is saved, and every
5656+encryption that touches existing documents fails silently later. If you want a matches-the-PDS
5757+check, fetch `publicKey/self` after init and compare the base64-encoded bytes against
5858+`identity.public_key` before you consider the recovery complete. Surfacing this as an in-SDK
5959+helper is on the list.
6060+6161+## Pairing: the new device side
6262+6363+The user has Opake running on another device and would rather not type 24 words. They open the
6464+pair flow on this new device. The SDK generates an ephemeral X25519 keypair, writes an
6565+`app.opake.pairRequest` record to the PDS containing the public half, and stashes the private
6666+half in Storage for `awaitPairCompletion` to read back later. JS never sees the private half.
6767+6868+<CodeBlock
6969+ language="typescript"
7070+ title="pair-start.ts"
7171+ code={createPairRequestNewDevice}
7272+/>
7373+7474+Now wait for approval from the other device. `awaitPairCompletion` polls the PDS for a matching
7575+`app.opake.pairResponse` record, decrypts the identity payload inside WASM using the stored
7676+ephemeral private key, writes the resulting `Identity` to Storage, and deletes both the request
7777+and response records. One call covers all of that.
7878+7979+<CodeBlock
8080+ language="typescript"
8181+ title="pair-await.ts"
8282+ code={awaitPairCompletionNewDevice}
8383+/>
8484+8585+The `AbortController` / `signal` is how you cancel from the UI. `cancelPairRequest` tears down
8686+both sides of the pair state: wipes the ephemeral private key from Storage and deletes the PDS
8787+request record, so it isn't visible to the approving device anymore.
8888+8989+## Pairing: the existing device side
9090+9191+On the device that already has an identity, list the requests and approve the one the user
9292+recognises.
9393+9494+<CodeBlock
9595+ language="typescript"
9696+ title="pair-approve.ts"
9797+ code={approvePairRequestExistingDevice}
9898+/>
9999+100100+`approvePairRequest` wraps this device's identity under the requester's ephemeral public key,
101101+writes the encrypted bundle as `app.opake.pairResponse`, and returns. The requesting device's
102102+`awaitPairCompletion` picks it up on its next poll.
103103+104104+## Fingerprints are doing real work
105105+106106+The pairing handshake is secure against a passive observer. The PDS sees ciphertexts and
107107+ephemeral public keys; that's not enough to recover an identity. Against an *active* attacker
108108+who can write to your PDS, though, it's different. They could inject their own pair request
109109+record with their ephemeral public key, wait for you to tap Approve, and receive your wrapped
110110+identity in the response.
111111+112112+The fingerprint comparison is what closes that gap. Both devices display the same 8-byte prefix
113113+of the same ephemeral public key. If the user reads them off and they match, the request on the
114114+approving device really is from the new one, not an attacker's forgery. If they don't match,
115115+abort.
116116+117117+Show fingerprints on both sides. Don't auto-approve. An SDK can't enforce the out-of-band check
118118+for you.
119119+120120+## What never crosses the JS boundary
121121+122122+A quick map of who holds what during pairing:
123123+124124+- **Ephemeral X25519 private key (new device):** generated inside WASM, written straight to
125125+ Storage via the storage adapter, read back by WASM during `awaitPairCompletion`, wiped on
126126+ success or cancellation. Your JS sees the public half only, and only for fingerprint display.
127127+- **The received `Identity` bundle (new device):** unwrapped inside WASM from the pair response
128128+ body, written to Storage through the adapter, never returned to JS as an object with token
129129+ fields populated.
130130+- **The existing device's identity during approval:** read from Storage into WASM, wrapped
131131+ under the requester's ephemeral public key inside WASM. JS signs nothing and holds no key
132132+ material during this call.
133133+134134+The only identity-shaped values your application code ever touches directly are the public
135135+`did`, `public_key`, and `verify_key` fields on the `Identity` type during the narrow window
136136+after generation, plus the `ephemeralPublicKey` return from `createPairRequest` for fingerprint
137137+rendering.
138138+139139+<DocsNext slug="identity" />
+15-127
apps/web/src/content/docs/build/sdk/overview.mdx
···11+import {
22+ helloCabinet,
33+ staticSurface,
44+ instanceSurface,
55+ storageOptions,
66+ errorHandling,
77+} from "./_snippets";
88+19<ChapterHeader title="@opake/sdk — Overview" />
210311<Lead>
···32403341The minimum it takes to upload and read back one encrypted file:
34423535-<CodeBlock language="typescript" title="example.ts">
3636-{`import { Opake } from "@opake/sdk";
3737-import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb";
3838-3939-const storage = new IndexedDbStorage();
4040-4141-// Assumes the user has already logged in. See authentication.mdx.
4242-const opake = await Opake.init({ storage });
4343-const fm = await opake.cabinet();
4444-4545-// Upload
4646-const bytes = new TextEncoder().encode("hello from an encrypted cabinet");
4747-await fm.uploadAt(bytes, "greetings.txt", "text/plain");
4848-4949-// Read back
5050-const { plaintext, filename } = await fm.downloadAt("/greetings.txt");
5151-console.log(filename, new TextDecoder().decode(plaintext));
5252-// → "greetings.txt hello from an encrypted cabinet"`}
5353-</CodeBlock>
4343+<CodeBlock language="typescript" title="example.ts" code={helloCabinet} />
54445545Under those calls: `IndexedDbStorage` persisted the session and the identity keypair, so the
5646next page load starts from `Opake.init` without redoing OAuth. `FileManager.uploadAt` generated
···67576858### Static surface
69597070-<CodeBlock language="typescript" title="before init">
7171-{`// OAuth two-step
7272-Opake.startLogin(handle, options);
7373-Opake.completeLogin(code, state, pending, options);
7474-Opake.loginWithAppPassword(options);
7575-7676-// Seed-phrase recovery
7777-Opake.generateMnemonic();
7878-Opake.validateSeedPhrase(phrase);
7979-Opake.createIdentity(phrase, did);
8080-8181-// Device pairing
8282-Opake.createPairRequest(storage, did);
8383-Opake.awaitPairCompletion(storage, did, rkey);
8484-Opake.cancelPairRequest(storage, did, rkey);
8585-8686-// The one that returns an Opake instance
8787-Opake.init({ storage, did? });`}
8888-</CodeBlock>
6060+<CodeBlock language="typescript" title="before init" code={staticSurface} />
89619062So much of the API is static because those three flows run before any identity exists on a
9163device. They can't live on an instance because you haven't got one. Once any of them finishes,
···93659466### Instance surface
95679696-<CodeBlock language="typescript" title="after init">
9797-{`opake.did; // invariant for the instance's lifetime
9898-9999-// FileManager access
100100-opake.cabinet();
101101-opake.workspaceByUri(uri);
102102-103103-// Workspace lifecycle
104104-opake.createWorkspace(name, description?);
105105-opake.listWorkspaces();
106106-opake.watchWorkspaces(handler);
107107-108108-// Incoming shares
109109-opake.listInbox();
110110-opake.watchInbox(handler);
111111-112112-// Live updates
113113-opake.startSseConsumer(indexerUrl?);
114114-opake.stopSseConsumer();
115115-116116-// ...plus pairing (approving side) and maintenance ops`}
117117-</CodeBlock>
6868+<CodeBlock language="typescript" title="after init" code={instanceSurface} />
1186911970The `did` field is the caller's DID, set during `init` and constant for the life of the
12071`Opake` instance. Safe as a React `useMemo` dependency or a cache key.
···12576identity keys, ephemeral pair state, the local record cache. Two implementations ship with the
12677SDK:
12778128128-<CodeBlock language="typescript" title="storage options">
129129-{`// Browsers. The default choice.
130130-import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb";
131131-const storage = new IndexedDbStorage(); // persists via Dexie
132132-133133-// Tests and scripts. In-memory, lost on process exit.
134134-import { MemoryStorage } from "@opake/sdk";
135135-const storage = new MemoryStorage();`}
136136-</CodeBlock>
7979+<CodeBlock language="typescript" title="storage options" code={storageOptions} />
1378013881If you need a different backend (Tauri filesystem, React Native AsyncStorage, something
13982server-backed), implement the interface yourself. See [Storage
140140-contract](/docs/sdk/storage) for what each method persists and when it's called.
8383+interface](/docs/sdk/storage) for what each method persists and when it's called.
1418414285If you build one that's reusable, consider sending it upstream. The `Storage` contract is
14386small; most backends are one file plus tests. Keeping your implementation in the main repo
···1499215093Every SDK method throws `OpakeError` on failure. Branch on `.kind`:
15194152152-<CodeBlock language="typescript" title="error handling">
153153-{`import { OpakeError } from "@opake/sdk";
154154-155155-try {
156156- const opake = await Opake.init({ storage });
157157-} catch (err) {
158158- if (err instanceof OpakeError) {
159159- switch (err.kind) {
160160- case "IdentityMissing":
161161- // Normal: the user is signed in but hasn't bootstrapped an
162162- // identity on this device yet. Route them to recovery or pair.
163163- break;
164164- case "NotFound":
165165- // No session at all. Send them to login.
166166- break;
167167- case "Auth":
168168- // Token refresh failed. The session is dead.
169169- break;
170170- default:
171171- throw err;
172172- }
173173- }
174174-}`}
175175-</CodeBlock>
9595+<CodeBlock language="typescript" title="error handling" code={errorHandling} />
1769617797`kind` maps 1-to-1 to the `opake_core::Error` variants in Rust, so anything the core library can
17898surface is reachable from TS without `any` casts.
···183103existing device to bootstrap an `Identity` into Storage. Route them to that flow, don't surface
184104it as a generic failure.
185105186186-## Where to go next
187187-188188-<DocsIndexGrid>
189189- <DocsIndexCard href="/docs/sdk/authentication" icon="lock">
190190- <DocsIndexTitle>Authentication</DocsIndexTitle>
191191- <DocsIndexBody>How `startLogin` and `completeLogin` compose, the app-password fallback, and how token refresh actually happens.</DocsIndexBody>
192192- </DocsIndexCard>
193193-194194- <DocsIndexCard href="/docs/sdk/identity" icon="seedling">
195195- <DocsIndexTitle>Identity & pairing</DocsIndexTitle>
196196- <DocsIndexBody>Seed phrases and device pairing, with private key material never touching JS memory.</DocsIndexBody>
197197- </DocsIndexCard>
198198-199199- <DocsIndexCard href="/docs/sdk/files" icon="folder">
200200- <DocsIndexTitle>Files & directories</DocsIndexTitle>
201201- <DocsIndexBody>The `FileManager` contract and how to subscribe to tree changes as they arrive.</DocsIndexBody>
202202- </DocsIndexCard>
203203-204204- <DocsIndexCard href="/docs/sdk/sharing" icon="share">
205205- <DocsIndexTitle>Sharing & workspaces</DocsIndexTitle>
206206- <DocsIndexBody>One-to-one grants, group workspaces, and the proposal model that lets members edit on other people's PDSes.</DocsIndexBody>
207207- </DocsIndexCard>
208208-209209- <DocsIndexCard href="/docs/sdk/events" icon="network">
210210- <DocsIndexTitle>Live updates</DocsIndexTitle>
211211- <DocsIndexBody>Starting the SSE consumer and subscribing to workspace, inbox, and directory changes.</DocsIndexBody>
212212- </DocsIndexCard>
213213-214214- <DocsIndexCard href="/docs/sdk/storage" icon="book">
215215- <DocsIndexTitle>Storage contract</DocsIndexTitle>
216216- <DocsIndexBody>Every method on `Storage`, what it persists, and when the SDK reaches for it.</DocsIndexBody>
217217- </DocsIndexCard>
218218-</DocsIndexGrid>
106106+<DocsNext slug="overview" />
+106
apps/web/src/content/docs/build/sdk/sharing.mdx
···11+import {
22+ shareDocument,
33+ handleRecipientNotReady,
44+ listOutgoingShares,
55+ revokeShareSnippet,
66+ listInboxSnippet,
77+ watchInbox,
88+ downloadFromGrantSnippet,
99+ pendingShares,
1010+} from "./_snippets";
1111+1212+<ChapterHeader title="Sharing" />
1313+1414+<Lead>
1515+ A grant is an `app.opake.grant` record on the sharer's PDS that wraps a document's content key
1616+ to one specific recipient's public key. One record, one recipient, one document. This is the
1717+ minimal sharing primitive — for folders of files shared with multiple people, use
1818+ [workspaces](/docs/sdk/workspaces) instead.
1919+</Lead>
2020+2121+## Sharing a document
2222+2323+<CodeBlock language="typescript" title="share.ts" code={shareDocument} />
2424+2525+`resolveIdentity` takes a handle or DID, walks the atproto identity chain (handle resolution,
2626+DID document, PDS discovery), and fetches the target's `app.opake.publicKey/self` record. The
2727+result is everything `fm.share` needs: the recipient's DID and their X25519 public key.
2828+2929+The grant itself lives on *your* PDS, not the recipient's. The recipient discovers it through
3030+the indexer's `/api/inbox` endpoint, which scans the firehose for grants that name them as the
3131+recipient. No write permission to the recipient's repository is needed.
3232+3333+## When the recipient hasn't set up Opake yet
3434+3535+If `resolveIdentity` succeeds at the atproto layer (the handle exists, the DID resolves, the
3636+PDS is reachable) but there's no `app.opake.publicKey/self` record, you get `RecipientNotReady`.
3737+Handle it by queueing:
3838+3939+<CodeBlock
4040+ language="typescript"
4141+ title="pending-share.ts"
4242+ code={handleRecipientNotReady}
4343+/>
4444+4545+`createPendingShare` writes an `app.opake.pendingShare` record on your PDS. The daemon
4646+periodically resolves the target handle and tries again. When the recipient eventually logs
4747+into Opake and publishes their public key, the next retry succeeds: a real grant is written
4848+and the pending share is deleted. Pending shares expire after seven days.
4949+5050+## Listing outgoing grants
5151+5252+<CodeBlock language="typescript" title="list-shares.ts" code={listOutgoingShares} />
5353+5454+Grants are per-document, per-recipient. A "folder" of shared files is a collection of grants
5555+that happen to target documents in the same directory, not a distinct record type. Grouping
5656+them in your UI is the consumer's job. `listShares` gives you the flat list.
5757+5858+## Revoking
5959+6060+<CodeBlock language="typescript" title="revoke.ts" code={revokeShareSnippet} />
6161+6262+Revocation is forward-only. The record is gone from your PDS after revoke, and the indexer
6363+removes it from the recipient's inbox shortly after that, but you can't claw back the content
6464+key the recipient already unwrapped. Opake's crypto model is identical to git-crypt here:
6565+once a recipient has the content key, they have it, and they can decrypt any copy of the
6666+ciphertext they kept. For actual forward secrecy on a single document, delete and re-upload
6767+with a fresh content key, then re-grant to the people who should still have access.
6868+6969+## Receiving shares
7070+7171+<CodeBlock language="typescript" title="inbox.ts" code={listInboxSnippet} />
7272+7373+`listInbox` returns all grants visible to you via the indexer. Pair it with a subscription so
7474+your UI reflects new shares in real time:
7575+7676+<CodeBlock language="typescript" title="watch-inbox.ts" code={watchInbox} />
7777+7878+`@opake/react` wraps `watchInbox` as the `useInbox` hook. See [React
7979+bindings](/docs/react/overview) when those pages land.
8080+8181+## Downloading shared content
8282+8383+<CodeBlock language="typescript" title="download-grant.ts" code={downloadFromGrantSnippet} />
8484+8585+`downloadFromGrant` walks the full chain: fetches the grant, fetches the document record from
8686+the sharer's PDS, fetches the blob, unwraps the content key with your private X25519 key, and
8787+decrypts. All unauthenticated reads — atproto record and blob endpoints are public by design.
8888+8989+`resolveGrantMetadata` is the cheaper variant. It fetches the grant and document record but
9090+skips the blob; you get the filename, MIME type, size, and tags decrypted, enough to render a
9191+"shared with me" list without paying download costs for files the user hasn't opened yet.
9292+9393+## Pending shares: the queue
9494+9595+<CodeBlock language="typescript" title="pending.ts" code={pendingShares} />
9696+9797+The daemon runs `retryPendingShares` on a schedule (default every 15 minutes), so in most apps
9898+you don't call it manually. Exposing it on the SDK is useful for a "retry now" button in a
9999+settings panel, or for tests that don't want to wait. The outcome shape lets you report
100100+progress: how many pending shares were checked, how many got promoted to real grants, how many
101101+expired, how many are still waiting, and how many errored out for other reasons.
102102+103103+`cancelPendingShare` is the "I changed my mind" path. Deletes the record, no retry fires
104104+again, nothing lands even if the recipient signs up tomorrow.
105105+106106+<DocsNext slug="sharing" />
+102
apps/web/src/content/docs/build/sdk/storage.mdx
···11+import {
22+ storageInterface,
33+ storageBuiltIns,
44+ storageCustomBackend,
55+} from "./_snippets";
66+77+<ChapterHeader title="Storage interface" />
88+99+<Lead>
1010+ Everything the SDK persists goes through a `Storage` implementation you provide.
1111+ `IndexedDbStorage` is the browser default, `MemoryStorage` is for tests, and the interface
1212+ is small enough to port to new backends (Tauri filesystem, React Native, a server-backed
1313+ sync service) with a few dozen lines.
1414+</Lead>
1515+1616+## The interface
1717+1818+<CodeBlock language="typescript" title="Storage" code={storageInterface} />
1919+2020+Every method is async. Implementations can use any storage technology underneath, but the
2121+contract is sequentially consistent per DID: a `saveIdentity` followed by `loadIdentity`
2222+with the same DID must return what was written.
2323+2424+The four groups of methods serve different lifetime profiles. Config, identity, and session
2525+are small, read frequently, written rarely. Pair-state is ephemeral: short-lived entries
2626+created and deleted within a single pairing flow. The cache is high-volume and
2727+access-pattern-dominant; it's OK for cache implementations to prune aggressively or skip
2828+writes under pressure.
2929+3030+## Built-in implementations
3131+3232+<CodeBlock language="typescript" title="built-ins.ts" code={storageBuiltIns} />
3333+3434+`IndexedDbStorage` uses Dexie tables for config, identities, sessions, pair-states, and the
3535+record cache. Database name is `opake` by default; pass a custom name to the constructor if
3636+you need multiple parallel stores (rare, but useful for test isolation). Schema evolution is
3737+Dexie-versioned; existing databases upgrade in place when the SDK schema bumps.
3838+3939+`MemoryStorage` is simpler: plain `Map` instances, no persistence. Fine for unit tests. Not
4040+fine for anything a user would run, because identity keys vanish when the process exits.
4141+4242+## Writing a custom backend
4343+4444+The contract is small enough to implement from scratch. A rough sketch for a Tauri app using
4545+the file system:
4646+4747+<CodeBlock language="typescript" title="tauri-fs-storage.ts" code={storageCustomBackend} />
4848+4949+The identity, session, and pair-state paths follow the same load-file / save-file pattern.
5050+For the cache, you'd probably want a single SQLite database rather than one file per record;
5151+the cache API is designed around (did, collection, uri) tuples which map cleanly to SQL.
5252+5353+If you build one that's reusable, consider sending it upstream. The `Storage` contract is
5454+small; most backends are one file plus tests. Keeping your implementation in the main repo
5555+saves you from re-syncing it every time the trait evolves, and means other people on the
5656+same platform find it next to the built-ins instead of re-solving the problem. Issues and
5757+PRs at [tangled.org/opake.app/opake](https://tangled.org/opake.app/opake).
5858+5959+## What each method is called for
6060+6161+- **`loadConfig` / `saveConfig`**: called once during `Opake.init` to resolve the target DID
6262+ (or pick the default), and on every account-mutating operation (login, logout, switch
6363+ default, remove account). Low-frequency.
6464+- **`loadIdentity` / `saveIdentity`**: `loadIdentity` runs on `Opake.init`. `saveIdentity`
6565+ runs during seed-phrase recovery and device-pairing completion. After that, the identity
6666+ is in WASM memory for the session's lifetime and doesn't get re-read.
6767+- **`loadSession` / `saveSession` / `clearSession`**: `loadSession` on `Opake.init`.
6868+ `saveSession` on every token refresh (proactive or reactive), and on fresh login.
6969+ `clearSession` on logout without account removal.
7070+- **`removeAccount`**: deletes everything for one DID atomically. The implementation should
7171+ treat this as a transaction; a half-removed account will fail the next `Opake.init` for
7272+ that DID in confusing ways.
7373+- **`savePairState` / `loadPairState` / `deletePairState`**: called exclusively from inside
7474+ WASM during device pairing. The bytes are the ephemeral X25519 private key for a pending
7575+ pair request. Implementations should treat these bytes as secret and clear them from any
7676+ intermediate buffers once the save completes.
7777+- **Cache methods**: called opportunistically. The SDK survives partial cache failures; if
7878+ `cachePutRecords` silently no-ops on a particular record, the next operation that needs
7979+ it just re-fetches from the PDS. The cache is correctness-preserving, not
8080+ correctness-dependent.
8181+8282+## Invariants implementations must respect
8383+8484+- **No leaking pair-state to JS userland.** `loadPairState` / `savePairState` return and
8585+ accept `Uint8Array`, but your implementation must not log, persist as plaintext in URLs or
8686+ query strings, or otherwise serialize these bytes anywhere other than the backing store.
8787+ IndexedDbStorage writes them as-is into an IDB row whose key is the `(did, rkey)` tuple;
8888+ anything equivalent is fine.
8989+- **Atomic `removeAccount`.** If a user clicks "log out and forget", and your
9090+ `removeAccount` crashes halfway through, the partial state becomes a support ticket. Use
9191+ whatever transaction primitive your backend offers, even if it's "write to a temp and
9292+ rename on success."
9393+- **Sequential consistency per DID.** Multiple in-flight `saveSession` calls for the same
9494+ DID during concurrent token refreshes must settle to the latest-written value. The SDK's
9595+ `@withTokenGuard` serialises refreshes into a single-flight promise, so you won't see
9696+ concurrent writes in practice, but your backend shouldn't rely on that.
9797+- **No in-place mutation of returned objects.** The SDK assumes `loadIdentity` /
9898+ `loadSession` / `loadConfig` return fresh copies each call. If your backend caches the
9999+ parsed object and returns references to it, a consumer mutating the returned value could
100100+ leak into subsequent loads.
101101+102102+<DocsNext slug="storage" />
···11+import {
22+ createWorkspace,
33+ listAndWatchWorkspaces,
44+ addWorkspaceMember,
55+ removeWorkspaceMember,
66+ leaveWorkspace,
77+ proposalFlow,
88+ mutationResultHandling,
99+} from "./_snippets";
1010+1111+<ChapterHeader title="Workspaces" />
1212+1313+<Lead>
1414+ A workspace is a shared encrypted folder: a named group that owns a tree of files, with a
1515+ roster of members who can read, write, or administer depending on their role. Technically
1616+ it's a keyring on the owner's PDS plus a collaborative proposal protocol that lets members
1717+ edit without needing write access to the owner's repository.
1818+</Lead>
1919+2020+## When to use a workspace (vs. a one-to-one grant)
2121+2222+If you want to share a single file with one other person, [grants](/docs/sdk/sharing) are the
2323+lighter tool: one record per sharer, recipient decrypts directly from the sharer's PDS, no
2424+group state. Grants scale poorly though. Adding a new person to a folder of 200 files means
2525+200 new grant records. Removing someone means deleting every grant they received.
2626+2727+A workspace inverts that. The content key for each file is wrapped under a single **group
2828+key**, and the group key is wrapped to each member individually. Adding a member is one
2929+operation regardless of how many files are in the workspace. Removing a member rotates the
3030+group key once; future uploads are inaccessible to them automatically.
3131+3232+Use workspaces for anything ongoing and multi-party: a family photo album, a research team's
3333+working files, a writing group's drafts.
3434+3535+## Create a workspace
3636+3737+<CodeBlock language="typescript" title="create.ts" code={createWorkspace} />
3838+3939+`createWorkspace` generates a fresh group key inside WASM, wraps it to the caller's public
4040+key with the `manager` role (owners are always managers of their own workspace), writes the
4141+`app.opake.keyring` record to the caller's PDS, and writes the workspace root directory
4242+entry. The returned `key` is the group key bytes; you can hold onto it for immediate use, but
4343+you don't need to store it anywhere. Opake re-resolves via `keyringUri` on every subsequent
4444+operation.
4545+4646+## List and watch workspaces
4747+4848+<CodeBlock language="typescript" title="list.ts" code={listAndWatchWorkspaces} />
4949+5050+`listWorkspaces` fetches from the indexer and decrypts workspace names inline. It also
5151+bootstraps the in-memory `WorkspaceKeeper`, so `watchWorkspaces` subscribers receive
5252+incremental updates from SSE events after the first call without the list round-tripping the
5353+indexer again.
5454+5555+`@opake/react` wraps this pattern as `useWorkspaces` with the subscription lifecycle handled.
5656+See [React bindings](/docs/react/overview) when those pages land.
5757+5858+## Add a member
5959+6060+<CodeBlock language="typescript" title="add-member.ts" code={addWorkspaceMember} />
6161+6262+The invitee needs an Opake public key published on their PDS. If `resolveHandleAndPublicKey`
6363+fails with `RecipientNotReady`, it means the target has a valid atproto identity but hasn't
6464+logged into Opake yet and therefore has no `app.opake.publicKey/self` record. You can queue a
6565+pending invite and wait for them to sign up; see [Sharing](/docs/sdk/sharing) for the
6666+pending-share mechanic, which applies the same way to workspace invites.
6767+6868+Three roles exist:
6969+7070+- **`manager`**: can add or remove members, change workspace metadata, and edit files like any editor.
7171+- **`editor`**: can read everything, upload new documents, and propose edits to existing ones.
7272+- **`viewer`**: can read but not write. Proposals from viewers are rejected by the owner daemon.
7373+7474+Roles are enforced at the proposal-apply layer, not at the crypto layer. A viewer has the group
7575+key (that's what membership means) and can decrypt. Role enforcement stops them from *writing*
7676+valid proposals that the owner would accept. If your threat model requires a viewer who can't
7777+see content at all, they shouldn't be a workspace member.
7878+7979+## Remove a member
8080+8181+<CodeBlock language="typescript" title="remove-member.ts" code={removeWorkspaceMember} />
8282+8383+Removing a member is the expensive operation by design. When the owner initiates it:
8484+8585+1. A fresh group key is generated inside WASM.
8686+2. Every remaining member's entry in the keyring is re-wrapped to the new key.
8787+3. The previous rotation's wrapped keys are archived into `keyHistory` on the keyring record
8888+ so the remaining members can still decrypt older documents (which were wrapped under the
8989+ old key).
9090+4. The `rotation` counter increments.
9191+5. Future uploads use the new key.
9292+9393+The removed member keeps whatever they already decrypted or cached locally. Rotation prevents
9494+*new* access, not retroactive access. If the threat model requires forward secrecy for
9595+existing documents, the owner has to re-upload them after rotation. Opake doesn't do this
9696+automatically.
9797+9898+## Leave a workspace
9999+100100+<CodeBlock language="typescript" title="leave.ts" code={leaveWorkspace} />
101101+102102+The member-side counterpart to being removed. Opting out goes through the same proposal path:
103103+the member writes a `keyringUpdate` with `actionType: "leave"` on their own PDS, the owner's
104104+daemon picks it up, and the standard rotate-and-re-wrap flow runs.
105105+106106+Owners can't `leaveWorkspace` on their own workspace. Depending on the membership list they'd
107107+either leave themselves as the only member, or orphan the workspace entirely. To dissolve an
108108+owned workspace, remove the other members and then delete the keyring record directly.
109109+110110+## How proposals work
111111+112112+Members who aren't the owner can't write to the owner's repository. The PDS auth model doesn't
113113+allow cross-account writes. So when an editor renames a file in a workspace, or uploads a new
114114+document, or moves a directory around, Opake doesn't touch the owner's PDS directly. Instead
115115+it writes a **proposal record** on the member's own PDS, describing the intended change.
116116+117117+<CodeBlock language="typescript" title="proposal-flow.ts" code={proposalFlow} />
118118+119119+The owner's daemon (or wherever the owner's Opake instance is running) periodically scans for
120120+pending proposals against workspaces it owns. For each, it checks the proposer's role
121121+(`viewer` → reject; `editor`/`manager` → accept), applies the change as a canonical record on
122122+the owner's repository, and deletes the proposal from the member's PDS.
123123+124124+The proposal record types are:
125125+126126+- **`app.opake.documentUpdate`**: proposed document changes. Content updates, metadata edits,
127127+ adoption (claiming an existing member-uploaded document into the owner's canonical tree).
128128+- **`app.opake.directoryUpdate`**: proposed structure changes. Add entry, remove entry, move
129129+ entry, create directory, delete directory, rename directory.
130130+- **`app.opake.keyringUpdate`**: proposed membership changes. Add member, remove member,
131131+ update role, rename workspace, update description, leave.
132132+133133+From your application's perspective, this is mostly invisible. You call `fm.upload(...)` or
134134+`fm.move(...)`, the WASM side figures out whether the caller is the owner and routes
135135+accordingly, and you get a `MutationResult` back that tells you whether the write was applied
136136+directly or proposed.
137137+138138+## Reading `MutationResult`
139139+140140+<CodeBlock
141141+ language="typescript"
142142+ title="mutation-result.ts"
143143+ code={mutationResultHandling}
144144+/>
145145+146146+`proposed: true` means the write is pending apply on the owner's side. The returned URI is the
147147+proposal record on the caller's own PDS, not the target document or directory on the owner's
148148+PDS (which doesn't exist yet).
149149+150150+For optimistic UI, render the change immediately with a "pending" affordance. When the owner
151151+applies, the SSE consumer fires a tree update on the target directory and the pending marker
152152+can come off. If the owner rejects or if the daemon hasn't run in a while, the pending marker
153153+stays until the proposal is applied or cancelled.
154154+155155+## A note on the indexer
156156+157157+Workspaces are discovered via the Opake [indexer](/docs/glossary#indexer), not the PDS firehose
158158+directly. `listWorkspaces` calls `/api/keyrings` with the caller's DID as a filter; the
159159+indexer returns every keyring where the caller appears in the member list (including
160160+keyrings the caller owns, since owners are always listed as the first member).
161161+162162+If you're running against a self-hosted indexer instead of the default, set the URL via the
163163+`OPAKE_INDEXER_URL` env var at build time, or call `opake.setIndexerUrl()` at runtime. See
164164+the account config mechanic in [Authentication](/docs/sdk/authentication) for how a user's
165165+preferred indexer syncs across their devices.
166166+167167+<DocsNext slug="workspaces" />
+13-13
apps/web/src/content/docs/faq.mdx
···1414 </FaqItem>
15151616<FaqItem question="Where is my data actually stored?">
1717- Opake uses a **bring-your-own-storage** design. Your encrypted files live on your PDS. In the
1818- future, we may offer managed storage options, but the goal is always to keep you in control of
1919- where your bits live.
1717+ Opake is bring-your-own-storage. Your encrypted files live on your Personal Data Server
1818+ (PDS) — either self-hosted or run by a provider you trust. The app is the interface; the
1919+ storage is yours to move, copy, or walk away with.
2020</FaqItem>
21212222<FaqItem question="Can Opake see my files?">
2323- **No.** Encryption happens entirely on your device (browser or CLI) before any data is sent to
2424- internet. We never see the contents of your files, your filenames, your tags, or your private
2525- keys. To us, your data is just opaque noise.
2323+ **No.** Encryption happens entirely on your device (browser or CLI) before any data is sent
2424+ over the network. We never see the contents of your files, your filenames, your tags, or
2525+ your private keys. From our perspective, your data is opaque bytes.
2626</FaqItem>
27272828<FaqItem question="Can I share files with people who don't use Opake?">
2929- Because of how our [Encryption & Keys](/docs/encryption) work, the recipient *must* have an
3030- AT Protocol identity (a DID) to receive a secure sharing [Grant](/docs/sharing). This is a
3131- feature, not a bug. It ensures that the file key is wrapped specifically to their public key. If
3232- you need to share with a total outsider, you'll need to help them join the network first—it's
3333- worth it for the privacy.
2929+ The recipient needs an AT Protocol identity (a DID) to receive a [Grant](/docs/sharing) — the
3030+ grant wraps the file's content key to their published public key, and there's no public key to
3131+ wrap to if they've never set up an account. If you want to share with someone outside the
3232+ network, help them sign up first.
3433</FaqItem>
35343635<FaqItem question="What happens if I lose my device?">
···4746</FaqItem>
48474948 <FaqItem question="How do I contribute?">
5050- 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.
4949+ The source is on [Tangled](https://tangled.org/opake.app/opake) — browse the code, open
5050+ issues, or send a pull request. Everything happens in the open there.
5151 </FaqItem>
5252</FaqSection>
53535454<CenterAction
5555 headline="Still curious?"
5656- subtext="The handbook covers everything from encryption to keyrings to device pairing."
5656+ subtext="The handbook covers encryption, sharing, workspaces, and device pairing in detail."
5757>
5858 <TextLink href="/docs">
5959 Read the full Handbook
+13
apps/web/src/content/docs/understand/_snippets.ts
···11+// Code snippets for the /understand/ docs pages.
22+// See apps/web/src/content/docs/build/sdk/_snippets.ts for the rationale
33+// (MDX 3 dedents template literals inside .mdx files; imports bypass it).
44+55+export const encryptBlob = `// The core primitive.
66+pub fn encrypt_blob(
77+ plaintext: &[u8],
88+ key: &ContentKey
99+) -> Result<Vec<u8>, CryptoError> {
1010+ // Generates 12-byte random nonce
1111+ // Applies AES-GCM
1212+ // Returns ciphertext with appended authentication tag
1313+}`;
···11<ChapterHeader title="The Open Network: Your Data, Anywhere" />
2233<Lead>
44- Opake isn't a walled garden; it's a resident of the Atmosphere — the open network built on the
55- AT Protocol. Your data doesn't sit in a silo; it lives on a foundation you control, alongside
66- everyone else's.
44+ Opake is built on the AT Protocol — the open, federated network behind Bluesky and the wider
55+ Atmosphere. Your data lives on infrastructure you control, interoperable with every other
66+ atproto-native app.
77</Lead>
8899## Why Opake was built on the Atmosphere
10101111-The social web most of us grew up with is broken. For years we lived in digital fiefdoms where a
1212-single company could decide the fate of our data, our identities, and our connections. When Elon
1313-Musk bought Twitter and spent two years dismantling its safety and moderation systems, the urgency
1414-of a real alternative became undeniable.
1111+Most cloud products lock identity and storage into a single company. When that company's
1212+priorities shift, so does your access to your own data. When Elon Musk bought Twitter and spent
1313+two years dismantling its safety and moderation systems, a lot of people were reminded how much
1414+of their digital life depended on a single company's choices.
15151616-The AT Protocol — and the wider Atmosphere around it — is one answer. It's an open, federated
1717-protocol: no single person or corporation gets to own your digital life. Opake didn't invent any
1818-of that. We picked it as our foundation because it already solves the hard parts of identity,
1919-portability, and federation, and because building on top of an ecosystem is a better long-term bet
2020-than trying to run our own silo.
1616+The AT Protocol is one answer to that problem. It's an open, federated protocol: identity,
1717+storage, and data portability are specified in the open, and no single company owns the pieces.
1818+Opake didn't invent any of it. We picked it as our foundation because it already solves the hard
1919+parts — identity, portability, federation — and because building on an existing ecosystem is a
2020+better long-term bet than running our own silo.
21212222### Breaking the silo
2323···4343log in with your handle and your files will be right there. Our code and algorithms are
4444public; the "vault" belongs to you, we just provide the keys.
45454646-<Callout type="info">
4747- **Our Guarantee:** We don't want to trap you. We want to be the best way for you to manage your
4848- private cabinet, but we believe you should always have the right to leave and take your data with
4949- you.
5050-</Callout>
4646+We'd rather be the best way to manage your private cabinet than a lock-in. Leaving is a
4747+first-class path, not an afterthought — your data walks with you.
51485249---
53505454-## Effortless sharing across the network
5151+## Sharing across the network
55525656-The real advantage of an open network is how easily you can connect. In traditional encrypted
5757-apps, you have to exchange complex "invite codes" or "public keys" just to see a single file.
5858-5959-Since everyone in the Atmosphere already has a digital identity, sharing is built-in.
5353+Most encrypted apps make sharing painful — you exchange invite codes or copy public keys around
5454+to see a single file. On the Atmosphere, every user already has a cryptographic identity, so
5555+sharing works off of handles you already know.
60566157### No more invite codes
6258···68642. It identifies their public lock on the network.
69653. It creates a secure "Grant" that only their key can open.
70667171-### Collaboration Without Borders
6767+### Across providers
72687373-It doesn't matter if you are with one provider and your friend is with another. The servers
7474-talk to each other seamlessly. You can share a 1GB encrypted video with a friend on a
7575-different host, and they can stream it directly from your cabinet—safely, privately,
7676-and without any "middleman" watching from the sidelines.
6969+You and your friend don't need to be on the same PDS. The servers relay records and blobs to
7070+each other as part of the protocol — you can share a 1 GB encrypted video with someone on a
7171+different host and they stream it straight from your cabinet. The PDS on each side only ever
7272+sees ciphertext; the encryption is the access control.
77737874---
7975···9389- **[Introduction to AT Protocol](https://mackuba.eu/2024/02/20/atproto-intro/):** A great "from scratch" deep dive into how the network functions.
9490- **[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.
95919696-<Callout type="info">
9797- **The Network is the App:** Opake is just a lens. The AT Protocol is the foundation. This means
9898- your digital life is no longer at the mercy of a single company's terms of service.
9999-</Callout>
1009210193Ready to learn about how we keep this open network private? Read about [Encryption & Keys](/docs/encryption).
···11+import { encryptBlob } from "./_snippets";
22+13<ChapterHeader title="Encryption & Keys" />
2435<Lead>
···57 key wrapping for sharing and identity.
68</Lead>
7988-## The Cryptographic Reality
1010+## What actually happens
9111010-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.
1212+Standard, audited cryptographic primitives. Here's what runs when you upload a file.
11131214### 1. Content Encryption
1315···15171618The 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.
17191818-<CodeBlock language="rust" title="opake-core/src/crypto/content.rs">
1919- {`// The core primitive.
2020-pub fn encrypt_blob(
2121- plaintext: &[u8],
2222- key: &ContentKey
2323-) -> Result<Vec<u8>, CryptoError> {
2424- // Generates 12-byte random nonce
2525- // Applies AES-GCM
2626- // Returns ciphertext with appended authentication tag
2727-}`}
2828-</CodeBlock>
2020+<CodeBlock language="rust" title="opake-core/src/crypto/content.rs" code={encryptBlob} />
29213022### 2. Key Wrapping (The Lockbox)
3123···4638 preventing replay attacks across different users or protocol versions.
4739</Callout>
48404949-## Cryptographic Heritage
4141+## Influences
50425151-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**.
4343+Opake's primitives and patterns come from a few specific places:
52445353-- **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.
5454-- **[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.
5555-- **[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.
4545+- **Signal Protocol** — Curve25519 (X25519) for identity and key wrapping. Used here for the
4646+ same reasons Signal picked it: fast, well-understood, resistant to many side-channel attacks.
4747+- **[age](https://github.com/FiloSottile/age)** — the file-for-recipients model (one content key,
4848+ wrapped to each recipient's public key) is borrowed directly. Same "static" encryption —
4949+ nothing to keep in sync between sessions.
5050+- **[Noise Protocol Framework](http://noiseprotocol.org/)** — Opake isn't a messaging protocol,
5151+ but the underlying composition (Diffie-Hellman + HKDF + AEAD) is the Noise recipe.
56525753---
5854
+10-5
apps/web/src/content/docs/understand/glossary.mdx
···8899### AES-256-GCM
10101111-The symmetric encryption algorithm we use for file contents. It's fast, secure, and industry-standard.
1111+The symmetric encryption algorithm used for file content. Authenticated (detects tampering),
1212+fast on modern hardware, and well-studied.
12131314### Indexer
1415···16171718### Cabinet
18191919-Our name for the Opake interface—the place where you manage your files, keys, and sharing grants.
2020+The Opake interface for your personal (non-shared) files. Where you manage uploads, keys, and
2121+outgoing sharing grants.
20222123### Content Key
2224···34363537A 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).
36383737-### Keyring
3939+### Workspace
38403939-A named group of users that shares a common Group Key. It makes sharing folders with teams or families effortless.
4141+A named group of users sharing a common group key. Lets you share folders with teams or families
4242+without wrapping every file to every member individually. The underlying atproto record is named
4343+`app.opake.keyring` for legacy reasons; the app calls it a workspace everywhere it's user-facing.
40444145### PDS (Personal Data Server)
4246···52565357### X25519
54585555-The specific elliptic curve we use for asymmetric encryption and key wrapping. It's modern, fast, and secure.
5959+The elliptic curve used for asymmetric encryption and key wrapping. Same curve Signal and age
6060+use; well-understood, fast, resistant to many side-channel attacks.
56615762---
5863
+25-23
apps/web/src/content/docs/use/getting-started.mdx
···11<ChapterHeader title="Getting Started" />
2233<Lead>
44- Though we've designed Opake to be as intuitive as possible, these documents are for if you want to
55- go a little more in-depth about our technology. Feel free to just get started, or to read on.
66-</Lead>
77-88-<Lead>
99- Opake is a radical departure from traditional cloud storage. You are not creating an account on
1010- our servers; you are bringing your own identity and storage to our interface.
44+ Opake works differently from most cloud storage. There's no account on our servers — you bring
55+ your own identity and your own storage, and the Opake app is the interface that ties them
66+ together. This page walks you through setting up.
117</Lead>
1281313-## The Burden of Ownership
1414-1515-Before we begin, an uncomfortable truth: **owning your data means owning your keys**.
99+## Owning your data means owning your keys
16101717-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.
1111+In a traditional system, losing a password means a support team resets it for you. They can do that
1212+because they hold the master keys to your account. Opake can't. We don't have your keys, and
1313+neither does your Personal Data Server (PDS).
18141919-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.
1515+When you first set up Opake, you'll receive a **24-word secret phrase** — a backup that can
1616+reconstruct your keys on any device. If you lose both the phrase and every device you're signed
1717+in on, your encrypted files stay permanently unreadable. That's how end-to-end encryption works;
1818+there's no recovery channel to exploit.
20192120<Callout type="warning">
2222- Write down your 24 words and store it somewhere safe. It is the only way to recover your identity
2121+ Write down your 24 words and store it somewhere safe. It is the only way to recover your files
2322 if you lose access to all your devices. Read more in [Your Seed Phrase](/docs/seed-phrase).
2423</Callout>
2524···3534 1. Navigate to the login screen.
3635 2. Enter your handle (e.g., `you.bsky.social`).
3736 3. You will be redirected to your PDS (such as Bluesky) to authorize Opake.
3838- 4. Once authorized, Opake generates your encryption identity from a 24-word seed phrase. Write these words down — they're your backup.
3737+ 4. Once authorized, Opake generates your encryption key from a 24-word seed phrase. Write these words down — they're your backup.
39384039 </PlatformTab>
4140 <PlatformTab name="CLI">
···48474948## 2. Keeping private what's in your drawers
50495151-Inside your cabinet, everything looks familiar: files, folders, and organized grids. But this is a structure built entirely on your device.
5050+Inside your cabinet, everything looks familiar — files, folders, the usual grid view. That
5151+structure is built entirely on your device.
52525353-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.
5353+On the network, your data is hidden in plain sight. File names, tags, and folder paths are all
5454+encrypted before they leave your device, so the storage provider only sees a flat list of
5555+nameless scrambled records. They can tell something exists; they can't tell whether it's a
5656+"Project" folder or a "Financial" PDF.
54575555-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.
5858+Your cabinet assembles itself once you provide your keys. Opake pulls the encrypted records from
5959+the network, decrypts the metadata locally, and reconstructs the folder hierarchy — instantly,
6060+and only for you.
56615757-<Callout type="info">
5858- **Why this matters:** Even if your server is compromised by a malicious admin, they will only see
5959- a flat list of opaque data and encrypted records. They cannot even tell if a file is a PDF or an
6060- image, nor what folder you've stored it in.
6161-</Callout>
6262+Even if your server is compromised, an admin only sees opaque blobs and encrypted records. They
6363+can't tell which file is a PDF or an image, or what folder it sits in.
62646363-Ready to dive deeper into how the math actually works? Read about [Encryption & Keys](/docs/encryption).
6565+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
···11<ChapterHeader title="A Secure Handshake" />
2233<Lead>
44- Your identity in Opake isn't a username and password; it's a private key that stays with you. When
55- you get a new phone or laptop, you need a way to pass that key to your new device without
66- anyone—including the network—ever catching a glimpse.
44+ Your Opake identity is a private key, not a username and password. When you get a new phone or
55+ laptop, you need a way to hand that key over without the network — or anyone watching it —
66+ ever seeing it in plaintext.
77</Lead>
8899-## The Problem: Identity vs. Access
1010-1111-Traditional apps "sync" your data by sending a master copy to a central server. If that server is compromised, your privacy vanishes.
99+## Identity vs. access
12101313-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.
1111+Traditional apps sync your data by copying everything to a central server. If that server is
1212+compromised, so is your privacy.
14131515-<Callout type="info">
1616- **The Silent Messenger:** We use your Personal Data Server (PDS) as a "dumb relay." It passes the
1717- encrypted messages back and forth, but it doesn't speak the language and has no way of knowing
1818- what’s inside the envelopes.
1919-</Callout>
1414+Opake uses **Device Pairing** instead: a direct exchange between two of your own devices. They
1515+perform a cryptographic handshake that transfers your encryption identity through the PDS as
1616+ciphertext. The PDS relays the messages without understanding what's inside them.
20172118---
22192320## The Pairing Process
24212525-This process ensures that your private keys are only ever unlocked on the devices you personally hold.
2222+Your private keys only leave one device to arrive on another. At no point are they readable by
2323+anyone but the two devices in the handshake.
26242727-### 1. The Request (New Device)
2525+### 1. Requesting access (New Device)
28262927On 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.
3028···3937 </PlatformTab>
4038</PlatformToggle>
41394242-### 2. The Approval (Existing Device)
4040+### 2. Approvaling access (Existing Device)
43414442Your current device will see a notification that a new guest is asking to join your cabinet.
4543···50483. Wraps your private identity keys inside that secret.
51494. Posts the locked package back to your PDS.
52505353-### 3. The Completion (New Device)
5151+### 3. Finishing up (New Device)
54525555-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.
5353+The new device picks up the response, unlocks it with its temporary key, and saves your
5454+identity. Then it deletes the request and response records from your PDS.
56555756---
58575958## Why is this safe?
60596161-Even if someone were monitoring your PDS at the exact moment of the handshake, they could not steal your keys.
6060+Even if someone is actively watching your PDS during the handshake, they can't recover the keys.
62616363-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.
6262+The "temporary lock" your new device made in step 1 is unique to that device — only it holds
6363+the matching key. When your existing device wraps your identity inside that lock, the only
6464+party who can unlock the package is the new device itself. The PDS shuttles the locked package
6565+between the two devices; it never holds the key.
6666+6767+For the specific algorithms Opake uses underneath, see [Encryption & Keys](/docs/encryption).
64686569<Callout type="warning">
6666- **The Human Element:** Always verify that the pairing request you are approving is actually from
6767- your own device. Approving a request is like handing over a physical key to your cabinet—only do
6868- it for devices you own and trust.
7070+ Verify that the pairing request you're approving is actually from your own device. Approving a
7171+ request is like handing over a physical key to your cabinet — only do it for devices you own
7272+ and trust.
6973</Callout>
70747175Ready 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
···11<ChapterHeader title="Sharing & DIDs" />
2233<Lead>
44- In the traditional cloud, sharing a file means granting a server permission to show your data to
55- someone else. In Opake, sharing means giving a key to your file to another user.
44+ Sharing on a traditional cloud means granting a server permission to show your data to someone.
55+ Sharing in Opake means handing someone a key to your file. Different model, different threat
66+ profile.
67</Lead>
7888-## The "Grant" Model
99+## The Grant model
9101010-When you share a file with someone in Opake, you are creating a **Grant**.
1111-1212-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.
1111+When you share a file, you create a **Grant** — a small record containing the file's content
1212+key, encrypted so that only the recipient's private key can open it. You publish the grant
1313+record to your own PDS. The recipient finds it via the indexer, unwraps the key with their
1414+private key, and then fetches the encrypted file directly from your PDS.
13151414-<Callout type="info">
1515- **Zero Server Involvement:** Your PDS (and the recipient's PDS) act only as couriers. They never
1616- see the key, and they never see the file content.
1717-</Callout>
1616+Neither PDS sees the key or the file content. They're just relaying ciphertext between two
1717+clients.
18181919---
2020···3636 </PlatformTab>
3737</PlatformToggle>
38383939-## 2. Why DIDs Matter
3939+## 2. Why DIDs matter
40404141-You might know your friend as `@bob.bsky.social`, but Opake knows them as `did:plc:z724xy...`.
4141+You might know your friend as `@bob.bsky.social`, but Opake stores them internally as
4242+`did:plc:z724xy...`.
42434343-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.
4444+A handle is a nickname that can change. A **DID (Decentralized Identifier)** is a permanent,
4545+cryptographic ID. Grants are keyed to DIDs, so access stays valid if your friend moves to a
4646+different PDS or swaps their handle.
44474545-<Callout type="warning">
4646- **Public Keys are Public:** Opake automatically publishes your public encryption key to your PDS
4747- when you log in. This is how others can wrap files to you without needing to ask for your
4848- "address" first.
4848+<Callout type="info">
4949+ Opake publishes your public encryption key to your PDS when you first log in. That's how
5050+ someone else's client can wrap a file to you without you having to exchange an address
5151+ out-of-band.
4952</Callout>
50535154---
52555353-## 3. Revoking Access
5656+## 3. Revoking access
54575555-If you want to stop sharing a file, you simply delete the Grant record.
5858+To stop sharing a file, delete the grant record.
56595757-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.
6060+There's an important nuance. Once a recipient has decrypted a file, they have the plaintext
6161+locally. Deleting the grant prevents them from fetching future updates or re-downloading if
6262+they lose their copy, but it can't reach out across the network to delete what they already
6363+have on disk.
58645965Ready 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
···11<ChapterHeader title="Troubleshooting & Common Issues" />
2233<Lead>
44- Opake is a decentralized protocol, which means sometimes things can get a little messy. Here is
55- how to fix the most common issues you might run into.
44+ Federated systems have more moving parts than centralised ones, so occasionally things break in
55+ novel ways. Here's a field guide to the issues we see most often.
66</Lead>
7788## Login & Authentication
···26262727## Files & Uploads
28282929-### Upload Fails halfway (Storage Limits)
2929+### Upload fails halfway (storage limits)
30303131-Large files can be tricky. Opake uploads your files as single blobs to the AT Protocol.
3131+Opake uploads files as single blobs to the AT Protocol, so large files stress both your
3232+connection and the PDS's blob limits.
32333333-- **Politeness Limits:** By default, Opake limits the size of blobs it will upload to respect PDS
3434- providers we don't own.
3535-- **Increasing Storage:** You (or your PDS administrator) can increase these limits by uploading
3636- specific configuration records to your repository. Check our [Technical Spec](/docs/protocol)
3737- for the exact schema.
3838-- **Network Stability:** If your connection drops, the upload may fail. Try again when you have a
3939- more stable connection.
3434+- **Default size cap:** Opake caps upload size to stay within what most PDS providers accept
3535+ without prior coordination.
3636+- **Increasing the cap:** You (or your PDS administrator) can raise it by publishing
3737+ configuration records to your repository. See the [Lexicon reference](/docs/lexicons) for the
3838+ schema.
3939+- **Network stability:** If your connection drops mid-upload, the upload fails and you have to
4040+ re-send. Resume support is on the roadmap, not there yet.
40414142### "Unable to Decrypt File"
42434343-This is the most serious error. It means Opake cannot open the "lockbox" for that file.
4444+The most serious error. Opake can't unwrap the key for this file under the identity you're
4545+signed in with. Two common causes:
44464545-- **Wrong Account:** Ensure you are logged into the correct account (the one the file was shared with).
4646-- **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.
4747+- **Wrong account.** Check you're logged into the account the file was shared with.
4848+- **Missing keys.** If you recently signed in on a new device but haven't recovered from your
4949+ seed phrase or completed a [Device Pairing](/docs/pairing), this device doesn't yet hold the
5050+ private keys needed to decrypt. Go through recovery or pairing first.
47514852---
4953
+35-40
apps/web/src/content/docs/use/workspaces.mdx
···11-<ChapterHeader title="Keyrings: Group Privacy" />
22-33-## (Coming soon)
11+<ChapterHeader title="Workspaces: group sharing" />
4253<Lead>
66- Direct sharing is great for one-off files, but what if you have a folder for your family, your
77- coworkers, or your research group? This is where **Keyrings** come in.
44+ Direct sharing works for one-off files. For a folder shared with a family, a team, or a
55+ research group, you want something that scales past pairwise encryption.
86</Lead>
971010-## The "Group Key" Concept
88+## The group-key model
1191212-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.
1010+A naive encrypted file-sharing app wraps every file to every recipient's public key. Ten members
1111+means ten copies of each content key. An eleventh person joining means re-encrypting everything.
13121414-Opake solves this with **Keyrings**.
1515-1616-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.
1313+Opake uses **workspaces** instead — a named group that owns a single shared symmetric key (the
1414+"group key"). Files in the workspace have their content keys wrapped under the group key, not
1515+directly under each member's key.
17161818-1. The file's `Content Key` is wrapped under the **Group Key**.
1919-2. The **Group Key** itself is then wrapped for each individual member of the group.
1717+1. The file's content key is wrapped once under the group key.
1818+2. The group key is wrapped once per member, under each member's X25519 public key.
20192121-<Callout type="info">
2222- **The Result:** When a new member joins the group, we only need to wrap the Group Key for them
2323- *once*. They immediately gain access to every file ever encrypted for that Keyring.
2424-</Callout>
2020+Adding a member means wrapping the group key for them once. They immediately gain access to
2121+every file ever uploaded under that workspace — no re-encryption of the files themselves.
25222623---
27242828-## 1. Creating a Keyring
2525+## 1. Creating a workspace
29263030-A Keyring is its own record on the AT Protocol (`app.opake.keyring`).
2727+A workspace is a single record on the AT Protocol (`app.opake.keyring`).
31283229<PlatformToggle>
3330 <PlatformTab name="Web App">
34313535- 1. Click the **(+)** icon in the Sidebar and select **New Keyring**.
3232+ 1. Click the **(+)** icon in the Sidebar and select **New Workspace**.
3633 2. Give it a name (e.g., "Family Photos").
3737- 3. Opake generates a new Group Key, wraps it to your public key, and publishes the record.
3434+ 3. Opake generates a group key, wraps it to your public key, and publishes the record.
38353936 </PlatformTab>
4037 <PlatformTab name="CLI">
···4239 </PlatformTab>
4340</PlatformToggle>
44414545-## 2. Managing Membership
4242+## 2. Managing membership
46434747-Adding and removing members is a cryptographic operation.
4444+### Adding a member
48454949-### Adding a Member
4646+Adding someone means wrapping the current group key to their public key and updating the
4747+workspace record. They can then decrypt anything stored under that workspace.
50485151-When you add someone to a Keyring, you are simply taking the Group Key and wrapping it to their public key.
4949+### Removing a member (key rotation)
52505353-- **Effect:** They can now open the "Group Lockbox" and decrypt any file associated with that Keyring.
5151+When you remove a member, Opake rotates the group key so the removed member can't decrypt
5252+files uploaded after their removal:
54535555-### Removing a Member (Key Rotation)
5656-5757-This is the most complex part of the system. To ensure the removed member can't access _future_ files, Opake performs a **Key Rotation**:
5858-5959-1. It generates a **NEW** Group Key (`GK_n+1`).
6060-2. it wraps this new key for all _remaining_ members.
6161-3. It keeps a history of the old keys so that older files can still be decrypted by the remaining group.
5454+1. Generate a new group key.
5555+2. Wrap it to all remaining members.
5656+3. Archive the previous group key into the workspace's key history so remaining members can
5757+ still decrypt files uploaded under older rotations.
62586359<Callout type="warning">
6464- **Forward Secrecy Reality:** Just like with [Grants](/docs/sharing), removing someone from a
6565- Keyring prevents them from accessing *future* files. If they already downloaded and decrypted old
6666- files, those files remain in their possession.
6060+ Key rotation prevents the removed member from decrypting _future_ files. Anything they already
6161+ downloaded and decrypted locally is theirs — rotation can't reach across the network to
6262+ delete copies.
6763</Callout>
68646965---
70667171-## 3. Uploading to a Keyring
7272-7373-When you upload a file, you can choose to associate it with a Keyring instead of an individual person.
6767+## 3. Uploading to a workspace
74687569<CodeBlock language="sh">opake upload "budget.pdf" --workspace "Family Photos"</CodeBlock>
76707777-Every member of that Keyring will see the file in their [Inbox](/docs/sharing) and can decrypt it instantly.
7171+Every member of that workspace sees the file in their directory tree and can decrypt it
7272+without extra steps.
78737979-Ready for a quick reference on all these terms? Check the [Glossary](/docs/glossary).
7474+Ready for a quick reference on the terminology? Check the [Glossary](/docs/glossary).
+189
apps/web/src/lib/docs-registry.ts
···2929}
30303131/**
3232+ * Human-facing labels for the sub-group keys used inside a category. Pages
3333+ * register with `group: "sdk"`; the sidebar renders it as `@opake/sdk`.
3434+ */
3535+export const GROUP_META: Readonly<Record<string, string>> = {
3636+ sdk: "@opake/sdk",
3737+ react: "@opake/react",
3838+};
3939+4040+/**
3241 * Audience metadata rendered on the docs landing and sidebar. "Under the
3342 * hood" intentionally breaks the "For X" pattern — the section covers the
3443 * crypto + protocol model, which anyone with curiosity can read regardless
···144153 description:
145154 "Install, initialise, and ship your first encrypted upload with the TypeScript SDK.",
146155 },
156156+ {
157157+ slug: "authentication",
158158+ group: "sdk",
159159+ category: "build",
160160+ title: "Authentication",
161161+ icon: "lock",
162162+ description:
163163+ "OAuth redirect flow, app-password fallback, proactive token refresh, session states.",
164164+ },
165165+ {
166166+ slug: "identity",
167167+ group: "sdk",
168168+ category: "build",
169169+ title: "Identity & pairing",
170170+ icon: "seedling",
171171+ description:
172172+ "Fresh creation, seed-phrase recovery, and device pairing. Private keys never touch JS.",
173173+ },
174174+ {
175175+ slug: "files",
176176+ group: "sdk",
177177+ category: "build",
178178+ title: "Files & directories",
179179+ icon: "folder",
180180+ description:
181181+ "The FileManager contract: upload, download, tree reads, structure changes, metadata and content edits, live subscriptions.",
182182+ },
183183+ {
184184+ slug: "sharing",
185185+ group: "sdk",
186186+ category: "build",
187187+ title: "Sharing",
188188+ icon: "share",
189189+ description:
190190+ "One-to-one grants, pending shares for recipients who haven't set up yet, inbox reads, and revocation semantics.",
191191+ },
192192+ {
193193+ slug: "workspaces",
194194+ group: "sdk",
195195+ category: "build",
196196+ title: "Workspaces",
197197+ icon: "group",
198198+ description:
199199+ "Create, list, and manage shared encrypted folders. Membership roles, key rotation, and the proposal model.",
200200+ },
201201+ {
202202+ slug: "events",
203203+ group: "sdk",
204204+ category: "build",
205205+ title: "Live updates",
206206+ icon: "lightning",
207207+ description:
208208+ "The SSE consumer lifecycle, bootstrap + reconnect semantics, token exchange, and keeper-backed watchers.",
209209+ },
210210+ {
211211+ slug: "storage",
212212+ group: "sdk",
213213+ category: "build",
214214+ title: "Storage interface",
215215+ icon: "book",
216216+ description:
217217+ "The Storage interface, built-in implementations, and how to write your own backend.",
218218+ },
219219+ {
220220+ slug: "overview",
221221+ group: "react",
222222+ category: "build",
223223+ title: "@opake/react — Overview",
224224+ icon: "book",
225225+ description:
226226+ "Provider, hook anatomy, and the optimistic overlay that keeps the UI in sync during in-flight mutations.",
227227+ },
228228+ {
229229+ slug: "queries",
230230+ group: "react",
231231+ category: "build",
232232+ title: "Reading hooks",
233233+ icon: "folder",
234234+ description:
235235+ "Subscription-backed and react-query-backed read hooks — directory trees, workspaces, inbox, shares, metadata.",
236236+ },
237237+ {
238238+ slug: "mutations",
239239+ group: "react",
240240+ category: "build",
241241+ title: "Writing hooks",
242242+ icon: "share",
243243+ description:
244244+ "Upload, delete, move, directory operations, sharing mutations, and how the optimistic overlay behaves.",
245245+ },
246246+ {
247247+ slug: "live-updates",
248248+ group: "react",
249249+ category: "build",
250250+ title: "Live updates",
251251+ icon: "lightning",
252252+ description:
253253+ "Gating the SSE auto-start, running the daemon, manually invalidating cached queries, account-switch semantics.",
254254+ },
255255+ {
256256+ slug: "lexicons",
257257+ category: "build",
258258+ title: "Lexicon reference",
259259+ icon: "network",
260260+ description:
261261+ "The atproto collections, record schemas, and encryption envelope Opake publishes to a PDS.",
262262+ },
147263148264 // -- Cross-cutting ---------------------------------------------------------
149265 {
···171287export function docPath(doc: DocMeta): string {
172288 return doc.group ? `/docs/${doc.group}/${doc.slug}` : `/docs/${doc.slug}`;
173289}
290290+291291+export interface DocGroupBlock {
292292+ readonly key: string;
293293+ readonly label: string;
294294+ readonly docs: readonly DocMeta[];
295295+}
296296+297297+/**
298298+ * Split a category's docs into (ungrouped, grouped) for sidebar rendering.
299299+ * Flat docs render as a straight list; grouped docs render nested under
300300+ * their group label from {@link GROUP_META}.
301301+ */
302302+/**
303303+ * Next doc in the registry that shares either the current doc's group
304304+ * (nested pages like sdk/*) or its category (flat pages). The registry
305305+ * order is the canonical reading order, so "next" is literally the next
306306+ * entry that matches. `null` when the current doc is the last in its
307307+ * sequence, which the UI should render as nothing.
308308+ */
309309+export function nextDoc(currentSlug: string): DocMeta | null {
310310+ const index = DOCS_REGISTRY.findIndex((d) => d.slug === currentSlug);
311311+ if (index === -1) return null;
312312+ const current = DOCS_REGISTRY[index]!;
313313+ for (let i = index + 1; i < DOCS_REGISTRY.length; i++) {
314314+ const candidate = DOCS_REGISTRY[i]!;
315315+ if (candidate.slug === "faq") continue; // FAQ is cross-cutting, not a chapter
316316+ const sameGroup = current.group !== undefined && candidate.group === current.group;
317317+ const sameFlatCategory =
318318+ current.group === undefined &&
319319+ candidate.group === undefined &&
320320+ candidate.category === current.category;
321321+ if (sameGroup || sameFlatCategory) return candidate;
322322+ // Stop walking once we leave the current group or flat-category band —
323323+ // we don't want `sdk/identity` to point at `react/overview` just because
324324+ // it comes later in the array.
325325+ if (current.group !== undefined && candidate.group !== current.group) return null;
326326+ if (current.group === undefined && candidate.category !== current.category) return null;
327327+ }
328328+ return null;
329329+}
330330+331331+export function partitionCategoryForSidebar(category: DocCategory): {
332332+ readonly ungrouped: readonly DocMeta[];
333333+ readonly groups: readonly DocGroupBlock[];
334334+} {
335335+ const docs = docsByCategory(category);
336336+ const ungrouped: DocMeta[] = [];
337337+ const groupMap = new Map<string, DocMeta[]>();
338338+339339+ for (const doc of docs) {
340340+ if (doc.group === undefined) {
341341+ ungrouped.push(doc);
342342+ continue;
343343+ }
344344+ const existing = groupMap.get(doc.group);
345345+ if (existing) {
346346+ existing.push(doc);
347347+ } else {
348348+ groupMap.set(doc.group, [doc]);
349349+ }
350350+ }
351351+352352+ const groups: DocGroupBlock[] = [];
353353+ for (const [key, groupDocs] of groupMap) {
354354+ groups.push({
355355+ key,
356356+ label: GROUP_META[key] ?? key,
357357+ docs: groupDocs,
358358+ });
359359+ }
360360+361361+ return { ungrouped, groups };
362362+}