diff --git a/packages/client/src/emulated-verifier/branch-registers.ts b/packages/client/src/emulated-verifier/branch-registers.ts index c111858..de641dc 100644 --- a/packages/client/src/emulated-verifier/branch-registers.ts +++ b/packages/client/src/emulated-verifier/branch-registers.ts @@ -53,6 +53,7 @@ import { storeOf, readUserStore, userInbox, + createDoc, ensureAccount, type VirtualUserRecord, } from "../shared-wallet/account-registry"; @@ -255,6 +256,32 @@ export async function ownsDocument(doc: Nuri): Promise { } /** 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 []; @@ -272,6 +299,8 @@ export async function readInboxCapPairs(): Promise { 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 + if (store) { + try { + await sparqlUpdate( + 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; +} + diff --git a/packages/client/src/shared-wallet/account-registry.ts b/packages/client/src/shared-wallet/account-registry.ts index 4429c9f..9dd1c2f 100644 --- a/packages/client/src/shared-wallet/account-registry.ts +++ b/packages/client/src/shared-wallet/account-registry.ts @@ -257,7 +257,20 @@ export interface RegistrySession { } function normalize(id: string): string { - return getStoreRegistryDeps().normalizeId(id); + const key = getStoreRegistryDeps().normalizeId(id); + // The reserved namespace's whole guarantee is that no user id can land in it, and + // that guarantee is NOT ours to make: `normalizeId` is injected by the consumer + // application, and the library's own default only trims — nothing stops a caller + // from passing an id that already starts with the sentinel. A collision here is not + // a cosmetic clash: a user would key onto an infrastructure account and read or + // write documents that are not theirs. So it is checked rather than assumed. + if (isReserved(key)) { + throw new Error( + "[ng-eventually] account-registry: `normalizeId` produced a key inside the " + + `reserved namespace, which no user id may occupy: ${JSON.stringify(key)}`, + ); + } + return key; } export async function session(): Promise { @@ -477,7 +490,7 @@ async function writePointer(doc: Nuri): Promise { } /** Create one graph document in the shared wallet's private store (→ a NURI). */ -async function createDoc(): Promise { +export async function createDoc(): Promise { const s = await session(); // crdt="Graph" (RDF/SPARQL/ORM), class="data:graph", destination="store", // store_repo=undefined → shared wallet's private store. @@ -988,103 +1001,6 @@ export async function userStoreDoc(id: string, scope: Scope): Promise { * another account's unsynced docs. This is the helper a consumer application uses * for its own my-entities path, instead of the all-accounts `listEntityDocs`. */ -/** - * 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 - if (store) { - try { - await sparqlUpdate( - s.sessionId, - `INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(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; -} - export async function listMyEntityDocs(id: string, scope: Scope): Promise { const record = await ensureAccount(id); const store = storeOf(record, scope); diff --git a/packages/client/src/surface/placement.ts b/packages/client/src/surface/placement.ts index 989ba3c..ea59dce 100644 --- a/packages/client/src/surface/placement.ts +++ b/packages/client/src/surface/placement.ts @@ -31,7 +31,6 @@ export { /** A user's own inbox — where caps and messages addressed to THEM arrive. */ userInbox, /** Open an inbox on a document you OWN, so others can deposit into it. */ - openDocumentInbox, /** WHERE to deposit for a document — readable by any holder of it. `undefined` if none. */ } from "../shared-wallet/account-registry"; -export { documentInboxAddress } from "../emulated-verifier/branch-registers"; +export { documentInboxAddress, openDocumentInbox } from "../emulated-verifier/branch-registers"; diff --git a/packages/client/test/cross-user-access.test.ts b/packages/client/test/cross-user-access.test.ts index 72f8a5f..150a05d 100644 --- a/packages/client/test/cross-user-access.test.ts +++ b/packages/client/test/cross-user-access.test.ts @@ -20,11 +20,10 @@ import { test, expect, mock, afterAll } from "bun:test"; import { createEntityDoc, - openDocumentInbox, resetRegistryCache, userInbox, } from "../src/shared-wallet/account-registry"; -import { documentInboxAddress } from "../src/emulated-verifier/branch-registers"; +import { documentInboxAddress, openDocumentInbox } from "../src/emulated-verifier/branch-registers"; import type { RegistrySession } from "../src/shared-wallet/account-registry"; import { configure, diff --git a/packages/client/test/vocabulary.test.ts b/packages/client/test/vocabulary.test.ts index 3196e3d..f54332f 100644 Binary files a/packages/client/test/vocabulary.test.ts and b/packages/client/test/vocabulary.test.ts differ