this repo has no description
1
fork

Configure Feed

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

Refresh docs for monorepo restructure, SDK split, pair containment

Catches the documentation tree up with several landed changes that
weren't previously reflected:

- Monorepo layout: `web/` → `apps/web/`, `crates/opake-cli` →
`apps/cli/`, `appview/` → `apps/indexer/`, packages moved into
`packages/opake-{sdk,react,daemon}/`. Updated every reference to
cargo install paths, test commands, file tree diagrams, and the
architectural narrative.
- AppView → Indexer rename across docs, lexicons, and the ER diagram
role labels. The architecture overview adds a one-liner explaining
that the service fills atproto's "appview" role but is called
indexer here because all payloads are ciphertext.
- SDK package split documented: `@opake/sdk`, `@opake/react`,
`@opake/daemon` descriptions, dependency boundary, React hooks
catalog. LICENSING.md updated so the copyleft-triggering bundle
list names all three packages.
- Lexicon additions: `keyringUpdate` (action types:
addMember/removeMember/updateRole/rename/updateDescription/leave)
replaces the old single-purpose `keyringLeave`, `invitation` +
`invitationAcceptance`, and the `authFullAccess` permission set
for OAuth `include:` scopes when PDSes support them.
- SSE live updates: FLOWS.md adds sections for the WorkspaceKeeper
and InboxKeeper bootstrap-then-patch lifecycle, dedup semantics,
and the rationale for separating `stopSseConsumer` from the keeper
drain (`wipeState`).
- Pair flow key containment + Opake identity invariant: sequence
diagrams updated to show the new Storage touchpoints
(`save_pair_state` on request, `load_pair_state` + `save_identity`
+ `delete_pair_state` on receive), prose rewritten to reflect that
the ephemeral private key is persisted in Storage rather than
kept in memory, and the Domain API section updated to reflect
Opake always having an Identity.
- STORAGE.md reorganised: new `save_pair_state` / `load_pair_state`
/ `delete_pair_state` trait row, JsStorage replaces the former
NoopStorage-on-WASM explanation, record-cache section rewritten
around the SSE-driven refresh model.
- AUTH.md: OAuth section rewritten to reflect WASM-owned tokens
(startOAuthLogin / completeOAuthLogin / loginWithAppPasswordWasm),
the two-step web login flow with PendingLogin, granular scopes
built from `OPAKE_COLLECTIONS`, and proactive refresh via
`tokenExpiresAt` + `proactiveRefresh`.
- docs/indexer.md: new file. Tables, endpoints, auth flow, SSE
stream, backfill, firehose consumer config, deployment notes.
- apps/indexer/lib/opake_indexer_web/router.ex: new file bringing
the Phoenix router into the repo with the full endpoint surface
(`/api/health`, `/api/inbox`, `/api/keyrings`, cabinet +
workspace snapshot/sync/updates, SSE events + token).
- crates/opake-core/src/crypto/mod.rs: add a `Debug` impl for
`Redacted<Option<[u8; N]>>` so `RedactedDebug`-derived types with
optional key fields (Identity.signing_key, verify_key) stop
printing raw bytes in `{:?}` output.

+273 -177
+5 -5
CONTRIBUTING.md
··· 60 60 - Phoenix API with DID-scoped Ed25519 auth (Erlang :crypto) 61 61 - rate limiting via Hammer 62 62 63 - web/ React SPA (Vite + TanStack Router + Tailwind/daisyUI) 64 - - opake-core via @opake/sdk (WASM under the hood) 65 - - IndexedDbStorage (impl Storage over Dexie.js/IndexedDB) 66 - - Zustand stores, Web Worker for off-main-thread crypto 67 - - cabinet file browser UI with panel navigation 63 + web/ React SPA (Vite + TanStack Router/Start + Tailwind/daisyUI) 64 + - opake-core via @opake/sdk (WASM under the hood, main-thread) 65 + - IndexedDbStorage (Dexie.js/IndexedDB, bound into WASM via JsStorage) 66 + - Zustand for small app-level state; @opake/react hooks for SDK data 67 + - cabinet file browser UI with panel navigation, live via SSE 68 68 69 69 packages/ 70 70 @opake/sdk TypeScript SDK wrapping WASM bindings
+15 -8
README.md
··· 2 2 NOTE TO EDITORS: 3 3 Opake uses a dual-documentation system. If you modify the technical details, 4 4 command list, or installation steps in this README, you MUST also update 5 - the corresponding MDX content in `web/src/content/` to prevent 5 + the corresponding MDX content in `apps/web/src/content/` to prevent 6 6 documentation drift. 7 7 --> 8 8 ··· 54 54 55 55 ## Repository Structure 56 56 57 - - `opake-core/` — Platform-agnostic library (Rust/WASM). 58 - - `opake-cli/` — CLI implementation. 59 - - `indexer/` — Elixir/Phoenix indexer for grant discovery. 60 - - `web/` — React SPA (Vite + TanStack). 57 + - `crates/opake-core/` — Platform-agnostic library (Rust/WASM). 58 + - `crates/opake-wasm/` — WASM bindings compiled by `wasm-pack`. 59 + - `apps/cli/` — CLI implementation (`opake` binary). 60 + - `apps/indexer/` — Elixir/Phoenix indexer for grant discovery. 61 + - `apps/web/` — React SPA (Vite + TanStack Start). 62 + - `packages/opake-sdk/` — TypeScript SDK wrapping the WASM bindings. 63 + - `packages/opake-react/` — React hooks over the SDK. 64 + - `packages/opake-daemon/` — Scheduled maintenance tasks. 61 65 - `lexicons/` — AT Protocol schemas (`app.opake.*`). 62 66 63 67 ## Development 64 68 65 69 ```sh 66 - cargo test # Rust tests 67 - bun run wasm:build # Build WASM for web 68 - mix setup # Setup Indexer 70 + just build # cargo build --workspace 71 + just rust-test # cargo test --workspace 72 + just wasm # wasm-pack build → packages/opake-sdk/wasm 73 + just sdk-build # build @opake/sdk (implies wasm) 74 + just web-build # build apps/web (implies sdk-build) 75 + just indexer-test # mix test --cd apps/indexer 69 76 ``` 70 77 71 78 See [CONTRIBUTING.md](CONTRIBUTING.md) for the "mini-nuke" policy and commit conventions.
+4 -3
SECURITY.md
··· 19 19 20 20 - **opake-core** — encryption, key wrapping, seed phrase derivation, XRPC client, record types 21 21 - **opake-cli** — command handling, file storage, credential management 22 - - **opake-wasm** — WASM bindings and the web worker bridge 23 - - **web/** — the React SPA (auth flows, state management, UI rendering of sensitive data) 24 - - **indexer/** — the Elixir indexer (API auth, grant/keyring discovery, rate limiting) 22 + - **opake-wasm** — WASM bindings and the JsStorage bridge 23 + - **packages/opake-sdk + @opake/react + @opake/daemon** — TypeScript layer (auth surfaces, storage adapters, SSE consumer wiring) 24 + - **apps/web** — the React SPA (auth flows, state management, UI rendering of sensitive data) 25 + - **apps/indexer** — the Elixir indexer (API auth, SSE token exchange, grant/keyring discovery, rate limiting) 25 26 - **lexicons** — schema definitions under `app.opake.*` 26 27 27 28 ### Out of scope
+1 -1
apps/indexer/lib/opake_indexer_web/router.ex
··· 6 6 NOTE TO EDITORS: 7 7 Opake uses a dual-documentation system. If you modify the API surface, 8 8 authentication schemes, or indexing logic in this service, you MUST also 9 - update the corresponding MDX content in `web/src/content/` to prevent 9 + update the corresponding MDX content in `apps/web/src/content/` to prevent 10 10 documentation drift. 11 11 """ 12 12
+1 -1
crates/opake-core/src/crypto/mod.rs
··· 3 3 // NOTE TO EDITORS: 4 4 // Opake uses a dual-documentation system. If you modify the cryptographic 5 5 // primitives, key wrapping schemes, or security model in this file, you 6 - // MUST also update the corresponding MDX content in `web/src/content/` 6 + // MUST also update the corresponding MDX content in `apps/web/src/content/` 7 7 // to prevent documentation drift. 8 8 // 9 9 // This module handles AES-256-GCM content encryption and asymmetric key
+20 -9
docs/ARCHITECTURE.md
··· 79 79 80 80 **Direct encryption** — the content key is wrapped individually to each authorized DID. The `keys` array in the document's encryption envelope holds one entry per authorized user. Good for ad-hoc sharing of individual files. 81 81 82 - **Keyring encryption (workspaces)** — a named group has a shared group key (GK), wrapped to each member's X25519 public key with a role (manager, editor, viewer). The keyring has a canonical `owner` DID. Documents have their content key wrapped under GK (AES-256-KW) instead of individual public keys. Adding a member gives them access to all documents without per-document changes. Removing a member rotates GK and re-wraps to remaining members. Editors propose changes via `documentUpdate` records on their own PDS; the owner applies them. Members can opt out via `keyringLeave` records. 82 + **Keyring encryption (workspaces)** — a named group has a shared group key (GK), wrapped to each member's X25519 public key with a role (manager, editor, viewer). The keyring has a canonical `owner` DID. Documents have their content key wrapped under GK (AES-256-KW) instead of individual public keys. Adding a member gives them access to all documents without per-document changes. Removing a member rotates GK and re-wraps to remaining members. Editors propose changes via `documentUpdate` records on their own PDS; the owner applies them. Membership changes go through `keyringUpdate` proposals (action types: `addMember`, `removeMember`, `updateRole`, `rename`, `updateDescription`, and `leave` for opt-outs). 83 83 84 84 **Workspace directories** — workspace folder hierarchies reuse `app.opake.directory` with `keyringKeyWrapping` (content key wrapped under the group key). Directories live on the owner's PDS only — members read them via public fetches. The workspace root uses a deterministic rkey (`ws-{keyring_rkey}`). Non-owner members propose structural changes (add/move/rename/delete entries) via `directoryUpdate` records on their own PDS; the owner's daemon applies them. Directories use `KeyWrapping` instead of the document `Encryption` type — no `algo`/`nonce` since directories have no blob. 85 85 ··· 124 124 DOCUMENT ||--o{ GRANT : "shared via" 125 125 DOCUMENT }o--o{ KEYRING : "optionally encrypted under" 126 126 DOCUMENT ||--o{ DOCUMENT_UPDATE : "updated via" 127 - KEYRING ||--o{ KEYRING_LEAVE : "opted out via" 127 + KEYRING ||--o{ KEYRING_UPDATE : "member proposals via" 128 + DIRECTORY ||--o{ DIRECTORY_UPDATE : "structure proposals via" 128 129 129 130 DOCUMENT { 130 131 blob encrypted_content ··· 154 155 at-uri supersedes "for adoption" 155 156 } 156 157 157 - KEYRING_LEAVE { 158 - at-uri keyring "workspace being left" 158 + KEYRING_UPDATE { 159 + at-uri keyring "target workspace" 160 + string actionType "addMember|removeMember|updateRole|rename|updateDescription|leave" 161 + did memberDid "for member actions" 162 + } 163 + 164 + DIRECTORY_UPDATE { 165 + at-uri keyring "target workspace" 166 + at-uri directory "target directory" 167 + string actionType "addEntry|removeEntry|moveEntry|createDirectory|deleteDirectory|renameDirectory" 159 168 } 160 169 161 170 IDENTITY { ··· 188 197 189 198 opake-core exposes a domain-driven API through three types: 190 199 191 - - **`Opake<T, R, S>`** — Root context. Bundles the authenticated PDS client, identity, RNG, platform time, and storage layer. Owns the storage so it can auto-persist sessions after mutations. All CLI commands except `pair request` (new device, no identity yet) route through Opake. Key method categories: 192 - - **Context:** `file_context(workspace_name?)`, `file_manager(&context)`, `workspace_admin()`, `resolve_workspace(name)`, `did()`, `identity()`, `now()`, `session()` 200 + - **`Opake<T, R, S>`** — Root context. Bundles the authenticated PDS client, identity, RNG, platform time, and storage layer. Owns the storage so it can auto-persist sessions after mutations. A constructed `Opake` always has an Identity: `for_account` returns `Error::IdentityMissing` when the account is authenticated but has no encryption keys yet, and callers route to the bootstrap flows (`recover` or `pair`) to produce one. Key method categories: 201 + - **Context:** `file_context(workspace_name?)`, `file_manager(&context)`, `workspace_admin()`, `resolve_workspace(name)`, `did()`, `identity()`, `now()` (the WASM handle exposes a narrower surface — `getDid` / `tokenExpiresAt` only, no session accessor — to keep tokens and DPoP keys from crossing into JS-managed memory) 193 202 - **Workspaces:** `create_workspace`, `list_workspaces`, `add_workspace_member`, `leave_workspace`, `unwrap_workspace_key` 194 203 - **Sharing:** `download_from_grant`, `download_as_workspace_member`, `list_pending_shares`, `cancel_pending_share`, `retry_pending_shares` 195 204 - **Identity/account:** `resolve_identity`, `publish_public_key`, `save_identity`, `remove_account`, `get_account_config`, `set_account_config` 196 - - **Pairing:** `create_pair_request`, `list_pair_requests`, `list_pair_responses`, `approve_pair_request`, `receive_pair_response`, `cleanup_pair_records`, `cleanup_expired_pair_requests` 205 + - **Pairing (existing device):** `list_pair_requests`, `approve_pair_request`, `cleanup_expired_pair_requests` 197 206 - **Maintenance:** `heal_stale_grants`, `purge_collection` 198 207 - **Low-level:** `create_record`, `get_record` 199 208 209 + The new-device side of pairing runs *before* an Identity exists, so it is exposed as free functions in `crate::pairing` — `create_pair_request`, `try_complete_pair`, `cancel_pair_request` — which take `&S: Storage` and `did` directly. The ephemeral X25519 private key is persisted via `Storage::save_pair_state` and never crosses the WASM/JS boundary; the resulting Identity is written to Storage by `try_complete_pair` on success, at which point the standard `Opake::for_account` path succeeds. 210 + 200 211 - **`FileManager<'a, T, R, S>`** — Borrows `&'a mut Opake` and `&'a FileContext`. Unified file operations for both cabinet (personal) and workspace (shared) contexts, dispatching internally based on `FileContext`. All mutations use `applyWrites` for atomicity — no ghost documents or dangling directory references on partial failure. Path-based methods: `upload_at`, `download_at`, `create_directory_at`, `resolve_entry`, `resolve_document_names`, `resolve_document_names_in`, `resolve_document_metadata_in`, `read_metadata`, `delete_recursive`, `create_record`. Also: `load_tree`, `update_metadata`, `update_content`, `fetch_content_key`, `move_entry`, `share`, `revoke_share`, `list_shares`. 201 212 202 213 - **`WorkspaceAdmin<T, R, S>`** — Created via `opake.workspace_admin()`. Workspace membership operations: `add_member`, `remove_member`, `leave`. Separated from `Opake` because these operate on a resolved workspace context, not individual files. 203 214 204 215 Construction example: 205 216 ```rust 206 - let mut opake = Opake::new(client, identity, rng, storage, now, now_micros); 217 + let mut opake = Opake::new(client, did, identity, rng, storage, now_micros); 207 218 let ctx = opake.file_context(Some("family-photos")).await?; 208 219 let mut mgr = opake.file_manager(&ctx); 209 220 mgr.upload_at(&plaintext, "photo.jpg", "image/jpeg", None, None).await?; ··· 212 223 213 224 Every public mutation method on `FileManager` and `Opake` uses the `#[signoff]` proc-macro attribute (from opake-derive). This generates a wrapper + inner method split: the wrapper calls the inner method, then calls `signoff(result).await` to persist the session if it was refreshed during the call. Two variants: `#[signoff]` (FileManager — routes through `self.opake.signoff()`) and `#[signoff(self)]` (Opake — calls `self.signoff()` directly). If the operation itself failed, signoff is best-effort — the original error is preserved. 214 225 215 - `Workspace` and `Cabinet` are domain types carrying decrypted key material, both with `ZeroizeOnDrop` — key bytes are overwritten when the context is dropped. The WASM layer uses `WasmFileManagerHandle` which owns `Opake + FileContext` and creates temporary `FileManager` borrows per JS method call (wasm_bindgen can't have lifetimes). `NoopStorage` is used for WASM (JS handles persistence externally) and tests. Raw functions (`encrypt_and_upload`, etc.) are `pub(crate)` — `FileManager` is the public API. 226 + `Workspace` and `Cabinet` are domain types carrying decrypted key material, both with `ZeroizeOnDrop` — key bytes are overwritten when the context is dropped. `Workspace::from_keyring` is `pub(crate)`, so the only way to produce a `Workspace` outside opake-core is through the resolution methods (`resolve_workspace`, `file_context`, `workspaceByUri`) — this keeps the invariant that the URI, owner, group key, and rotation all came from the same verified keyring record. The WASM layer uses `WasmFileManagerHandle`, which shares an `Rc<Mutex<WasmOpake>>` with the parent `WasmOpakeHandle` and creates short-lived `FileManager` borrows inside each JS method (wasm_bindgen can't carry lifetimes across the boundary). The shared `Mutex` queues concurrent async operations instead of panicking on aliased `&mut self`. WASM persistence goes through `JsStorage`, a `Storage` impl that calls back into a JS-side `IndexedDbStorage`; `NoopStorage` is tests only. Raw functions (`encrypt_and_upload`, etc.) are `pub(crate)` — `FileManager` is the public API. 216 227 217 228 ## Further Reading 218 229
+5 -3
docs/AUTH.md
··· 64 64 65 65 On the web, `IndexedDbStorage` uses the same logical layout over IndexedDB tables, keyed by DID. 66 66 67 - CLI commands that need an account resolve it via: explicit `--did` flag, then `default_did` from config, then error. `opake accounts` lists all accounts; `opake set-default` switches. 67 + CLI commands that need an account resolve it via: explicit `--as <did>` flag, then `default_did` from config, then error. `opake account list` lists all accounts (`*` marks the default); `opake account set-default <did>` switches. 68 68 69 69 ## Device Pairing 70 70 ··· 77 77 participant Old as Existing Device 78 78 79 79 New->>New: Generate ephemeral X25519 keypair 80 + New->>Storage: save_pair_state(did, rkey, privkey) 80 81 New->>PDS: createRecord(pairRequest) { ephemeralKey } 81 82 New->>New: Display fingerprint (first 8 bytes, hex) 82 83 ··· 86 87 Old->>PDS: createRecord(pairResponse) { wrappedKey, ciphertext, nonce } 87 88 88 89 New->>PDS: Poll for matching pairResponse 90 + New->>Storage: load_pair_state(did, rkey) 89 91 New->>New: Unwrap key, decrypt identity 90 92 New->>PDS: getRecord(publicKey/self) 91 93 New->>New: Verify derived pubkey == published pubkey 92 - New->>New: Save identity.json (0600) 94 + New->>Storage: save_identity + delete_pair_state 93 95 New->>PDS: Delete pairRequest + pairResponse 94 96 ``` 95 97 96 - The ephemeral private key never leaves memory. The identity payload uses the same AES-256-GCM + x25519-hkdf-a256kw primitives as file encryption. Visual fingerprint comparison is the current SAS mechanism; programmatic verification is a follow-up. 98 + The ephemeral private key is persisted to the new device's `Storage` (0600 file on CLI, dedicated IndexedDB table on web) between `create_pair_request` and `try_complete_pair` — it needs to survive page reloads or CLI restarts while the user walks to the other device, so in-memory only isn't sufficient. It never crosses the WASM/JS boundary: the SDK exposes `Opake.createPairRequest(storage, did)` → `{uri, rkey, ephemeralPublicKey}` and `Opake.awaitPairCompletion(storage, did, rkey)` → `void`. JS sees the public key (for fingerprint display) and nothing else. The identity payload uses the same AES-256-GCM + x25519-hkdf-a256kw primitives as file encryption. Visual fingerprint comparison is the current SAS mechanism; programmatic verification is a follow-up. 97 99 98 100 Login on a new device detects an existing `publicKey/self` record and prompts for pairing instead of generating a new keypair (which would orphan encryption on the existing device). 99 101
+128 -77
docs/CRATE_STRUCTURE.md
··· 8 8 cabinet.rs Cabinet domain type (personal file space). ZeroizeOnDrop 9 9 workspace.rs Workspace domain type (shared file space, wraps keyring data). ZeroizeOnDrop 10 10 tid.rs TID (Timestamp ID) generator for client-side AT Protocol rkeys 11 + timestamp.rs RFC 3339 formatter from microseconds — single clock source for Opake (no chrono dep in core) 11 12 atproto.rs AT-URI parsing, shared AT Protocol primitives 12 13 account_config.rs Fetch/publish singleton account config from PDS 13 14 resolve.rs Handle/DID → PDS → public key resolution pipeline ··· 17 18 daemon.rs Background task registry (shared definitions for CLI + web). Daemon builds Opake per account per task iteration, auto-persists via signoff 18 19 error.rs Typed error hierarchy (thiserror) 19 20 test_utils.rs MockTransport + response queue (behind test-utils feature) 20 - tree_keeper/ 21 - mod.rs TreeKeeper — per-DID in-memory directory tree state. Bootstrapped from SSE initial snapshot, patched incrementally by document/directory events. `watchDirectory` installs typed snapshot callbacks. Separate Mutex from WorkspaceKeeper. 22 - tests.rs Unit tests 23 - workspace_keeper/ 24 - mod.rs WorkspaceKeeper — in-memory workspace list state. Bootstrapped by `listWorkspaces`, patched by `keyring:upsert` / `keyring:delete` SSE events. `watchWorkspaces` installs snapshot callbacks. Parallel design to TreeKeeper. 25 - tests.rs Unit tests 26 - inbox_keeper/ 27 - mod.rs InboxKeeper — in-memory incoming-share list state. Bootstrapped by `listInbox`, patched by `grant:upsert` / `grant:delete` SSE events (indexer fans both to owner and recipient). `watchInbox` installs snapshot callbacks. Parallel design to WorkspaceKeeper; no crypto — entries are already-resolved indexer records. 28 - tests.rs Unit tests 21 + indexer/ 22 + mod.rs Indexer client, types, submodule declarations 23 + auth.rs Opake-Ed25519 header construction for indexer calls 24 + client.rs HTTP wrappers for /api/inbox, /api/keyrings, /api/workspace, /api/cabinet, /api/events/token 25 + daemon.rs Indexer-coordinated maintenance tasks (subset of the core daemon registry) 26 + types.rs InboxGrant, KeyringEntry, WorkspaceEntry, etc. 27 + sse/ SseEvent enum + reconnecting consumer (wasm + native transports) 28 + tree_keeper/ 29 + mod.rs TreeKeeper — per-DID in-memory directory tree state. Bootstrapped from loadTree, patched incrementally by document/directory SSE events. `watchDirectory` installs typed snapshot callbacks. Separate Mutex from WorkspaceKeeper. 30 + tests.rs Unit tests 31 + workspace_keeper/ 32 + mod.rs WorkspaceKeeper — in-memory workspace list state. Bootstrapped by `listWorkspaces`, patched by `keyring:upsert` / `keyring:delete` SSE events. `watchWorkspaces` installs snapshot callbacks. Parallel design to TreeKeeper. 33 + tests.rs Unit tests 34 + inbox_keeper/ 35 + mod.rs InboxKeeper — in-memory incoming-share list state. Bootstrapped by `listInbox`, patched by `grant:upsert` / `grant:delete` SSE events (indexer fans both to owner and recipient). `watchInbox` installs snapshot callbacks. Parallel design to WorkspaceKeeper; no crypto — entries are already-resolved indexer records. 36 + tests.rs Unit tests 29 37 manager/ 30 38 mod.rs FileManager<'a, T, R, S> struct (borrows &mut Opake + &FileContext), create_record passthrough 31 39 types.rs UploadRequest, UploadResult, DownloadResult, MutationOutcome, FileContext ··· 60 68 keyring.rs KeyHistoryEntry, Keyring (with owner field) 61 69 document_update.rs DocumentUpdate (actionType: updateContent/updateMetadata/supersede) 62 70 directory_update.rs DirectoryUpdate (actionType: addEntry/removeEntry/moveEntry/createDirectory/deleteDirectory/renameDirectory) 63 - keyring_leave.rs KeyringLeave, KEYRING_LEAVE_COLLECTION 71 + keyring_update.rs KeyringUpdate (actionType: addMember/removeMember/updateRole/rename/updateDescription/leave) 72 + account_config.rs AccountConfig (singleton; proof-of-life heartbeat) 73 + invitation.rs Invitation, InvitationAcceptance 74 + pair_request.rs PairRequest + tests 75 + pair_response.rs PairResponse + tests 76 + pending_share.rs PendingShare (local-only queue of in-flight shares) 64 77 client/ 65 78 mod.rs Re-exports 66 79 transport.rs Transport trait (HTTP abstraction for WASM compat) ··· 111 124 revoke.rs revoke_grant() 112 125 pairing/ 113 126 mod.rs Re-exports 114 - request.rs create_pair_request() — write ephemeral key to PDS 115 - respond.rs respond_to_pair_request() — encrypt + wrap identity 116 - receive.rs receive_pair_response() — decrypt + verify identity 117 - cleanup.rs cleanup_pair_records() — delete request + response 127 + request.rs create_pair_request() — write ephemeral key to PDS, persist privkey via Storage 128 + respond.rs respond_to_pair_request() — encrypt + wrap identity (existing device) 129 + receive.rs try_complete_pair() — poll, decrypt, save Identity, tear down pair state 130 + cancel.rs cancel_pair_request() — wipe pair state on user back-out 131 + cleanup.rs cleanup_pair_records() — daemon sweep for expired/orphan records 118 132 119 133 opake-wasm/ WASM bridge (wasm-pack, wasm_bindgen) 120 134 src/ 121 135 lib.rs Module declarations, WASM init, pure crypto + tree exports (stateless) 122 136 auth_wasm.rs OAuth login WASM exports: startOAuthLogin, completeOAuthLogin, loginWithAppPasswordWasm. All token handling in WASM. 123 - opake_wasm.rs OpakeContext + WasmFileManagerHandle (owns Opake+FileContext, temporary FileManager borrows per JS call). Also: tokenExpiresAt, proactiveRefresh, checkSession 124 - daemon.rs Service Worker maintenance task exports (session refresh, pair cleanup) 125 - wasm_util.rs make_client, make_opake, make_cabinet, make_workspace helpers. WasmOpake = Opake<WasmTransport, OsRng, NoopStorage> 137 + opake_wasm.rs WasmOpakeHandle (exported to JS as `OpakeContext`). Owns Rc<Mutex<WasmOpake>> shared with WasmFileManagerHandle; short-lived FileManager borrows per JS call. Exports: tokenExpiresAt, proactiveRefresh, checkSession, wipeState, getDid (cached by the SDK at init time into `opake.did`). 138 + file_manager_wasm.rs WasmFileManagerHandle (exported to JS as `FileManager`). File operations within a cabinet or workspace context. 139 + sse_wasm.rs SSE consumer bindings: startSseConsumer, stopSseConsumer, watchWorkspaces, watchInbox 140 + pair_wasm.rs New-device pair bindings: createPairRequest, tryCompletePair, cancelPairRequest. Top-level functions (not on OpakeContext) — they run pre-identity and take JsStorageAdapter + DID directly. No key material crosses the JS boundary. 141 + js_storage.rs JsStorage — Storage impl that calls back into a JS-side IndexedDbStorage adapter. 142 + daemon.rs WASM bindings for the daemon task registry (daemonTaskDefs) and the default-interval/TTL constants the Service Worker shares with the CLI. Maintenance ops themselves (session refresh, pair cleanup, stale-grant healing, share retry) are OpakeContext methods in opake_wasm.rs. 143 + wasm_util.rs make_opake_from_storage, cabinet_context helpers; wasm_err / pub_key_from_slice / to_js / parse_role / build_snapshot utilities; DTOs (DownloadResult, MutationResultDto). WasmOpake = Opake<WasmTransport, OsRng, JsStorage>. Workspace contexts only via opake.workspaceByUri()/resolve — no pre-resolved-key escape hatch. 126 144 127 145 opake-derive/ Proc-macro crate 128 146 src/ ··· 134 152 src/ 135 153 main.rs Clap app, command dispatch. Workspace command (alias for hidden Keyring) 136 154 config.rs FileStorage (impl Storage for filesystem), anyhow wrappers 137 - session.rs CommandContext, opake() factory (passes FileStorage to Opake), chrono_now/chrono_now_micros 155 + session.rs CommandContext, build_opake() (the single CLI Opake construction path — shared by CommandContext::opake and the daemon), chrono_now_micros 138 156 identity.rs Identity loading, migration (signing keys), permission checks 139 157 keyring_store.rs Local group key persistence (per-keyring) 140 158 oauth.rs OAuth loopback redirect server + browser open ··· 227 245 via `@opake/sdk`. No web-local worker layer: all file/identity operations go 228 246 through `@opake/sdk` and `@opake/react` hooks on the main thread. 229 247 230 - indexer/ Elixir/Phoenix indexer + REST API (replaces Rust indexer) 231 - lib/ 232 - opake_indexer/ 233 - application.ex OTP supervision tree (Repo, KeyCache, Endpoint, Consumer) 234 - indexer.ex Event dispatch, cursor saving, connection state (ETS) 235 - release.ex Release tasks (create_db, migrate, rollback, status) 236 - repo.ex Ecto Repo 237 - auth/ 238 - plug.ex Opake-Ed25519 header verification (Plug) 239 - key_cache.ex GenServer + ETS, 5-min TTL per DID 240 - key_fetcher.ex DID → PDS → publicKey → signingKey resolution 241 - base64.ex Flexible base64 decode (padded/unpadded) 242 - jetstream/ 243 - consumer.ex WebSockex client with exponential backoff 244 - event.ex Jetstream JSON → tagged tuples 245 - queries/ 246 - cursor_queries.ex Singleton cursor upsert/load 247 - grant_queries.ex Grant CRUD + inbox pagination 248 - keyring_queries.ex Keyring member CRUD + membership pagination 249 - workspace_queries.ex Workspace document membership queries 250 - document_update_queries.ex Document update index queries 251 - pagination.ex Shared cursor-based pagination helpers 252 - schemas/ 253 - cursor.ex Singleton cursor (id=1) 254 - grant.ex Grant (uri PK) 255 - keyring_member.ex Keyring member (composite PK) 256 - workspace_document.ex Workspace document schema 257 - document_update.ex Document update schema 258 - opake_indexer_web/ 259 - router.ex /api/health (public), /api/inbox + /api/keyrings + /api/workspace + /api/workspace/updates (auth'd) 260 - endpoint.ex Bandit HTTP, API-only (no sessions/static) 261 - plugs/rate_limit.ex Hammer ETS rate limiting per IP 262 - controllers/ 263 - health_controller.ex Indexer status + cursor lag 264 - inbox_controller.ex Grants by recipient DID 265 - keyrings_controller.ex Keyrings by member DID 266 - workspace_controller.ex Workspace documents + pending updates 267 - pagination_helpers.ex Shared param parsing (did, limit, cursor) 248 + apps/indexer/ Elixir/Phoenix indexer + REST API + SSE broadcaster 249 + lib/ 250 + opake_indexer/ 251 + application.ex OTP supervision tree (Repo, KeyCache, Endpoint, Jetstream consumer, SSE ETS tables) 252 + firehose.ex Event dispatch, cursor saving, connection state 253 + firehose/ Firehose runtime state + heartbeat 254 + release.ex Release tasks (create_db, migrate, rollback, status) 255 + repo.ex Ecto Repo 256 + backfill.ex Historical ingestion of a DID's app.opake.* records 257 + tombstone_cleanup.ex Periodic cleanup of tombstoned records 258 + auth/ 259 + plug.ex Opake-Ed25519 header verification (Plug) 260 + key_cache.ex GenServer + ETS, 5-min TTL per DID 261 + key_fetcher.ex DID → PDS → publicKey → signingKey resolution 262 + base64.ex Flexible base64 decode (padded/unpadded) 263 + jetstream/ 264 + consumer.ex WebSockex client with exponential backoff 265 + event.ex Jetstream JSON → tagged tuples 266 + compression.ex Per-consumer zstd streaming context 267 + sse/ 268 + broadcaster.ex PubSub fan-out for every indexed event 269 + topics.ex Topic builders (personal, workspace) 270 + token_store.ex Single-use opaque tokens for /api/events 271 + connection_tracker.ex ETS-backed per-DID SSE connection cap 272 + queries/ 273 + cursor_queries.ex Singleton cursor upsert/load 274 + grant_queries.ex Grant CRUD + inbox pagination 275 + keyring_queries.ex Keyring + keyring_member CRUD and membership pagination 276 + document_queries.ex Keyring-encrypted document index queries 277 + document_update_queries.ex Document update proposal queries 278 + directory_queries.ex Directory + directory_update queries, cabinet/workspace snapshot + sync 279 + pagination.ex Shared cursor-based pagination helpers 280 + schemas/ 281 + cursor.ex Singleton cursor (id=1) 282 + grant.ex Grant (uri PK) 283 + keyring.ex Keyring (uri PK, owner DID, metadata) 284 + keyring_member.ex Keyring member (composite PK: keyring_uri + member_did) 285 + keyring_update.ex Keyring update proposal 286 + document.ex Keyring-encrypted document 287 + document_update.ex Document update proposal 288 + directory.ex Workspace directory 289 + directory_update.ex Directory update proposal 290 + opake_indexer_web/ 291 + router.ex /api/health (public); everything else auth'd: inbox, keyrings, workspace/{documents,updates,directory-updates,snapshot,sync}, cabinet/{snapshot,sync}, events + events/token 292 + endpoint.ex Bandit HTTP, long SSE read timeouts 293 + plugs/rate_limit.ex Hammer ETS rate limiting per IP (skipped for /api/events) 294 + controllers/ 295 + health_controller.ex Indexer status + cursor lag 296 + inbox_controller.ex Grants by recipient DID 297 + keyrings_controller.ex Keyrings by member DID 298 + workspace_controller.ex Workspace documents, update proposals, snapshot + sync 299 + cabinet_controller.ex Cabinet snapshot + sync (personal tree) 300 + events_controller.ex POST /events/token + GET /events (chunked SSE) 301 + pagination_helpers.ex Shared param parsing (did, limit, cursor, since) 302 + tree_helpers.ex Snapshot/sync response shaping 268 303 269 304 packages/ 270 - opake-sdk/ @opake/sdk — TypeScript SDK wrapping WASM bindings 305 + opake-sdk/ @opake/sdk — TypeScript SDK wrapping the WASM bindings 271 306 src/ 272 307 index.ts Package entry point, re-exports 273 - opake.ts Opake client (auth, identity, workspaces, daemon ops) 274 - file-manager.ts FileManager (upload, download, tree, metadata) 308 + opake.ts Opake client (auth, identity, workspaces, SSE, daemon ops) 309 + file-manager.ts FileManager (upload, download, tree, metadata, directory CRUD) 275 310 auth.ts OAuth/DPoP two-step login + app password flows 276 - pairing.ts Device pairing (create/approve/receive/cleanup) 277 - storage.ts Storage interface (mirrors opake-core trait) 278 - wasm.ts WASM initialization and bridge 279 - types.ts Domain type definitions (results, pairing, workspaces) 311 + pairing.ts Device pairing — new-device statics (createPairRequest / awaitPairCompletion / cancelPairRequest, take Storage+DID) + existing-device helpers (listPairRequests / approvePairRequest / cleanupExpiredPairRequests, take an Opake context) 312 + schemas.ts Zod schemas for WASM return values (runtime validation at the boundary) 313 + storage.ts Storage interface (mirrors the opake-core trait) 314 + storage-adapter.ts JS-side adapter that the Rust JsStorage binds against 315 + storage/ 316 + indexeddb.ts Dexie-based IndexedDbStorage 317 + memory.ts In-memory Storage for tests 318 + wasm.ts WASM init + bridge helpers 319 + finalizer.ts FinalizationRegistry wrapper for WASM handle disposal 320 + types.ts Domain type exports (DirectoryTreeSnapshot, WorkspaceEntry, etc.) 280 321 errors.ts Typed error hierarchy 281 - storage/ Storage implementations 282 - wasm/ WASM build output (wasm-pack → here) 322 + wasm/ wasm-pack output (imported by the SDK) 283 323 284 - opake-daemon/ @opake/daemon — Background task scheduler 324 + opake-daemon/ @opake/daemon — Background task scheduler (browser-main-thread) 285 325 src/ 286 326 index.ts Package entry point 287 - scheduler.ts Task scheduling loop 288 - tasks.ts Task definitions (session refresh, pair cleanup, grant healing) 327 + scheduler.ts startDaemon() — interval-driven task loop 328 + tasks.ts Task definitions (session refresh, pair cleanup, grant healing, share retry) 289 329 types.ts Task type definitions 290 330 291 - opake-react/ @opake/react — React bindings 331 + opake-react/ @opake/react — React 19 hooks over @opake/sdk 292 332 src/ 293 333 index.ts Package entry point 294 - provider.tsx OpakeProvider context 295 - keys.ts Query key management 296 - hooks/ React hooks for Opake operations 334 + provider.tsx OpakeProvider (FileManagerCache + OptimisticOverlay + SSE lifecycle) 335 + file-manager-cache.ts Refcounted per-context FileManager cache 336 + optimistic-overlay.ts Per-scope snapshot-transforming patch store 337 + keys.ts React Query key factories 338 + hooks/ 339 + bootstrap-once.ts Deduped (Opake, label) bootstrap guard 340 + use-directory.ts Subscription tree hook (SSE-driven) 341 + use-directory-metadata.ts Per-directory document-metadata query 342 + use-directory-mutations.ts Create/rename/delete directory mutations 343 + use-file-manager.ts Low-level FileManager acquisition 344 + use-tree.ts Deprecated react-query tree reader 345 + use-tree-mutation.ts Shared mutation helper (optimistic overlay + invalidation) 346 + use-upload.ts / use-delete.ts / use-move.ts / use-download.ts 347 + use-workspaces.ts / use-inbox.ts / use-shares.ts / use-pending-shares.ts 348 + use-share-mutations.ts / use-create-workspace.ts 349 + use-daemon.ts Background task status 350 + use-start-sse-consumer.ts Standalone SSE start primitive (provider does this already) 297 351 298 - tests/ E2E and integration tests 299 - tests/ 300 - cli/ CLI integration tests 301 - web/ Web E2E tests (Playwright) 352 + tests/ Cross-package integration tests (CLI-driven Rust tests) 302 353 ``` 303 354 304 355 The boundary is strict: `opake-core` never touches the filesystem, stdin, or any platform-specific API. All I/O happens through the `Storage` trait — `FileStorage` (CLI, filesystem) and `IndexedDbStorage` (web, IndexedDB) implement the same contract with platform-specific backends. This keeps `opake-core` compilable to WASM. The `@opake/sdk` package wraps the WASM bindings in a TypeScript API; the web frontend consumes Opake through the SDK, not raw WASM imports. 305 356 306 - The `Opake<T, R, S>` struct is the root context for all operations. All CLI commands route through Opake except `pair request` (new device has no identity yet — uses raw pairing functions). Construct one with an authenticated client + identity + storage, then call `.file_context(workspace_name?)` to resolve the target, `.file_manager(&context)` for file operations, or `.workspace_admin()` for membership management (add/remove member, leave). Opake itself provides workspace CRUD, sharing (grants, pending shares), identity/account management, pairing, and maintenance methods. Session persistence is automatic via `#[signoff]` / `#[signoff(self)]` on every public mutation. Platform differences (transport, RNG, clock, storage) are injected via type parameters and function pointers — no conditional compilation in the domain layer. 357 + The `Opake<T, R, S>` struct is the root context for all operations. A constructed `Opake` always has an Identity — `for_account` returns `Error::IdentityMissing` for authenticated accounts without one, and callers route to recovery or pairing to bootstrap. All CLI commands route through Opake except `pair request` and `recover` (neither has an Identity yet — both use the storage-backed free functions in `opake_core::pairing` and the raw XRPC client). Construct one with an authenticated client + identity + storage, then call `.file_context(workspace_name?)` to resolve the target, `.file_manager(&context)` for file operations, or `.workspace_admin()` for membership management (add/remove member, leave). Opake itself provides workspace CRUD, sharing (grants, pending shares), identity/account management, the existing-device side of pairing (list/approve), and maintenance methods. Session persistence is automatic via `#[signoff]` / `#[signoff(self)]` on every public mutation. Platform differences (transport, RNG, clock, storage) are injected via type parameters and function pointers — no conditional compilation in the domain layer.
+3 -3
docs/FLOWS.md
··· 2 2 NOTE TO EDITORS: 3 3 Opake uses a dual-documentation system. If you modify the operation flows 4 4 or data models in this file, you MUST also update the corresponding MDX 5 - content in `web/src/content/` to prevent documentation drift. 5 + content in `apps/web/src/content/` to prevent documentation drift. 6 6 --> 7 7 8 8 # Opake — Operation Flows ··· 51 51 52 52 `wipeState` → `WorkspaceKeeper::uninstall_all` (clears entries, watchers, resets `loaded`). `stopSseConsumer` only flips the consumer's cancellation flag; the keeper drain lives on `wipeState` so callers that need to stop streaming without losing decrypted state (e.g. temporary network pause) can do so without forcing a fresh re-bootstrap. 53 53 54 - See `WorkspaceKeeper` in `crates/opake-core/src/workspace_keeper/` and `apply_keyring_to_workspace_keeper` in `crates/opake-wasm/src/sse_wasm.rs`. 54 + See `WorkspaceKeeper` in `crates/opake-core/src/indexer/workspace_keeper/` and `apply_keyring_to_workspace_keeper` in `crates/opake-wasm/src/sse_wasm.rs`. 55 55 56 56 ## Inbox live updates 57 57 ··· 78 78 79 79 `wipeState` → `InboxKeeper::uninstall_all` (clears entries, watchers, resets `loaded`). See the `WorkspaceKeeper` section above for the reason `stopSseConsumer` doesn't drain the keepers itself. 80 80 81 - See `InboxKeeper` in `crates/opake-core/src/inbox_keeper/` and `apply_grant_to_inbox_keeper` in `crates/opake-wasm/src/sse_wasm.rs`. 81 + See `InboxKeeper` in `crates/opake-core/src/indexer/inbox_keeper/` and `apply_grant_to_inbox_keeper` in `crates/opake-wasm/src/sse_wasm.rs`.
+2 -2
docs/LICENSING.md
··· 12 12 | Running modified Opake as a network service for others | **Yes** | 13 13 | Building a plugin that links against `opake-core` or `opake-wasm` | **Yes** — your plugin is AGPL | 14 14 | Building a tool that only talks to Opake over HTTP/XRPC APIs | No | 15 - | Bundling `@opake/wasm` in your web app | **Yes** — your app is AGPL | 15 + | Bundling `@opake/sdk`, `@opake/react`, `@opake/daemon`, or the underlying WASM in your web app | **Yes** — your app is AGPL | 16 16 | Submitting a pull request | Your contribution is licensed under AGPL-3.0 | 17 17 18 18 ## For Self-Hosters ··· 36 36 ### What triggers copyleft 37 37 38 38 - **Importing `opake-core` as a Rust dependency.** Your crate becomes a derivative work. AGPL applies to the combined work. 39 - - **Bundling `@opake/wasm`** (the WASM module built from `opake-core`) in a web application. The entire application that incorporates the module is covered. 39 + - **Bundling `@opake/sdk`** (or `@opake/react`, `@opake/daemon`, or the WASM module built from `opake-core`) in a web application. The entire application that incorporates the module is covered. 40 40 - **Calling `opake-core` functions from a WASM host.** Same as linking — the host application is a derivative work. 41 41 42 42 ### What does NOT trigger copyleft
+11 -40
docs/STORAGE.md
··· 9 9 | `load_config` / `save_config` | Read/write the global config (accounts map, default DID) | 10 10 | `load_identity` / `save_identity` | Read/write per-account encryption keypairs | 11 11 | `load_session` / `save_session` | Read/write per-account JWT tokens | 12 + | `save_pair_state` / `load_pair_state` / `delete_pair_state` | Persist the ephemeral X25519 private key between `create_pair_request` and `try_complete_pair`. Keyed by `(did, rkey)`. Storage-owned so the key never crosses the WASM/JS boundary — it is written and read exclusively from inside WASM. | 12 13 | `remove_account` | Full cleanup: mutate config + delete identity/session data + persist | 13 14 | `cache_get_record` / `cache_put_records` | Record-level cache: look up or upsert individual PDS records | 14 15 | `cache_remove_record` | Remove a single cached record (e.g. after deletion or metadata update) | ··· 20 21 21 22 **CLI (`FileStorage`)** — TOML config at `~/.config/opake/config.toml`, JSON files in per-account directories, unix permissions (0600/0700). Cache methods are no-ops (not yet implemented). 22 23 23 - **Web (`IndexedDbStorage`)** — Dexie.js over IndexedDB. Schema v3 adds `cacheRecords` (compound key `[did+collection+uri]`) and `cacheMeta` (compound key `[did+collection]`) tables. `removeAccount` clears cache as part of its atomic transaction. 24 + **Web (`IndexedDbStorage`)** — Dexie.js over IndexedDB (`packages/opake-sdk/src/storage/indexeddb.ts`). Schema includes `cacheRecords` (compound key `[did+collection+uri]`) and `cacheMeta` (compound key `[did+collection]`) tables. `removeAccount` clears cache as part of its atomic transaction. The JS side runs on the main thread; `JsStorage` (below) bridges WASM into this implementation. 24 25 25 - **WASM (`NoopStorage`)** — All methods return `Error::NotFound`. Used when the JS layer handles persistence externally (IndexedDB via the web worker) and in tests. WASM builds pass `NoopStorage` to `Opake<WasmTransport, OsRng, NoopStorage>`. 26 + **WASM (`JsStorage`)** — `Storage` impl in `crates/opake-wasm/src/js_storage.rs` that calls back into a JS-side adapter. The adapter wraps `IndexedDbStorage` so WASM's Rust code reads and writes identity, session, config, and the record cache through the same IndexedDB tables the SDK uses directly. `NoopStorage` exists for tests only. 26 27 27 28 ### Auto-Persist via Signoff 28 29 ··· 30 31 31 32 ## Local Record Cache 32 33 33 - The cache stores **encrypted PDS records** — the same ciphertext the PDS returns. No plaintext metadata is ever persisted locally. Decrypted metadata lives only in-memory and is discarded on page unload. 34 + The cache stores **encrypted PDS records** — the same ciphertext the PDS returns. No plaintext metadata is ever persisted locally. Decrypted metadata lives only in-memory (in the WASM TreeKeeper and its per-directory name cache) and is discarded on logout or `wipeState`. 34 35 35 - ### Design: Two-Path Loading 36 + ### Population 36 37 37 - The cache separates the **UI path** (what the user sees) from the **warming path** (how the cache gets populated). This avoids rate-limiting the PDS while keeping the UI responsive. 38 + `FileManager::load_tree` and `syncAndLoadTree` are the primary entry points. Both fetch directories and grants via paginated `listRecords`, populate the cache with the encrypted records, and hand a `DirectoryTree` back to the caller. Document records are fetched on demand via `loadTreeWithMetadata` when a consumer needs decrypted filenames / MIME types for a specific directory. 38 39 39 - ``` 40 - ┌─────────────────────────────────────────────────────────┐ 41 - │ loadCabinet() │ 42 - │ │ 43 - │ 1. Fetch directories + grants (small, list-all) │ 44 - │ 2. Build directory tree → show shell immediately │ 45 - │ 3. Kick off background document cache warm │ 46 - └───────────────────┬─────────────────────────────────────┘ 47 - 48 - ┌───────────┴───────────┐ 49 - ▼ ▼ 50 - UI Path (foreground) Warming Path (background) 51 - ┌───────────────────┐ ┌──────────────────────────┐ 52 - │ ensureDirectory- │ │ listDocumentsRaw() │ 53 - │ Ready(uri) │ │ │ 54 - │ │ │ One paginated request │ 55 - │ For each doc URI: │ │ (1-3 pages) fetches all │ 56 - │ cache hit → use │ │ documents → writes each │ 57 - │ cache miss → │ │ to cache via │ 58 - │ getRecordRaw │ │ cachePutCollection() │ 59 - │ (rare) │ │ │ 60 - └───────────────────┘ └──────────────────────────┘ 61 - ``` 40 + Paginated `listRecords` is O(1-3) requests per collection (cursor-driven), so fetching an entire collection is cheaper than N `getRecord` calls. Mutations invalidate the affected collections so stale records aren't re-used after a change. 62 41 63 - **Why not just fetch per-directory?** AT Protocol's `listRecords` is paginated (1-3 requests for an entire collection). Fetching per-directory means N individual `getRecord` calls — one per document. For 50 documents, that's 50 requests vs 2-3. PDS rate limits make the N+1 pattern impractical as the primary fetch strategy. 42 + ### SSE-Driven Refresh 64 43 65 - **Why not just fetch all documents upfront?** The user shouldn't wait for all documents to load before seeing the directory tree. The tree (directories + grants) is small and loads fast. Documents are the heavy part — caching them in the background lets the UI show the tree instantly while records trickle into the cache. 66 - 67 - **The steady state:** After the first background warm, all subsequent directory navigations are pure cache reads. Zero PDS requests. The background warm runs on each `loadCabinet` call (page load, post-mutation refresh) to keep the cache fresh. 68 - 69 - **Cache misses are rare.** They only happen when a document was created between the last `listDocumentsRaw` and the user navigating to its directory. In that case, `ensureDirectoryReady` falls back to a single `getRecordRaw` call for the missing record. 44 + Live updates come from the indexer over SSE (see `FLOWS.md`). TreeKeeper applies the patches directly in memory; the IDB cache is updated opportunistically during the next cache write. There is no timer-based refresh — the consumer maintains freshness through the event stream, and a reconnect triggers a full re-sync. 70 45 71 46 ### Invalidation 72 47 73 - Mutations invalidate affected caches so stale data isn't shown on the next load: 48 + Mutations invalidate affected caches so stale data isn't shown on the next cold start: 74 49 75 50 | Mutation | Invalidation | 76 51 | ----------------------------- | -------------------------------------------------------------------------- | ··· 78 53 | Delete folder | `cacheInvalidateCollection` (documents + directories) | 79 54 | Update metadata | `cacheRemoveRecord` (document) | 80 55 | Rename directory | `cacheInvalidateCollection` (directories) | 81 - | Upload / create folder / move | Triggers `loadCabinet` which re-warms the cache | 82 - 83 - ### Future: Daemon Warming 84 - 85 - The background warm in `loadCabinet` is the in-process version of daemon warming. A future service worker or background process would do the same thing — call `listDocumentsRaw` periodically and write to the cache via `cachePutCollection`. The `ensureDirectoryReady` UI path doesn't change. 56 + | Upload / create folder / move | Invalidates the touched collections | 86 57 87 58 ## File Permissions 88 59
+11 -3
docs/flows/pairing.md
··· 9 9 participant User 10 10 participant CLI as CLI (new device) 11 11 participant Crypto 12 + participant Storage 12 13 participant PDS 13 14 14 15 User->>CLI: opake pair request ··· 20 21 21 22 CLI->>PDS: createRecord(pairRequest)<br/>{ ephemeralKey, algo: "x25519" } 22 23 PDS-->>CLI: { uri, cid } 24 + 25 + CLI->>Storage: save_pair_state(did, rkey, private_key) 23 26 24 27 CLI->>User: Fingerprint: a1:b2:c3:d4:e5:f6:g7:h8 25 28 CLI->>User: Run `opake pair approve` on existing device ··· 33 36 Note over CLI: Response found — see "Receive" below 34 37 ``` 35 38 36 - The ephemeral private key stays in memory. The fingerprint (first 8 bytes of the public key, hex-encoded) is displayed for out-of-band verification. 39 + The ephemeral private key is persisted to `Storage` under `(did, rkey)` so it survives CLI restarts or a browser reload while the user walks to the other device — in-memory only isn't sufficient. On the web the bytes are written straight into IndexedDB through WASM's storage adapter and never become a JS `Uint8Array`. The fingerprint (first 8 bytes of the public key, hex-encoded) is displayed for out-of-band verification. 37 40 38 41 ## Pair Approve (existing device) 39 42 ··· 79 82 sequenceDiagram 80 83 participant CLI as CLI (new device) 81 84 participant Crypto 85 + participant Storage 82 86 participant PDS 83 87 84 88 Note over CLI: Poll found a matching pairResponse 85 89 90 + CLI->>Storage: load_pair_state(did, rkey) 91 + Storage-->>CLI: ephemeral_private_key 92 + 86 93 CLI->>Crypto: unwrap_key(wrappedKey, ephemeral_private_key) 87 94 Crypto-->>CLI: K (content key) 88 95 ··· 96 103 97 104 CLI->>CLI: Verify identity's public key == published key 98 105 99 - CLI->>CLI: Save identity.json (0600) 106 + CLI->>Storage: save_identity(did, identity) 107 + CLI->>Storage: delete_pair_state(did, rkey) 100 108 101 109 CLI->>PDS: deleteRecord(pairRequest) 102 110 CLI->>PDS: deleteRecord(pairResponse) ··· 104 112 CLI->>CLI: Pairing complete 105 113 ``` 106 114 107 - The verification step guards against a corrupted or tampered response — the derived public key must match what's already published on the PDS. 115 + The verification step guards against a corrupted or tampered response — the derived public key must match what's already published on the PDS. Teardown is best-effort: the Identity is saved *before* the pair state and PDS record deletions, so a partial failure still leaves the user paired. Orphan records get swept by the daemon's `cleanup_expired_pair_requests`. 108 116 109 117 ## Login Detection 110 118
+49 -8
docs/indexer.md
··· 7 7 8 8 # Indexer: API & Deployment 9 9 10 - The Indexer indexes five `app.opake.*` collections from the AT Protocol firehose — `grant`, `keyring`, `document` (keyring-encrypted only), `documentUpdate`, and `keyringLeave` — and serves them via a REST API. It enables the `inbox` command ("what's been shared with me?") and workspace queries without scanning every PDS in the network. 10 + The Indexer ingests the eight `app.opake.*` collections from the AT Protocol firehose and serves them via a REST API plus a Server-Sent Events stream. It backs the `inbox` query ("what's been shared with me?"), workspace document / directory discovery, and the live update pipeline that keeps web and CLI clients in sync without polling. 11 11 12 12 Built with Elixir/Phoenix. Source lives in `apps/indexer/`. 13 13 ··· 52 52 |-------|-----|---------| 53 53 | `cursor` | `id` (singleton) | Jetstream cursor position | 54 54 | `grants` | `uri` | Indexed sharing grants | 55 - | `keyring_members` | `(keyring_uri, member_did)` | Denormalized keyring membership with role | 56 - | `workspace_documents` | `document_uri` | Documents encrypted under a keyring | 55 + | `keyrings` | `uri` | Keyring records (owner, metadata) | 56 + | `keyring_members` | `(keyring_uri, member_did)` | Denormalized membership with role | 57 + | `keyring_updates` | `uri` | Membership proposals (add/remove member) | 58 + | `documents` | `uri` | Keyring-encrypted documents (join target for workspace ops) | 57 59 | `document_updates` | `uri` | Pending collaborative edit proposals | 60 + | `directories` | `uri` | Workspace directory records | 61 + | `directory_updates` | `uri` | Pending structural proposals (add/move/rename/delete entry) | 58 62 59 63 ## Configuration 60 64 ··· 186 190 187 191 ### `GET /api/workspace/updates?document=<uri>&limit=<n>&cursor=<cursor>` 188 192 189 - Returns pending document updates. If `document` is provided, returns updates for that specific document. If omitted, returns all pending updates targeting documents owned by the authenticated DID (joined on workspace_documents ownership). 193 + Returns pending document updates. If `document` is provided, returns updates for that specific document. If omitted, returns all pending updates targeting documents owned by the authenticated DID (joined on `documents` ownership). 190 194 191 195 | Param | Required | Default | Max | 192 196 |-------|----------|---------|-----| ··· 208 212 } 209 213 ``` 210 214 215 + ### `GET /api/workspace/directory-updates?keyring=<uri>&limit=<n>&cursor=<cursor>` 216 + 217 + Returns pending structural proposals for a workspace (add/move/rename/delete entry). Requires the authenticated DID to be a member of the keyring. 218 + 219 + ### Tree snapshots and sync 220 + 221 + `/api/cabinet/snapshot` and `/api/workspace/snapshot?keyring=<uri>` return the full directory tree + document list for cold starts. `/api/cabinet/sync?since=<iso8601>` and `/api/workspace/sync?keyring=<uri>&since=<iso8601>` return only records whose `indexed_at` is newer than `since`, for incremental catch-up after a reconnect. Both sync endpoints reply with `{ directories, documents, serverTime }`; the caller uses `serverTime` as the next `since` value. 222 + 223 + ### SSE event streaming 224 + 225 + Two endpoints work together to push indexed events to authenticated consumers in real time. 226 + 227 + `POST /api/events/token` (Ed25519-authenticated) returns a short-lived single-use token: 228 + 229 + ```json 230 + { "token": "<opaque>", "ttl": 60 } 231 + ``` 232 + 233 + `GET /api/events?token=<opaque>` upgrades to a chunked text/event-stream response. The consumer subscribes to: 234 + - their personal topic (keyring memberships affecting them, grants addressed to them, their own proposals) 235 + - every workspace keyring they are currently a member of (re-computed dynamically as `keyring:upsert` events flow through) 236 + 237 + Events are formatted as: 238 + 239 + ``` 240 + event: <type> 241 + data: <json payload> 242 + 243 + ``` 244 + 245 + Types include `keyring:upsert` / `keyring:delete`, `grant:upsert` / `grant:delete`, `directory:upsert` / `directory:delete`, `document:upsert` / `document:delete`, and the matching `*:proposal` events for the update collections. A keepalive comment is emitted every 15 seconds to survive proxy idle timeouts. 246 + 247 + Each DID is capped at a small number of concurrent SSE connections (tracked in ETS); additional connections return `429`. Token exchange is one-shot — the consumer must POST again after losing the connection. 248 + 211 249 ## Firehose Collections 212 250 213 251 | Collection | Events | Effect | 214 252 |------------|--------|--------| 215 - | `app.opake.grant` | create/update/delete | Index/remove grants in `grants` table | 216 - | `app.opake.keyring` | create/update/delete | Upsert/delete keyring members (with roles) in `keyring_members` | 217 - | `app.opake.document` | create/update/delete | If `keyringEncryption`, index in `workspace_documents`. Direct-encrypted documents are ignored. | 253 + | `app.opake.grant` | create/update/delete | Index/remove in `grants` | 254 + | `app.opake.keyring` | create/update/delete | Upsert `keyrings` row; replace `keyring_members` (with roles) | 255 + | `app.opake.keyringUpdate` | create/update/delete | Index/remove in `keyring_updates` (add/remove-member proposals) | 256 + | `app.opake.document` | create/update/delete | Keyring-encrypted documents → `documents`. Direct-encrypted documents are ignored by the indexer. | 218 257 | `app.opake.documentUpdate` | create/update/delete | Index/remove in `document_updates` | 219 - | `app.opake.keyringLeave` | create | Remove the authoring member from `keyring_members` for the referenced keyring | 258 + | `app.opake.directory` | create/update/delete | Upsert/delete in `directories` | 259 + | `app.opake.directoryUpdate` | create/update/delete | Index/remove in `directory_updates` (add/move/rename/delete entry proposals) | 260 + | `app.opake.accountConfig` | create/update/delete | Parsed as a proof-of-life heartbeat; not persisted | 220 261 221 262 ## Rate Limiting 222 263
+13 -11
lexicons/EXAMPLES.md
··· 333 333 ``` 334 334 335 335 **How the new device decrypts:** 336 - 1. Unwraps the content key using the ephemeral private key (held in memory) 337 - 2. Decrypts the ciphertext with the content key + nonce → identity JSON 338 - 3. Verifies the derived public key matches the published `publicKey/self` record 339 - 4. Saves the identity to disk 336 + 1. Loads the ephemeral private key from local `Storage` (keyed by DID + request rkey) 337 + 2. Unwraps the content key using that private key 338 + 3. Decrypts the ciphertext with the content key + nonce → identity JSON 339 + 4. Verifies the derived public key matches the published `publicKey/self` record 340 + 5. Saves the identity to disk and wipes the pair state entry 340 341 341 - Both records are deleted after successful transfer. The ephemeral keypair is never persisted — it exists only in memory during the pairing session. 342 + Both PDS records are deleted after successful transfer. The ephemeral private key is persisted in `Storage` only between `create_pair_request` and `try_complete_pair` — it has to survive a CLI restart or browser reload while the user walks to the other device, so in-memory alone isn't sufficient. It never crosses the WASM/JS boundary. 342 343 343 344 344 345 ## 8. Pending share (recipient hasn't set up Opake yet) ··· 420 421 421 422 ## 10. Leaving a workspace 422 423 423 - A member opts out of a workspace by writing a `keyringLeave` record to their own PDS. The Indexer stops listing them as a member. 424 + A member opts out of a workspace by writing a `keyringUpdate` record with action `leave` to their own PDS. The indexer removes them from the workspace's member list. 424 425 425 426 ```json 426 427 { 427 - "$type": "app.opake.keyringLeave", 428 + "$type": "app.opake.keyringUpdate", 428 429 "opakeVersion": 1, 429 430 "keyring": "at://did:plc:alice123/app.opake.keyring/3k...", 431 + "actionType": "leave", 430 432 "createdAt": "2026-03-21T11:00:00.000Z" 431 433 } 432 434 ``` 433 435 434 436 **Key points:** 435 - - This is a visibility opt-out, not a key revocation — the member's wrapped key still exists on the keyring record 436 - - The workspace disappears from the member's sidebar 437 - - The owner can follow up with a proper removal (key rotation) to revoke future access 438 - - Used for both voluntary leave and cleaning up stale/forked workspace membership 437 + - `leave` is one of the action types on the unified `keyringUpdate` record (alongside `addMember`, `removeMember`, `updateRole`, `rename`, `updateDescription`). 438 + - This is a visibility opt-out, not a key revocation — the member's wrapped key still exists on the keyring record until the owner processes the proposal and rotates the group key. 439 + - The workspace disappears from the member's sidebar once the indexer processes the record. 440 + - Used for both voluntary leave and cleaning up stale/forked workspace membership. 439 441 440 442 ## Design Decisions & Notes 441 443
+5 -3
lexicons/README.md
··· 2 2 NOTE TO EDITORS: 3 3 Opake uses a dual-documentation system. If you modify the AT Protocol 4 4 schemas or lexicon definitions in this file, you MUST also update the 5 - corresponding MDX content in `web/src/content/` to prevent documentation drift. 5 + corresponding MDX content in `apps/web/src/content/` to prevent documentation drift. 6 6 --> 7 7 8 8 # app.opake.* Lexicon Schemas ··· 27 27 | `app.opake.document` | record | An encrypted file/document with metadata | 28 28 | `app.opake.publicKey` | record | Singleton X25519 encryption public key (rkey: `self`) for key discovery | 29 29 | `app.opake.keyring` | record | A named group (workspace) with a shared symmetric key, wrapped to each member with a role | 30 - | `app.opake.keyringLeave` | record | Opt-out record — member signals they're leaving a workspace | 31 30 | `app.opake.grant` | record | A share grant — gives a DID access to a specific document's key | 32 31 | `app.opake.documentUpdate` | record | A proposed update to another member's document — content, metadata, or adoption | 33 32 | `app.opake.directoryUpdate` | record | A proposed structural change to a workspace directory (placement, move, create, rename, delete) | ··· 142 141 participant MemberPDS as Member's PDS 143 142 participant Indexer 144 143 145 - Member->>MemberPDS: createRecord(keyringLeave, { keyring }) 144 + Member->>MemberPDS: createRecord(keyringUpdate, { keyring, actionType: "leave" }) 146 145 MemberPDS->>Indexer: firehose event 147 146 Indexer->>Indexer: remove member from workspace index 148 147 Note right of Indexer: Workspace disappears from<br/>member's sidebar ··· 161 160 Note over DevB,PDS: 1. New device creates pair request 162 161 DevB->>DevB: Generate ephemeral X25519 keypair 163 162 DevB->>PDS: createRecord(pairRequest, { ephemeralKey }) 163 + DevB->>DevB: Persist private key to local Storage (keyed by DID+rkey) 164 164 DevB->>DevB: Display key fingerprint 165 165 DevB->>DevB: Poll for pairResponse... 166 166 ··· 179 179 Note over DevB,PDS: 4. New device receives identity 180 180 DevB->>PDS: listRecords(pairResponse) 181 181 PDS-->>DevB: Matching response 182 + DevB->>DevB: Load ephemeral private key from Storage 182 183 DevB->>DevB: Unwrap K with ephemeral private key 183 184 DevB->>DevB: Decrypt identity JSON 184 185 DevB->>PDS: getRecord(publicKey/self) 185 186 DevB->>DevB: Verify public key matches published key 186 187 DevB->>DevB: Save identity.json 188 + DevB->>DevB: Wipe pair state from Storage 187 189 188 190 Note over DevB,PDS: 5. Cleanup 189 191 DevB->>PDS: deleteRecord(pairRequest)