diff --git a/docs/briefs/2026-07-30-virtual-wallet-boundary.md b/docs/briefs/2026-07-30-virtual-wallet-boundary.md index db3272c..ae92e9e 100644 --- a/docs/briefs/2026-07-30-virtual-wallet-boundary.md +++ b/docs/briefs/2026-07-30-virtual-wallet-boundary.md @@ -186,7 +186,9 @@ So the store-root pointer, the doc-shim and the account records go through the m 7. ~~**Per-document inboxes**~~ — **DONE 2026-08-03.** Upstream a repo carries `inbox: Option` (`engine/repo/src/repo.rs:126`): an inbox is a keypair on the document whose PRIVATE half the owner holds, recorded with `AddInboxCap { repo_id, overlay, priv_key }` on the **User branch** — the same branch as `AddLink`, and with the same stated purpose (*"so that a user can share with all its device"*). So "which inboxes may I read" has exactly one answer, and it is the one place to look. - `storeRegistry.documentInbox(doc)` resolves — creating on first ask — the inbox of a document this user owns, recording the pair on its User branch. **Lazy**: minting an inbox document for every entity up front would double every `createEntityDoc` for inboxes most documents never receive anything in. `myInboxes()` enumerates both levels, `isOwnInbox` answers from the same record, and `connect.connectedUser` drains them all in one call. + *(Renamed and split on 2026-08-03 — `documentInbox` became `openDocumentInbox` (own the inbox) + `documentInboxAddress` (find where to deposit). Conflating the two made per-document inboxes unusable by anyone but their owner; see [`2026-08-03-document-inbox-addressing.md`](2026-08-03-document-inbox-addressing.md).)* + + `storeRegistry.openDocumentInbox(doc)` resolves — creating on first ask — the inbox of a document this user owns, recording the pair on its User branch. **Lazy**: minting an inbox document for every entity up front would double every `createEntityDoc` for inboxes most documents never receive anything in. `myInboxes()` enumerates both levels, `isOwnInbox` answers from the same record, and `connect.connectedUser` drains them all in one call. The asymmetry holds at both levels, and a test walks it: **anyone deposits** into a document's inbox (that is how a third party reaches its owner at all), **only the owner reads** it. diff --git a/docs/briefs/2026-08-03-document-inbox-addressing.md b/docs/briefs/2026-08-03-document-inbox-addressing.md index f401aaf..bcae1e3 100644 --- a/docs/briefs/2026-08-03-document-inbox-addressing.md +++ b/docs/briefs/2026-08-03-document-inbox-addressing.md @@ -2,6 +2,20 @@ **Raised 2026-08-03, from the consumer side (Festipod), after an attempt to solve it in the app proved it does not belong there.** +> ## IMPLEMENTED 2026-08-03 — shape 2 (the library publishes), with the cost objection taken as binding +> +> `storeRegistry.documentInboxAddress(doc)` answers *"where do I deposit for this document"* for **any holder**, and `inbox.post` into it. The address is published **at creation**, in a compartment the library owns — so it never enters a consumer shape. +> +> **On the cost.** The measured regression (9m37 → 21m30) came from creating a second **DOCUMENT** per document. The published address points at the owner's **own inbox**, which already exists and is amortized over every document that owner creates: creation grows by triples, not by a document. The deposit carries the document it concerns, so the owner still materializes per document. Shape 1 (derivation) was **not available**: our inbox is a document, and a derived NURI would name a repo `doc_create` never created — upstream can derive because an inbox there is a keypair on the repo, not a document. +> +> **Why at creation and not at first open.** Publishing the day the owner opens a dedicated inbox leaves a window where a third party reads the document, finds no address, and cannot reach the owner at all — which is exactly the consumer's central act (signing up to someone else's document, before that owner ever touched an inbox). `openDocumentInbox(doc)` remains, for an owner who wants one document's deposits kept apart; it **replaces** the published address rather than adding to it. +> +> **Where the address lives.** On the document's emulated **Header branch** (`urn:ng-eventually:shim:headerBranch`), beside the content rather than in it — the same subject-as-compartment shape already used for the Store and User branches. `read-model` now drops every subject under the reserved `urn:ng-eventually:` namespace (`src/machinery.ts`), so the address cannot surface as one of the entity's properties. That filter is by SUBJECT, so it covers every emulated compartment present and future. +> +> **The open question, answered.** *"Is 'a document has an inbox addressable by any holder' upstream, or this library's invention?"* — **the library's**, and it is now written down as such. Verified: no document has an inbox upstream, and neither does the private store; the only two `AddInboxCap` commits in the engine are for the public and protected STORE repos (`engine/verifier/src/site.rs:128,149`), `new_store_default` attaches one only `if !private` (`engine/verifier/src/verifier.rs:2994`), and `doc_create` leaves `inbox: None` (`engine/repo/src/repo.rs:574`). What IS upstream is the shape that makes this a defensible anticipation rather than a fiction: `AddInboxCapV0` is keyed by `repo_id` (`engine/repo/src/types.rs:1973`), so the record accommodates an inbox on any repo. The half-split is upstream's too — a depositor seals with the inbox PUBLIC key (`engine/net/src/types.rs:4299`) and only the owner holds the private half — which is why an address is public by nature and belongs on the document, not on the owner's User branch. +> +> **Also fixed, and it was the root of the reported symptom.** `openDocumentInbox` (formerly `documentInbox`) called by a non-owner used to mint a parallel inbox and record it for the caller — no error, deposits lost. It now refuses, on OWNERSHIP (read from the Store branches), not on cap possession: a cap can be received, and a recipient must not be able to redirect the owner's deposits to itself. + ## The problem, in one sentence `documentInbox(doc)` answers *"which inbox do **I** know for this document?"* — and mints a fresh one when the answer is none. So a third party never reaches the owner's inbox: they get one of their own, which the owner never reads, and their deposit vanishes without an error. diff --git a/docs/simulation.md b/docs/simulation.md index 8561c1a..aa451ad 100644 --- a/docs/simulation.md +++ b/docs/simulation.md @@ -200,16 +200,33 @@ store-id: blocker, [`migration-guide.md`](./migration-guide.md)). At migration each scope resolves to the user's real per-scope store — the change is in this function, and the consumer application is unchanged. -- **`walletInbox(id)` / `documentInbox(doc)`** — an inbox BELONGS to someone. The - first is a virtual user's own inbox (where Links arrive), the second the inbox of - one of its documents, created on first ask. Both are dedicated documents (real - repo NURIs from `docCreate`), never the private-store root: routing deposits into - the shim graph would bloat the account→document trust root without bound. - `myInboxes()` enumerates both levels — what `connect.ts` drains at connection — - and `isOwnInbox` answers from the same record. *(The former `resolveInboxAnchor`, - a single inbox COMMON to every user, was removed on 2026-07-30: nothing may be - common but the mechanisms that make the virtual users work.)* At migration these - become native per-document inboxes. +- **`walletInbox(id)` / `openDocumentInbox(doc)`** — an inbox BELONGS to someone. The + first is a user's own inbox (where Links arrive), the second a DEDICATED inbox for + one of its documents, opened on demand by its **owner only** (ownership read from the + Store branches — a received cap is not ownership, and a recipient must not be able to + redirect the owner's deposits to itself). Both are dedicated documents (real repo + NURIs from `docCreate`), never the private-store root: routing deposits into the shim + graph would bloat the account→document trust root without bound. `myInboxes()` + enumerates both levels — what `connect.ts` drains at connection — and `isOwnInbox` + answers from the same record. *(The former `resolveInboxAnchor`, a single inbox COMMON + to every user, was removed on 2026-07-30: nothing may be common but the mechanisms + that make the virtual users work.)* +- **`documentInboxAddress(doc)` — the DEPOSIT side, and the one a third party uses.** + Reading an inbox and finding where to deposit into it are opposite acts with opposite + audiences, and conflating them is what made per-document inboxes unusable at first: + resolution answered *"which inbox do I know for this document"*, so a depositor got + one of their own and their deposit vanished silently + ([`briefs/2026-08-03-document-inbox-addressing.md`](./briefs/2026-08-03-document-inbox-addressing.md)). + Now every document carries its address **from creation**, on its emulated **Header + branch** — a reserved subject inside the document, so any holder of the document + reads it, and `read-model` filters the whole `urn:ng-eventually:` namespace out of + consumer data (`src/machinery.ts`). It points at the owner's own inbox by default — + one inbox per user, amortized, NOT one document per document — and + `openDocumentInbox` replaces it when an owner wants a document's deposits kept apart. + This mirrors upstream's split: a depositor seals with the inbox PUBLIC key and needs + nothing else, only the owner holds the private half. At migration the address becomes + the repo's native inbox pubkey and the resolution moves; the consumer-facing act + (resolve, then `inbox.post`) is unchanged. Both resolve the native store ids from the injected session (`RegistrySession.protectedStoreId` / `publicStoreId`, alongside the existing diff --git a/packages/client/docs/sdk-reference.md b/packages/client/docs/sdk-reference.md index 4958807..716bc25 100644 --- a/packages/client/docs/sdk-reference.md +++ b/packages/client/docs/sdk-reference.md @@ -239,6 +239,19 @@ Consequences a consumer must internalize: are this library's, not the engine's: upstream only the public and protected store repos carry one (`engine/verifier/src/site.rs:128,149`).* + Depositing into a document you do not own is two calls, and the first is the one that + makes it possible at all: + + ```ts + const where = await storeRegistry.documentInboxAddress(doc); // Nuri | undefined + if (where) await inbox.post(where, { payload: { signingUp: true } }); + ``` + + You need the **document** (its cap), nothing else — the address rides on it and is + published from creation. `undefined` means you cannot read the document, not that the + owner is unreachable. Reading that inbox is a different right, and it stays the + owner's (`inbox.read` refuses otherwise). + The consumer asks the SDK for what it needs and trusts the result; it does not construct NURIs, pick union-vs-anchor, or reason about caps. The domain-shaped list helpers live in the consumer app; the SDK exposes the generic reactive/by-need read. diff --git a/packages/client/e2e/run.ts b/packages/client/e2e/run.ts index f645ac7..d3613b1 100644 --- a/packages/client/e2e/run.ts +++ b/packages/client/e2e/run.ts @@ -248,6 +248,18 @@ async function main(): Promise { check("watch fires when a deposit lands", after.fires > base.fires && after.lastLen >= 1, `fires=${after.fires} lastLen=${after.lastLen}`); await sdk(frame, "inboxWatchStop"); }); + await step("a document's inbox: owner opens, a third party resolves and deposits", async () => { + const t = Date.now(); + const r = await sdk(frame, "documentInboxDeposit", "@owner-" + t, "@depositor-" + t); + check( + "the depositor RESOLVES the same inbox from the document, deposits into it, and the address stays out of the data", + r.sameInbox === true && + r.openRefused === true && + JSON.stringify(r.deposits) === JSON.stringify([{ joining: true }]) && + !r.props.some((p: string) => p.startsWith("urn:ng-eventually:")), + `sameInbox=${r.sameInbox} openRefused=${r.openRefused} deposits=${JSON.stringify(r.deposits)} props=${JSON.stringify(r.props)}`, + ); + }); await step("inbox spoof guard", async () => { const r = await sdk(frame, "inboxSpoofGuard"); check("post as another principal is rejected; self + anon allowed", r.spoofRejected && r.selfOk && r.anonOk, `spoof=${r.spoofRejected} self=${r.selfOk} anon=${r.anonOk}`); diff --git a/packages/client/e2e/sdk-entry.ts b/packages/client/e2e/sdk-entry.ts index 0e3dde0..27122ca 100644 --- a/packages/client/e2e/sdk-entry.ts +++ b/packages/client/e2e/sdk-entry.ts @@ -802,6 +802,48 @@ const identity = new IdentityStore( * "receive" operation exists, and no principal is ever named to the registry. * Runs against the REAL broker inbox document, so it exercises the whole path. */ + /** + * The DEPOSIT side of a document's inbox, end to end against the real broker: the + * owner opens it, a third party RESOLVES its address from the document itself and + * deposits, the owner reads it back. + * + * The point of the step is the resolution: nothing hands `depositorId` the address. + * It gets the document's link (which is what circulates in this model) and must find + * where to deposit on its own — which is exactly what a consumer app has to do, and + * what a unit test passing the NURI through a variable cannot prove. + */ + async documentInboxDeposit(ownerId: string, depositorId: string) { + storeRegistry.resetRegistryCache(); + setCurrentUser(ownerId); + const doc = await storeRegistry.createEntityDoc(ownerId, "public"); + const ownerInbox = await storeRegistry.openDocumentInbox(doc); + const link = capFor(doc)!; // the repo link the owner circulates + + setCurrentUser(depositorId); + getCaps().learn(link); + const resolved = await storeRegistry.documentInboxAddress(doc); + // Opening one on someone else's document must be refused, not silently forked. + let openRefused = false; + try { + await storeRegistry.openDocumentInbox(doc); + } catch { + openRefused = true; + } + if (resolved) await inbox.post(resolved, { payload: { joining: true }, ts: 1000 }); + + setCurrentUser(ownerId); + const deposits = await inbox.read(ownerInbox); + // The address is machinery: it must not surface among the document's properties. + const subjects = await readModel.readUnion([doc]); + const props = Object.keys(subjects[0]?.props ?? {}); + setCurrentUser(null); + return { + sameInbox: resolved === ownerInbox, + openRefused, + deposits: deposits.map((d) => d.payload), + props, + }; + }, async capsShareCap(friendId: string) { const s = await sessionReady; resetCaps(); diff --git a/packages/client/src/machinery.ts b/packages/client/src/machinery.ts new file mode 100644 index 0000000..0bd600c --- /dev/null +++ b/packages/client/src/machinery.ts @@ -0,0 +1,42 @@ +/** + * The namespace this library reserves for its OWN triples, and the one predicate a + * read path needs about it: *is this subject machinery, or is it the consumer's data?* + * + * ── Why this exists ──────────────────────────────────────────────────────── + * The polyfill has no branches, so it emulates each of a repo's compartments with a + * distinct SUBJECT inside a document (`shim:index` for the store's Main branch, + * `shim:storeBranch`, `shim:userBranch`, `shim:headerBranch` — see `store-registry.ts`). + * That was invisible as long as those subjects only ever appeared in documents the + * consumer never reads through the data path — store documents and the doc-shim. + * + * The Header branch broke that: it lives in an ENTITY document, the one the consumer + * reads with `SELECT ?s ?p ?o`. Without a filter, the address of a document's inbox + * would surface as one of that entity's properties — machinery leaking into domain + * data. Filtering by SUBJECT rather than by predicate is what makes this hold for + * every compartment, present and future: a new emulated branch needs no new filter. + * + * Upstream this problem does not exist, because there the separation is real — a + * branch is a different CRDT with its own topic, not a subject in the same graph. This + * module is the seam where our emulation pays for that. + */ + +/** + * The URN namespace every triple this library writes for itself lives under — + * `urn:ng-eventually:shim:…` (store-registry's compartments) and + * `urn:ng-eventually:inbox:…` (inbox deposits). + * + * A consumer that writes its own data under this prefix would have it filtered out of + * its reads. That is a deliberate reservation, not a hazard to guard against: the + * namespace names this library. + */ +export const MACHINERY_NS = "urn:ng-eventually:"; + +/** + * Is `subject` one of this library's own, rather than consumer data? + * + * Tolerant of `undefined` so a read path can hand it a possibly-absent binding + * without a preliminary check — an absent subject is not machinery. + */ +export function isMachinerySubject(subject: string | undefined): boolean { + return subject !== undefined && subject.startsWith(MACHINERY_NS); +} diff --git a/packages/client/src/read-model.ts b/packages/client/src/read-model.ts index 1dc30fd..99c9aeb 100644 --- a/packages/client/src/read-model.ts +++ b/packages/client/src/read-model.ts @@ -46,6 +46,7 @@ import { getCaps, getStoreRegistryDeps } from "./polyfill"; import { mustNotAttempt } from "./reach"; import { ensureReposOpen } from "./open-repo"; import { assertNuri } from "./sparql"; +import { isMachinerySubject } from "./machinery"; import type { Nuri } from "./types"; // Keep the primitives referenced so tree-shaking never drops the import used by @@ -172,6 +173,11 @@ export async function readUnion(docs: Nuri[]): Promise { // (writeEntity invariant). Pin subject/graph to the doc NURI (the anchor), which // is stable regardless of the repo_graph_name overlay suffix the store carries. for (const row of rows) { + // The polyfill's own compartments live as reserved SUBJECTS inside the very + // documents the consumer reads (the Header branch carrying a document's inbox + // address is the first). They are machinery, not this entity's properties — + // drop them here, once, for every compartment present and future. + if (isMachinerySubject(row.s?.value)) continue; const p = row.p?.value; const o = row.o?.value; if (!p || o === undefined) continue; diff --git a/packages/client/src/store-registry.ts b/packages/client/src/store-registry.ts index 66b6dd6..efdc910 100644 --- a/packages/client/src/store-registry.ts +++ b/packages/client/src/store-registry.ts @@ -67,6 +67,7 @@ import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill"; import { ensureRepoOpen, ensurePhysicalRepoOpen } from "./open-repo"; import { escapeLiteral, escapeIri, assertNuri } from "./sparql"; import { hasReadCap, isNuri, mintCap } from "./nuri"; +import { mustNotAttempt } from "./reach"; import { accessLogPrefix, logStage, shortNuri } from "./access-log"; import type { Nuri, ReadCap, Scope } from "./types"; @@ -105,6 +106,7 @@ const P = { link: `${SHIM}:link`, // user branch → a ReadCap received for an EXTERNAL document readCap: `${SHIM}:readCap`, // store branch → the ReadCap of a document IN this store inboxCap: `${SHIM}:inboxCap`, // user branch → an inbox this user may READ + inboxAddress: `${SHIM}:inboxAddress`, // header branch → WHERE to deposit for this document } as const; // Fixed subject of the per-(account×scope) index document. The index doc plays // the role of the future store-container: it lists the NURIs of the entity @@ -142,6 +144,34 @@ const USER_BRANCH_SUBJECT = `${SHIM}:userBranch`; * keys stay separate. */ const STORE_BRANCH_SUBJECT = `${SHIM}:storeBranch`; +/** + * Fixed subject of the **Header branch** emulation, inside an ENTITY document — the + * first compartment we put in a document the consumer also reads, hence the filter in + * `read-model.ts` (every `${SHIM}:` subject is machinery and never surfaces as data). + * + * It carries what must be readable by *whoever can read the document*, as opposed to + * what belongs to its owner alone. Today that is one thing: the ADDRESS of the + * document's inbox. + * + * Why the address must live here and not on the owner's User branch. Upstream an inbox + * is a KEYPAIR (`repo.inbox: Option`, `engine/repo/src/repo.rs:126`) and the + * two halves have opposite audiences: a depositor seals with the PUBLIC key + * (`InboxMsg::new` → `crypto_box::seal(&to_inbox.to_dh_slice(), …)`, + * `engine/net/src/types.rs:4299`) and needs nothing else; only the owner holds the + * private half (`AddInboxCap`, on the User branch). An address is therefore public by + * nature — upstream it travels with the profile (`ContactDetails` carries + * `ng:site_inbox` / `ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:823`). + * Keeping it only on the owner's User branch, as this lib first did, made the deposit + * side unreachable: a third party had no way to learn where to deposit. + * + * **Not `BranchType::Header` upstream.** That branch exists (`engine/repo/src/types.rs:1551`) + * but is CLOSED: `update_header` writes only `title`/`about` + * (`engine/verifier/src/request_processor.rs:173-211`) and `fetch_header` reads back + * only `title`/`about`/`class` (`:1240-1284`). It cannot carry an inbox address. The + * name is borrowed for the shape — a compartment of the document that is not its + * content — not for the upstream branch's contract. + */ +const HEADER_BRANCH_SUBJECT = `${SHIM}:headerBranch`; // --- pointer (store-root → doc-shim indirection) -------------------------- // @@ -927,9 +957,59 @@ export async function createEntityDoc(id: string, scope: Scope): Promise { } // …and the creator holds THAT cap for this session. holdOwnCap(id, scope, entityNuri, cap); + // The THIRD write: WHERE to deposit for this document, on its Header branch, so any + // holder can find it. Done at creation because the alternative — publishing it the + // day the owner opens a dedicated inbox — leaves a window in which a third party + // reads the document, finds no address, and cannot reach its owner at all. That + // window is precisely the consumer's central act (signing up to someone else's + // document), so it cannot be left open. + // + // It points at the owner's OWN inbox, which already exists: one inbox per user, + // amortized over every document they create — NOT one document per document. That + // distinction is the whole cost question (see + // `docs/briefs/2026-08-03-document-inbox-addressing.md`: publishing at creation was + // measured at 9m37 → 21m30 on the consumer's suite, because it created a second + // DOCUMENT each time). Here creation grows by one triple, and the deposit carries + // the document it concerns, so the owner still materializes per document. + // + // `openDocumentInbox` later REPLACES this address with a dedicated inbox for owners + // who want one document's deposits kept apart; the resolution is the same either way. + await publishInboxAddress(entityNuri, await walletInbox(id)); return entityNuri; } +/** + * Publish WHERE to deposit for `doc`, on its Header branch — the compartment any + * holder of the document can read. Idempotent by replacement: a document has exactly + * one address, and re-publishing (when a dedicated inbox is opened) must not leave the + * previous one behind for a depositor to pick. + */ +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. Deleting first is what makes this a + // replacement — a document has ONE address, and a stale one left beside the new + // one is a depositor writing where nobody reads. + await sparqlUpdate( + s.sessionId, + `DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`, + doc, + "publishInboxAddress:clear", + ); + await sparqlUpdate( + 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 @@ -1038,15 +1118,31 @@ export async function userStoreDoc(id: string, scope: Scope): Promise { * in. Upstream the keypair is cheap; here an inbox is a document, so it is minted * when first asked for. * - * Only for documents this user holds — you cannot open an inbox on someone else's - * document, you can only deposit into it. + * 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 documentInbox(doc: Nuri): Promise { +export async function openDocumentInbox(doc: Nuri): Promise { const holder = getCurrentUser(); - if (holder === null) throw new Error("[ng-eventually] documentInbox: no identity is set"); + 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 — not "is there an address yet", since every document + // carries one from creation (its owner's inbox). Opening a dedicated inbox REPLACES + // that address, so letting a non-owner do it would redirect the owner's deposits to + // the caller: usurpation, silent, and on a document the caller merely reads. + // Holding a cap is not ownership; a cap can be received. + 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); @@ -1058,15 +1154,80 @@ export async function documentInbox(doc: Nuri): Promise { s.sessionId, `INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(doc + " " + inbox)}" }`, store, - "documentInbox", + "openDocumentInbox", ); } catch (error) { - console.error(accessLogPrefix() + " documentInbox persist failed:", error); + console.error(accessLogPrefix() + " openDocumentInbox persist failed:", error); } } + // …and the PUBLIC half, in the document itself, so a depositor finds THIS inbox + // instead of the owner's general one that `createEntityDoc` published. Replacement, + // not addition: one document, one address. + await publishInboxAddress(doc, inbox); return inbox; } +/** + * 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. + 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. + */ +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. */ async function readInboxCapPairs(): Promise> { const holder = getCurrentUser(); diff --git a/packages/client/test/cross-user-access.test.ts b/packages/client/test/cross-user-access.test.ts index 7ec129c..db27dfc 100644 --- a/packages/client/test/cross-user-access.test.ts +++ b/packages/client/test/cross-user-access.test.ts @@ -18,7 +18,13 @@ * no authorization list anywhere, and nobody was named to the registry. */ import { test, expect, mock, afterAll } from "bun:test"; -import { createEntityDoc, documentInbox, resetRegistryCache, walletInbox } from "../src/store-registry"; +import { + createEntityDoc, + documentInboxAddress, + openDocumentInbox, + resetRegistryCache, + walletInbox, +} from "../src/store-registry"; import type { RegistrySession } from "../src/store-registry"; import { configure, @@ -76,6 +82,19 @@ function makeFakeNg() { const query = a[1] as string; const anchor = a[2] as string | undefined; if (!anchor) return undefined; + // `DELETE WHERE {

?var }` — the form the lib uses to REPLACE a value + // (see docs/decisions/sparql-delete-for-orm-objects.md). Without this arm the + // fake would treat the delete as an insert and the replacement would silently + // become an accumulation — the exact bug a replacement exists to prevent. + const del = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+<([^>]+)>\s+\?/); + if (del) { + const [s0, p0] = [del[1]!, del[2]!]; + for (let i = quads.length - 1; i >= 0; i--) { + const q = quads[i]!; + if (q.g === anchor && q.s === s0 && q.p === p0) quads.splice(i, 1); + } + return undefined; + } const body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, ""); const sm = body.match(/<([^>]+)>/); if (!sm) return undefined; @@ -149,6 +168,10 @@ function makeFakeNg() { if (query.includes(`<${SHIM}:inboxCap>`)) { return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxCap`).map((q) => ({ c: { value: q.o } })) } }; } + // Header-branch `inboxAddress` SELECT (where to deposit for this document). + if (query.includes(`<${SHIM}:inboxAddress>`)) { + return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxAddress`).map((q) => ({ a: { value: q.o } })) } }; + } // Store-branch `readCap` SELECT (the emulated AddRepo records). if (query.includes(`<${SHIM}:readCap>`)) { return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:readCap`).map((q) => ({ c: { value: q.o } })) } }; @@ -383,28 +406,96 @@ test("a document has its own inbox: anyone deposits, only the owner reads", asyn inject(); setCurrentUser("alice"); const doc = await createEntityDoc("alice", "public"); - const docInbox = await documentInbox(doc); - expect(docInbox).not.toBe(await walletInbox("alice")); + const aliceInbox = await openDocumentInbox(doc); + expect(aliceInbox).not.toBe(await walletInbox("alice")); + const link = capFor(doc)!; // the repo link alice circulates — links DO travel - // Bob deposits into the document's inbox — the cross-user act, open to all. + // Bob RESOLVES the address himself, from the document. The only thing he is handed + // is the link, which is the one thing the model says circulates. The address is not + // passed to him — if it had to be, there would be no way for an app to get it. setCurrentUser("bob"); - await post(docInbox, { payload: { joining: true }, ts: 1 }); + getCaps().learn(link); + const bobTarget = await documentInboxAddress(doc); + expect(bobTarget).toBe(aliceInbox); // …and it is the SAME inbox alice reads + await post(bobTarget!, { payload: { joining: true }, ts: 1 }); - // …and cannot read it back: depositing grants nothing. - await expect(readInbox(docInbox)).rejects.toThrow(/does not belong to the connected wallet/i); + // …and he cannot read it back: depositing grants nothing. + await expect(readInbox(bobTarget!)).rejects.toThrow(/does not belong to the connected wallet/i); // Alice reads her document's inbox, because she opened it. setCurrentUser("alice"); - const deposits = await readInbox(docInbox); + const deposits = await readInbox(aliceInbox); expect(deposits.map((d) => d.payload)).toEqual([{ joining: true }]); }); +test("opening an inbox on someone else's document is refused, not silently forked", async () => { + inject(); + setCurrentUser("alice"); + const doc = await createEntityDoc("alice", "public"); + const aliceInbox = await openDocumentInbox(doc); + + const link = capFor(doc)!; + + // Bob holds the document — that is a READ right, and it is not ownership. + setCurrentUser("bob"); + getCaps().learn(link); + await expect(openDocumentInbox(doc)).rejects.toThrow(/already has an inbox|you may only open an inbox/i); + // The address he resolves is still alice's, so his deposits reach her. + expect(await documentInboxAddress(doc)).toBe(aliceInbox); +}); + +test("a document is addressable from creation — its owner's inbox, no second document", async () => { + inject(); + setCurrentUser("alice"); + const aliceInbox = await walletInbox("alice"); + const doc = await createEntityDoc("alice", "public"); + const link = capFor(doc)!; + + // No `openDocumentInbox` anywhere: a depositor must not have to wait for the owner + // to open one. This is the window the consumer's central act falls into — signing up + // to someone else's document, before that owner ever touched an inbox. + setCurrentUser("bob"); + getCaps().learn(link); + expect(await documentInboxAddress(doc)).toBe(aliceInbox); +}); + +test("opening a dedicated inbox REPLACES the published address — it never accumulates", async () => { + inject(); + setCurrentUser("alice"); + const aliceInbox = await walletInbox("alice"); + const doc = await createEntityDoc("alice", "public"); + expect(await documentInboxAddress(doc)).toBe(aliceInbox); // the general one, first + const dedicated = await openDocumentInbox(doc); + expect(dedicated).not.toBe(aliceInbox); + + // A depositor resolving now must reach the DEDICATED one, and only it: a stale + // address left beside the new one is a deposit written where nobody reads. + const link = capFor(doc)!; + setCurrentUser("bob"); + getCaps().learn(link); + expect(await documentInboxAddress(doc)).toBe(dedicated); +}); + +test("the inbox address is machinery: it never surfaces as the document's data", async () => { + inject(); + setCurrentUser("alice"); + const doc = await createEntityDoc("alice", "public"); + await write(doc, SECRET, "s1"); + await openDocumentInbox(doc); + + // The consumer read returns the entity's properties and nothing of the compartment + // that carries the address — the Header branch is beside the content, not in it. + const subjects = await readUnion([doc]); + const props = subjects[0]?.props ?? {}; + expect(Object.keys(props)).toEqual([SECRET]); +}); + test("connecting drains BOTH levels: the user's inbox and its documents'", async () => { inject(); setCurrentUser("alice"); const protDoc = await createEntityDoc("alice", "protected"); const pubDoc = await createEntityDoc("alice", "public"); - const docInbox = await documentInbox(pubDoc); + const docInbox = await openDocumentInbox(pubDoc); const aliceInbox = await walletInbox("alice"); // Two deposits, one at each level, both made by someone else. @@ -422,3 +513,13 @@ test("connecting drains BOTH levels: the user's inbox and its documents'", async const left = await readInbox(docInbox); expect(left.map((d) => d.payload)).toEqual([{ onTheDocument: true }]); // consumer data stays }); + +// The same resolution property one level up: a user's own inbox. +test("a third party resolves another user's inbox (the wallet level)", async () => { + inject(); + setCurrentUser("alice"); + const aliceView = await walletInbox("alice"); + setCurrentUser("bob"); + const bobView = await walletInbox("alice"); + expect(bobView).toBe(aliceView); +}); diff --git a/packages/client/test/machinery.test.ts b/packages/client/test/machinery.test.ts new file mode 100644 index 0000000..b92f77d --- /dev/null +++ b/packages/client/test/machinery.test.ts @@ -0,0 +1,51 @@ +/** + * The reserved-namespace predicate, in isolation. + * + * It is one `startsWith`, but it is the seam that keeps the polyfill's emulated + * branches out of the consumer's data (see `machinery.ts`), so its edges are worth + * pinning: get it wrong in one direction and machinery leaks into domain properties; + * wrong in the other and real data silently disappears from reads. + */ +import { test, expect } from "bun:test"; +import { MACHINERY_NS, isMachinerySubject } from "../src/machinery"; + +test("the emulated branch subjects are all machinery", () => { + // The four compartments store-registry emulates, verbatim. + for (const s of [ + "urn:ng-eventually:shim:index", + "urn:ng-eventually:shim:storeBranch", + "urn:ng-eventually:shim:userBranch", + "urn:ng-eventually:shim:headerBranch", + ]) { + expect(isMachinerySubject(s)).toBe(true); + } +}); + +test("inbox deposits are machinery too — a second prefix under the same namespace", () => { + expect(isMachinerySubject("urn:ng-eventually:inbox:deposit:1700:abc")).toBe(true); +}); + +test("consumer subjects are not machinery — including a NURI, which is what entities use", () => { + expect(isMachinerySubject("did:ng:o:doc1")).toBe(false); + expect(isMachinerySubject("urn:e2e:secret")).toBe(false); + expect(isMachinerySubject("http://example.org/thing")).toBe(false); +}); + +test("a look-alike prefix is NOT machinery — the boundary is exact, not fuzzy", () => { + // Anything that merely resembles the namespace must fall on the data side, or a + // consumer's own vocabulary could vanish from its reads. + expect(isMachinerySubject("urn:ng-eventuallyX:thing")).toBe(false); + expect(isMachinerySubject("urn:ng-event:thing")).toBe(false); + expect(isMachinerySubject("x-urn:ng-eventually:shim:index")).toBe(false); +}); + +test("an absent subject is not machinery — read paths hand bindings straight in", () => { + expect(isMachinerySubject(undefined)).toBe(false); + expect(isMachinerySubject("")).toBe(false); +}); + +test("the namespace is the prefix both writers actually use", () => { + // Guards against the constant drifting away from store-registry/inbox. + expect("urn:ng-eventually:shim".startsWith(MACHINERY_NS)).toBe(true); + expect("urn:ng-eventually:inbox".startsWith(MACHINERY_NS)).toBe(true); +});