/** * The durable cap/inbox registers — this library's stand-in for the compartments the * verifier maintains on a repo's own branches. * * Upstream these are not RDF at all: they are streams of service commits on branches * whose CRDT is `BranchCrdt::None` (`engine/repo/src/types.rs:1420`). Each register here * names its native counterpart: * * - **Store branch** — `AddRepo { read_cap }` (`engine/repo/src/types.rs:1890-1899`): * the cap of a document you CREATED, filed beside the store that holds it. Replaying * it is what reloads a store's documents with their keys (`AddRepo::verify` -> * `Verifier::load_repo_from_read_cap`, `engine/verifier/src/verifier.rs:2237`). * - **User branch, links** — `AddLink { read_cap }` (`types.rs:1939-1948`), *"so that a * user can share with all its device a new Link they received"*, external repos only. * - **User branch, inbox caps** — `AddInboxCap { repo_id, overlay, priv_key }` * (`types.rs:1969-1981`): which inboxes you may READ. Keyed by `repo_id`, hence valid * for ANY repo — `update_inbox_cap_v0` applies it with no `is_store` check * (`engine/verifier/src/verifier.rs:1920`). * - **Header branch** — a document's deposit ADDRESS, readable by any holder of it. * The one register with NO native counterpart: upstream an address is TRANSMITTED * (a message, a profile QR code), never published, and `inboxes: PubKey -> RepoId` is * a per-session local table (`verifier.rs:105`). Publishing is our divergence, taken * because an emulation has no message channel — see * `docs/briefs/2026-08-03-document-inbox-addressing.md`. * * Why separate from the shim next door: these emulate the VERIFIER's bookkeeping and * survive conceptually — at migration the native side keeps them, only our RDF * representation goes. `shared-wallet/account-registry.ts` has no counterpart at all and * evaporates. One file until 2026-08-03, two fates. * * The imports back into `shared-wallet/` are deliberate, visible cross-fate edges: a * register needs the shim to know WHOSE it is, and where its store document lives. Every * use sits inside a function body, so the module cycle is inert at evaluation time. */ import { sparqlQuery } from "../surface/docs"; import { registerUpdate } from "./register-write"; import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap"; import { escapeLiteral } from "../surface/sparql"; import { hasReadCap, isNuri } from "../model/nuri"; import { mustNotAttempt } from "./reach"; import { fetchReadCap } from "./public-store"; import { ensureRepoOpen } from "./open-repo"; import { accessLogPrefix } from "../shared-wallet/access-log"; import { P, USER_BRANCH_SUBJECT, STORE_BRANCH_SUBJECT, HEADER_BRANCH_SUBJECT, accountKey, session, readBindings, bindingValue, resolveAccount, storeOf, readUserStore, userInbox, createDoc, ensureAccount, recordInbox, type VirtualUserRecord, } from "../shared-wallet/account-registry"; import type { InboxScope, Nuri, ReadCap, Scope } from "../model/types"; /** * Does `nuri` belong to the CURRENT wallet as one of its inboxes? The predicate the * inbox read guard consults (`inbox.ts`). Anonymous holds no inbox, so it is false * for everyone until an identity is set. */ export async function isOwnInbox(nuri: Nuri): Promise { const holder = getCurrentUser(); if (holder === null) return false; // Either of the user's two inboxes counts as its own. for (const scope of ["public", "protected"] as const) { if ((await userInbox(holder, scope)) === nuri) return true; } // …and the inbox of any document this user opened one on (the emulated // `AddInboxCap` records on its User branch). return (await readInboxCapPairs()).some((p) => p.inbox === nuri); } // --- the cap side of a user's store ---------------------------------- /** * File the caps of documents the CURRENT holder owns into what they hold — the * emulated `AddRepo { read_cap }`. * * Upstream, creating a document commits an `AddRepo { read_cap }` into a typed * branch of the store, and that branch — listing the store's documents, each with * its read key — carries the owner's caps. Here the per-(account × scope) index * document plays the store-container role, so it carries the caps too: a * document appended to it on creation, or read back from it on a later session, * puts its cap in the owner's hands with nothing for the consumer to do. That is * what makes the invariant hold both ways — you never derive a cap from a bare * reference, and yet a document's own creator is never locked out of it. * * Scoped to the current holder ON PURPOSE: another account's documents are listed * by the cross-account fan-out (`listEntityDocs`), and those caps are emphatically * not ours to hold. `id` is compared through the shim key, so it matches however * the consumer spells the identity. */ export function holdOwnCap(id: string, scope: Scope, doc: Nuri, cap: ReadCap): void { const holder = getCurrentUser(); if (holder === null || accountKey(holder) !== accountKey(id)) return; const caps = getCaps(); // `learn(cap)`, not `open(doc, scope)` — the cap must be the SAME value that was // written to the Store branch, not a second one minted from the NURI. They agree // today only because the stand-in value is a constant; with a real key (P1b) a // second mint would produce a DIFFERENT key and the document would be unreadable // by the very session that created it. Mint once, store it, hold that one. caps.learn(cap); // Which store the document sits in is a registry fact, applied separately — and a // MARK only, for the same reason the cap above is learned rather than re-minted. if (scope === "public") caps.markInPublicStore(doc); } /** * File the caps of the documents a virtual user owns BY BEING one: its three * stores, and its inbox. They are as much its documents as any entity it creates, * and without them it cannot even list its own content — the boundary would lock a * user out of itself. * * Scoped to the current holder, like {@link holdOwnCap}: another user's stores are * emphatically not ours to hold. */ export function fileOwnStructure(id: string, record: VirtualUserRecord): void { const holder = getCurrentUser(); if (holder === null || accountKey(holder) !== accountKey(id)) return; const caps = getCaps(); if (record.docPublic) caps.open(record.docPublic, "public"); if (record.docProtected) caps.open(record.docProtected, "protected"); if (record.docPrivate) caps.open(record.docPrivate, "private"); } /** Same, for the user's own inbox — it is its document, and it must be able to * read it. Depositing into someone else's needs no cap (see `register-write.depositInto`). */ export function fileOwnInbox(id: string, inbox: Nuri): void { const holder = getCurrentUser(); if (holder === null || accountKey(holder) !== accountKey(id)) return; getCaps().open(inbox, "private"); } // --- per-entity documents + per-scope index ------------------------------- /** * Publish WHERE to deposit for `doc`, on its Header branch — the compartment any * holder of the document can read. * * Replacement, not addition: a document has exactly ONE inbox upstream (the verifier's * `inboxes: PubKey → RepoId` is a function, and `repo.inbox` a single `Option`), * so two addresses on one document is a state the model has no meaning for — and a * depositor picking the stale one writes where nobody reads. */ export async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise { const s = await session(); try { // Two separate updates, not one compound statement: `DELETE WHERE { … }` is the // form verified against the real broker (see // `docs/decisions/sparql-delete-for-orm-objects.md`), whereas a `;`-joined update // is not exercised anywhere in this lib. await registerUpdate( s.sessionId, `DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`, doc, "publishInboxAddress:clear", ); await registerUpdate( s.sessionId, `INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> "${escapeLiteral(inbox)}" }`, doc, "publishInboxAddress", ); } catch (error) { console.error(accessLogPrefix() + " publishInboxAddress failed:", error); } } /** * The ReadCaps recorded on a store's Store branch — its documents, each with its * key. The emulated replay of `AddRepo`, and the reason a fresh session recovers * what it owns without recomputing anything. */ export async function readStoreCaps(storeDoc: Nuri): Promise { const s = await session(); const out: ReadCap[] = []; try { const res = await sparqlQuery( s.sessionId, `SELECT ?c WHERE { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> ?c }`, undefined, storeDoc, "readStoreCaps", ); for (const row of readBindings(res)) { const v = bindingValue(row, "c"); if (v && hasReadCap(v)) out.push(v); } } catch (error) { console.error(accessLogPrefix() + " readStoreCaps failed:", error); } return out; } /** * WHERE to deposit for `doc` — its inbox address, or `undefined` if its owner never * opened one. The deposit-side counterpart of {@link openDocumentInbox}, and the * function an app calls before `inbox.post`. * * Readable by whoever can read the document, because it lives on its Header branch — * an address is public by nature (upstream a depositor needs only the inbox PUBLIC * key). Conversely someone who cannot read the document learns nothing, which is * faithful too: upstream the inbox pubkey is not derivable from a RepoId, it has to * reach you. * * **Never creates.** Asking where to deposit must not bring an inbox into existence — * only its owner opens one, and only on its own document. */ export async function documentInboxAddress(doc: Nuri): Promise { // RULE 2 — do not even attempt. Not holding the document, we have no address to // find: upstream the inbox pubkey travels WITH what you can read, so "where do I // deposit for a document I cannot read" is not a refused question, it is a question // with no referent. Answering `undefined` here keeps the caller's shape (an address // or none) instead of turning the boundary into an exception it must catch. // …but ask the (emulated) network first: a document in a public store serves its cap // to whoever asks (public-store.ts), and "where do I deposit for this public // document" is exactly the question a third party arrives with, holding nothing but // the reference. await fetchReadCap(doc); if (mustNotAttempt(doc)) return undefined; const s = await session(); try { const res = await sparqlQuery( s.sessionId, `SELECT ?a WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`, undefined, doc, "documentInboxAddress", ); for (const row of readBindings(res)) { const a = bindingValue(row, "a"); if (a && isNuri(a)) return a; } } catch (error) { // Unreadable document (no cap) or not synced → no address to give. Refusing to // read is the boundary doing its job, not an error to propagate here. console.error(accessLogPrefix() + " documentInboxAddress failed:", error); } return undefined; } /** * Does the connected user own `doc`? Answered from its **Store branches** — the * register of the documents it created — across the three scopes, which is the only * place that records authorship. Holding a cap is NOT ownership: a cap can be * received, and a recipient must not be able to open an inbox on what it merely reads. */ export async function ownsDocument(doc: Nuri): Promise { const holder = getCurrentUser(); if (holder === null) return false; const record = await resolveAccount(holder); if (record === null) return false; for (const scope of ["public", "protected", "private"] as const) { const store = storeOf(record, scope); if (!store) continue; if ((await readUserStore(store)).includes(doc)) return true; } return false; } /** The `(document, inbox)` pairs recorded on this user's User branch. */ /** * Encode the `(document, inbox)` pair of an emulated `AddInboxCap` record. * * Upstream this is a TYPED structure — `AddInboxCapV0 { repo_id, overlay, priv_key }` * (`engine/repo/src/types.rs:1973`) — carried by a service commit, not a string. Ours is * one RDF literal because our User branch is a subject in a document, so the pairing has * to live inside a value. That is the emulation's shape, and it is what migration * replaces: the fields become fields again. * * The separator is a space, which is safe for a reason worth stating rather than * assuming: a NURI is `did:ng:` followed by base64url and `:`-separated segments * (`NuriV0`, `engine/net/src/app_protocol.rs`), an alphabet that contains no space. The * assertion below turns that from an implicit property into a checked one — a silently * mis-split pair would file an inbox under a truncated document and lose deposits with * no error, which is exactly the failure class this whole path already paid for once. */ function encodeInboxCap(doc: Nuri, inbox: Nuri): string { if (doc.includes(" ") || inbox.includes(" ")) { throw new Error( "[ng-eventually] branch-registers: a NURI containing a space cannot be paired in " + `an inbox-cap record — the separator would be ambiguous: ${JSON.stringify([doc, inbox])}`, ); } return `${doc} ${inbox}`; } export async function readInboxCapPairs(): Promise> { const holder = getCurrentUser(); if (holder === null) return []; const record = await resolveAccount(holder); const store = record?.docPrivate; if (!store) return []; const s = await session(); const out: Array<{ doc: Nuri; inbox: Nuri }> = []; try { const res = await sparqlQuery( s.sessionId, `SELECT ?c WHERE { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> ?c }`, undefined, store, "readInboxCaps", ); for (const row of readBindings(res)) { // See `encodeInboxCap` for why a space is a safe separator here, and why this // pairing exists at all. const [doc, inbox] = bindingValue(row, "c").split(" "); if (doc && inbox && isNuri(doc) && isNuri(inbox)) out.push({ doc, inbox }); } } catch (error) { console.error(accessLogPrefix() + " readInboxCaps failed:", error); } return out; } /** The inbox recorded for one document, if this user opened one. */ export async function readInboxCapsFor(doc: Nuri): Promise { return (await readInboxCapPairs()).find((p) => p.doc === doc)?.inbox; } /** * Every inbox this user may READ: its own, plus one per document it opened an * inbox on. What `connect.connectedUser` drains, and what `isOwnInbox` answers from. */ export async function myInboxes(): Promise { const holder = getCurrentUser(); if (holder === null) return []; const out: Nuri[] = []; // BOTH of the user's inboxes — public and protected — since upstream a site carries // one on each of those two store repos (`engine/verifier/src/site.rs:127-152`). if ((await resolveAccount(holder)) !== null) { for (const scope of ["public", "protected"] as const) out.push(await userInbox(holder, scope)); } for (const { inbox } of await readInboxCapPairs()) out.push(inbox); return out; } /** * File a cap received for someone ELSE's document — the emulated * `AddLink { read_cap }` on the User branch of the current user's private store. * * This is what makes a received cap DURABLE. Before it, a shared document survived * only by re-reading the inbox every session, which uses a queue as a database: * upstream an inbox is consumed, and processing a message *applies* it. Applying a * Link means writing it here. * * Idempotent — re-applying the same Link is a no-op, so re-processing an inbox * (a second tab, a reconnect) costs nothing. */ export async function addLink(cap: ReadCap): Promise { const holder = getCurrentUser(); if (holder === null) return; const record = await ensureAccount(holder); const store = record.docPrivate; if (!store) return; if ((await readLinks()).includes(cap)) return; const s = await session(); try { await registerUpdate( s.sessionId, `INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.link}> "${escapeLiteral(cap)}" }`, store, "addLink", ); } catch (error) { console.error(accessLogPrefix() + " addLink failed:", error); } } /** * The caps this user has received and applied — the User branch read back. Called * at connection to restore what was shared with them, without touching any inbox. */ export async function readLinks(): Promise { const holder = getCurrentUser(); if (holder === null) return []; const record = await ensureAccount(holder); const store = record.docPrivate; if (!store) return []; const s = await session(); const out: ReadCap[] = []; await ensureRepoOpen(store); try { const res = await sparqlQuery( s.sessionId, `SELECT ?c WHERE { <${USER_BRANCH_SUBJECT}> <${P.link}> ?c }`, undefined, store, "readLinks", ); for (const row of readBindings(res)) { const v = bindingValue(row, "c"); if (v && hasReadCap(v)) out.push(v); } } catch (error) { console.error(accessLogPrefix() + " readLinks failed:", error); } return out; } /** * The inbox of a document this user owns — resolved, and created on first ask. * * Upstream a repo carries `inbox: Option` (`engine/repo/src/repo.rs:126`): * an inbox is a keypair on the repo, whose PRIVATE half its owner holds. That half is * recorded with `AddInboxCap { repo_id, overlay, priv_key }` — *"into the user branch, * so that a user can share with all its device"* (`engine/repo/src/types.rs:1973`), the * same branch that carries `AddLink`. So "which inboxes may I read" is answered by the * User branch, and that is what this emulates. * * **The engine SUPPORTS this; nothing exercises it automatically.** Those are two * different statements, and conflating them is what made an earlier version of this * comment call the feature an "anticipation". It is not. `inbox: Option` is a * field of EVERY `Repo` (`engine/repo/src/repo.rs:126`), not of a store structure; * `AddInboxCapV0` is keyed by `repo_id` (`engine/repo/src/types.rs:1973`); and * `update_inbox_cap_v0` applies it with `self.repos.get_mut(repo_id)` and **no * `is_store` check of any kind** (`engine/verifier/src/verifier.rs:1920`). Generic by * construction, and at any time (see the User-branch note above). * * What is true is narrower: no code path CREATES one for a document — `new_store_default` * attaches one only `if !private` (`verifier.rs:2994`), `doc_create` leaves `inbox: None` * (`repo.rs:574`), and the only two `AddInboxCap` commits in the engine are for the * public and protected STORE repos (`engine/verifier/src/site.rs:128,149`). So the * capability exists and is simply unexposed above level 1: this function is aligned on * the engine's model, it does not bet past it. * * Lazy on purpose, for the same reason: creating an inbox document for every entity up * front would double every `createEntityDoc` for inboxes most documents never receive * anything in. Upstream the keypair is cheap; here an inbox is a document, so it is * minted when first asked for. * * *(Not covered: ROTATING an inbox key — the engine's "update" case with a new * `priv_key`. This function is idempotent and returns the existing inbox instead. A * known limit, not an oversight.)* * * Only for a document this user OWNS — see {@link ownsDocument}. Opening an inbox on * someone else's document would be usurpation, not a courtesy: the opener keeps the * reading half, so it would silently divert to itself the deposits meant for the * owner. To deposit into someone else's document, resolve * {@link documentInboxAddress} and `inbox.post` into it. */ export async function openDocumentInbox(doc: Nuri): Promise { const holder = getCurrentUser(); if (holder === null) throw new Error("[ng-eventually] openDocumentInbox: no identity is set"); const known = (await readInboxCapsFor(doc)) ?? null; if (known) return known; // OWNERSHIP is the criterion, and holding a cap is NOT ownership — a cap can be // received. Opening an inbox is what PUBLISHES this document's address, so a // non-owner doing it would route the owner's deposits to itself, silently, on a // document it merely reads. // // **This guard compensates OUR design, not an upstream constraint** — an earlier // comment here claimed "upstream only the owner can commit `AddInboxCap`", which is // false: that commit lands on the committer's OWN User branch, so anyone may write // one naming anyone's repo. What protects upstream is that an inbox address is never // PUBLISHED — it is TRANSMITTED (in a `ContactDetails` message, or a profile QR // code), and `inboxes: PubKey → RepoId` is a per-verifier local table // (`engine/verifier/src/verifier.rs:105`, rebuilt empty each session). A forged pair // reaches nobody, because nobody was told about it. // // We publish instead of transmitting — the only way a third party can find the // address at all here — which creates a vector upstream does not have: whoever can // write the document can redirect its deposits. Hence this guard. It is a real // divergence, deliberately taken; see `docs/briefs/2026-08-03-document-inbox-addressing.md`. if (!(await ownsDocument(doc))) { throw new Error( "[ng-eventually] openDocumentInbox: refused — you may only open an inbox on a document " + `you own. Deposit into its published address instead (storeRegistry.documentInboxAddress ` + `then inbox.post): ${JSON.stringify(doc)}`, ); } const inbox = await createDoc(); const s = await session(); const record = await ensureAccount(holder); const store = record.docPrivate; getCaps().open(inbox, "private"); // its owner holds it, like any document of theirs // …and the shim records that it IS an inbox, so a depositor can find that out without // holding anything of it. See `recordInbox`: upstream a deposit cannot address a plain // document at all, and this is what stands in for that impossibility. await recordInbox(inbox); if (store) { try { await registerUpdate( s.sessionId, `INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(encodeInboxCap(doc, inbox))}" }`, store, "openDocumentInbox", ); } catch (error) { console.error(accessLogPrefix() + " openDocumentInbox persist failed:", error); } } // …and the PUBLIC half, in the document itself, so a depositor can find it at all. // Without this the inbox is reachable only by its owner — the opposite of what an // inbox is for, and the bug this path shipped with. await publishInboxAddress(doc, inbox); return inbox; }