From 3257afe8c064fed670f7c759d39b526545571795 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Tue, 4 Aug 2026 16:19:49 +0200 Subject: [PATCH] feat(inbox): un utilisateur a DEUX inboxes, comme en amont MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tranché par la cascade plutôt qu'en attendant une réponse : le niveau 3 (ORM) ne dit rien des inbox, le niveau 2 non plus — `@ng-org/web` n'expose aucune méthode contenant « inbox » et la session n'en publie aucune. C'est donc le modèle du moteur qui décide, et il dit DEUX : un site porte une inbox sur son repo de store public et une autre sur son protégé (`engine/verifier/src/site.rs:127-152`), les seuls `AddInboxCap` du moteur, `new_store_default` n'en posant une que `if !private`. Elles sont adressées séparément jusque dans les enregistrements de contact, qui choisissent leur prédicat selon le profil visé — `ng:site_inbox` pour un profil public, `ng:protected_inbox` sinon (`engine/verifier/src/inbox_processor.rs:787,823-824`). `userInbox(id)` en exposait une : une cardinalité que cette bibliothèque avait inventée, et que le nom `walletInbox` avait contribué à masquer. Elle prend désormais le scope, et le store PRIVÉ n'en a pas — d'où `InboxScope` plutôt que `Scope` : demander l'inbox privée n'est pas une recherche qui ne rend rien, c'est une question sans référent dans le modèle, et le type l'interdit. `myInboxes` énumère les deux, `isOwnInbox` reconnaît les deux. Le shim garde un triple par (user, scope). 160 tests unitaires, typecheck src/test/e2e vert, e2e 40/40 contre le broker. --- docs/api-contract.md | 8 ++-- .../2026-07-30-virtual-wallet-boundary.md | 2 +- docs/internal-contract.md | 4 +- docs/migration-guide.md | 2 +- docs/read-model.md | 4 +- docs/simulation.md | 4 +- packages/client/docs/sdk-reference.md | 4 +- packages/client/e2e/sdk-entry.ts | 6 +-- .../src/emulated-verifier/branch-registers.ts | 13 +++++-- packages/client/src/model/types.ts | 11 ++++++ .../src/shared-wallet/account-registry.ts | 39 ++++++++++++++----- .../client/test/cross-user-access.test.ts | 16 ++++---- packages/client/test/inbox.test.ts | 2 +- packages/client/test/isolation-active.test.ts | 8 ++-- packages/client/test/reach.test.ts | 4 +- packages/client/test/store-registry.test.ts | 21 ++++++++-- 16 files changed, 103 insertions(+), 45 deletions(-) diff --git a/docs/api-contract.md b/docs/api-contract.md index f3175da..a5620d6 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -1,5 +1,7 @@ # API contract — what `@ng-eventually/client` exposes today, and what the future SDK should expose per subject +> **Updated 2026-08-03, after the source layout was reorganised by migration fate** (`docs/source-layout-by-fate.md`). Paths, and three names, changed under this document: `readModel` became the directly-exported `readUnion`; `accounts` / `AccountRecord` / `AccountStorage` became `virtualUsers` / `VirtualUserRecord` / `VirtualUserStorage` (module `shared-wallet/virtual-users.ts`); `store-registry-api.ts` became `surface/placement.ts`. Two modules were created and are covered here: `emulated-verifier/branch-registers.ts` (the four durable registers, split out of the shim) and `shared-wallet/bootstrap.ts` (the injection store, split out of the `/polyfill` entry). The subject-by-subject rulings below are unaffected — what moved is where the code lives, not what it promises. + **Scope: the APP-FACING contract only.** Everything reachable from the two published entry points, and nothing else. The library's internal modules — the shim machinery, the read paths, the boundary guards — are held to the same standard (as close as possible to what NextGraph does or plans) but have their own document, `docs/internal-contract.md`: a consumer never reads that one, a maintainer does. This split was made on 2026-08-03, together with the export change described in § 15. **Scope.** The real exported surface of `@ng-eventually/client` (verified against the `export` statements in `packages/client/src/index.ts` and `packages/client/src/polyfill.ts` — `package.json` maps exactly two entry points, `.` and `./polyfill`), and, for each subject, the target signature the future NextGraph JS SDK is expected to expose. Written 2026-08-03, verified against the `nextgraph-rs` clone (HEAD `213338f6`, 2026-05-16) and the installed `@ng-org/web@0.1.2-alpha.13` type declarations (`node_modules/.bun/@ng-org+web@0.1.2-alpha.13/node_modules/@ng-org/web/dist/index.d.ts`, hereafter `index.d.ts`). @@ -285,7 +287,7 @@ declare function sparql_query(session_id: any, sparql: string, base: any, nuri: `depositInto` has **NO COUNTERPART as a SPARQL write**: upstream a deposit is a sealed message, not an update into the recipient's graph (§ 9). It exists only because the emulated inbox is an RDF document. -**Store targeting — a nuance this repo's docs understate.** `docs/nextgraph-current-state.md` and `docs/migration-guide.md` say a public/arbitrary `StoreRepo` "is not JS-constructible". Verified in the clone, the picture is finer: +**Store targeting — finer than "not JS-constructible".** *(The other docs were corrected on 2026-08-03 to match this entry; they used to state the blanket form.)* Verified in the clone: - The **web** wasm variant (`sdk/js/lib-wasm/src/lib.rs:1575`, `#[cfg(not(wasmpack_target = "nodejs"))]`) deserializes its 5th argument as `Option` via serde — so a value CAN be passed, but no JS helper exists to build the serde form, which keeps it out of practical reach. The published `.d.ts` documents this 5-arg form. - The **nodejs** variant (`lib.rs:1618`, 6 args) takes `store_type: Option` + `store_repo: Option` and builds the store via `StoreRepo::from_type_and_repo(store_type, repo_id_str)` with `store_type ∈ "public" | "protected" | "private" | "group"` (`sdk/rust/src/local_broker.rs:2969-2987`, `engine/repo/src/types.rs:819-828`). @@ -594,7 +596,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat ### Places the current surface teaches something to unlearn -- ~~**The SDK entry is not as pure as its header claims.**~~ **FIXED 2026-08-03.** The header claimed the entry "exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose" while also shipping `accounts` and the whole `store-registry` module. Both are gone from it, and the header now states what the entry actually promises: *every symbol here has a target-SDK counterpart, verified or assumed, listed in this document*. It still exports `docs`, `readModel`, `watchShape`, `subscribeDoc(s)`, the SPARQL helpers and the NURI guards — justified inventions, documented per subject above — so the promise is no longer "@ng-org surface only", which was never true, but "nothing here is machinery". +- ~~**The SDK entry is not as pure as its header claims.**~~ **FIXED 2026-08-03.** The header claimed the entry "exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose" while also shipping `virtualUsers` and the whole `store-registry` module. Both are gone from it, and the header now states what the entry actually promises: *every symbol here has a target-SDK counterpart, verified or assumed, listed in this document*. It still exports `docs`, `readUnion`, `watchShape`, `subscribeDoc(s)`, the SPARQL helpers and the NURI guards — justified inventions, documented per subject above — so the promise is no longer "@ng-org surface only", which was never true, but "nothing here is machinery". - **`shareCap` is importable from both entries** (`inbox.shareCap` on the SDK entry via `export * as inbox`, and a named re-export on `/polyfill`). The polyfill re-export exists "so the cap vocabulary stays on the polyfill side" — but the namespace export undoes that. Harmless functionally; blurs the same boundary. - **`inbox.read`/`materialize` as a mailbox** — enumerating raw deposits is emulation detail (§ 9); the durable contract is deposit-and-it-gets-applied. An app building UI on the deposit list should expect that surface to change shape entirely. - **`watchShape`'s "planned `useShape` upgrade"** — stated in the module header with no provenance in this repo or the clone (§ 5). The load-state *distinction* is safe; the claim that NextGraph plans this exact hook shape is an assumption and must not be cited as an announced API. @@ -605,7 +607,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat ## Appendix — full export inventory (for diffing) -`@ng-eventually/client` (from `index.ts`): types `Nuri`, `ReadCap`, `Scope`, `PrincipalId`, `NgLike`, `UseShapeLike`, `ShapeQuery`, `ShapeObservable`, `DocChange`, `DocChangeType`, `Unsubscribe`, `UnionSubject`, `VirtualUserRecord`, `RegistrySession`, `VirtualUserStorage`, `Deposit`, `PostOptions` (via namespaces), re-exported `ShapeType`, `BaseType`, `Schema`, `DeepSignalSet`, `NG`; values `ng`, `useShape`, `watchShape`, `init`, `initNg`, `subscribeDoc`, `subscribeDocs`, `docChangeType`, `escapeLiteral`, `escapeIri`, `assertNuri`, `isNuri`, `hasReadCap`; namespaces `inbox` (`post`, `postToDocument`, `shareCap`, `read`, `materialize`, `readSynced`, `processInbox`, `watch`), `docs` (`docCreate`, `sparqlUpdate`, `sparqlQuery`, `depositInto`), `readModel` (`readUnion`), `storeRegistry` (`reservedAccount`, `resetRegistryCache`, `resolveAccount`, `ensureAccount`, `resolveWriteGraph`, `resolveScopeGraph`, `userInbox`, `isOwnInbox`, `createEntityDoc`, `userStoreDoc`, `openDocumentInbox`, `documentInboxAddress`, `myInboxes`, `addLink`, `readLinks`, `listMyEntityDocs`), `accounts` (`ACCOUNT_STORAGE_KEY`, `IdentityStore`, `browserIdentityStore`). +`@ng-eventually/client` (from `index.ts`): types `Nuri`, `ReadCap`, `Scope`, `PrincipalId`, `NgLike`, `UseShapeLike`, `ShapeQuery`, `ShapeObservable`, `DocChange`, `DocChangeType`, `Unsubscribe`, `UnionSubject`, `VirtualUserRecord`, `RegistrySession`, `VirtualUserStorage`, `Deposit`, `PostOptions` (via namespaces), re-exported `ShapeType`, `BaseType`, `Schema`, `DeepSignalSet`, `NG`; values `ng`, `useShape`, `watchShape`, `init`, `initNg`, `subscribeDoc`, `subscribeDocs`, `docChangeType`, `escapeLiteral`, `escapeIri`, `assertNuri`, `isNuri`, `hasReadCap`; namespaces `inbox` (`post`, `postToDocument`, `shareCap`, `read`, `materialize`, `readSynced`, `processInbox`, `watch`), `docs` (`docCreate`, `sparqlUpdate`, `sparqlQuery`, `depositInto`), `readUnion` (`readUnion`), `storeRegistry` (`reservedAccount`, `resetRegistryCache`, `resolveAccount`, `ensureAccount`, `resolveWriteGraph`, `resolveScopeGraph`, `userInbox`, `isOwnInbox`, `createEntityDoc`, `userStoreDoc`, `openDocumentInbox`, `documentInboxAddress`, `myInboxes`, `addLink`, `readLinks`, `listMyEntityDocs`), `virtualUsers` (`ACCOUNT_STORAGE_KEY`, `IdentityStore`, `browserIdentityStore`). `@ng-eventually/client/polyfill` (from `polyfill.ts`): types `StoreRegistryDeps`, `EventuallyConfig`; values `configure`, `getConfig`, `resetConfig`, `configureStoreRegistry`, `getStoreRegistryDeps`, `resetStoreRegistry`, `setCurrentUser`, `getCurrentUser`, `getCaps`, `capFor`, `resetCaps`, `CapRegistry`, `shareCap`, `connectedUser`. diff --git a/docs/briefs/2026-07-30-virtual-wallet-boundary.md b/docs/briefs/2026-07-30-virtual-wallet-boundary.md index 6a7a6f8..fd63ba8 100644 --- a/docs/briefs/2026-07-30-virtual-wallet-boundary.md +++ b/docs/briefs/2026-07-30-virtual-wallet-boundary.md @@ -69,7 +69,7 @@ Nothing else touches `ng`. (`open-repo` only tests whether `doc_subscribe` exist | `storeRegistry.listEntityDocs` / `resolveReadGraphs` | fan-out over every account | **removed** — cross-wallet enumeration, and its former justification (feeding discovery) is gone too | | `storeRegistry.allAccounts` / `loadShim` | enumerates every virtual user | **removed**, or reduced to the reserved-account resolution that infrastructure needs | | `storeRegistry.ensureAccount(id)` | any id | the current identity, plus the reserved accounts | -| `readModel.readUnion` | ✅ already guarded (P1a) | unchanged | +| `readUnion` | ✅ already guarded (P1a) | unchanged | ## The breach P1a opened, and which this lot must close first diff --git a/docs/internal-contract.md b/docs/internal-contract.md index c6d2244..384aabf 100644 --- a/docs/internal-contract.md +++ b/docs/internal-contract.md @@ -1,5 +1,7 @@ # Internal contract — what `@ng-eventually/client` keeps off its surface, and what NextGraph does or would do about each subject +> **Updated 2026-08-03, after the source layout was reorganised by migration fate** (`docs/source-layout-by-fate.md`). Paths, and three names, changed under this document: `readModel` became the directly-exported `readUnion`; `accounts` / `AccountRecord` / `AccountStorage` became `virtualUsers` / `VirtualUserRecord` / `VirtualUserStorage` (module `shared-wallet/virtual-users.ts`); `store-registry-api.ts` became `surface/placement.ts`. Two modules were created and are covered here: `emulated-verifier/branch-registers.ts` (the four durable registers, split out of the shim) and `shared-wallet/bootstrap.ts` (the injection store, split out of the `/polyfill` entry). The subject-by-subject rulings below are unaffected — what moved is where the code lives, not what it promises. + **Scope.** The complement of [`docs/api-contract.md`](./api-contract.md): every module export under `packages/client/src/` that is NOT reachable from the two published entry points (`package.json` maps exactly `.` → `src/index.ts` and `./polyfill` → `src/polyfill.ts`). A consumer never reads this document; a maintainer does. The internal code is held to the same standard as the surface — as close as possible to what NextGraph does or plans — so every subject below carries the same target-side analysis. Written 2026-08-04, verified against the `nextgraph-rs` clone (HEAD `213338f6`) and the installed `@ng-org/web@0.1.2-alpha.13` declarations (`node_modules/.bun/@ng-org+web@0.1.2-alpha.13/node_modules/@ng-org/web/dist/index.d.ts`, hereafter `index.d.ts`). **How the boundary was computed — mechanically, from the `export` statements.** `index.ts` re-exports wholesale (`export *` / `export * as ns`) from `types.ts`, `inbox.ts`, `docs.ts`, `surface/read-model.ts`, and by name everything `surface/use-shape.ts`, `surface/watch-shape.ts`, `lifecycle.ts`, `sparql.ts` export, plus `isNuri`/`hasReadCap` from `nuri.ts` and `subscribeDoc`/`subscribeDocs`/`docChangeType` (+ types) from `subscribe.ts`; its `storeRegistry` namespace is the **`surface/placement.ts` slice only** (7 functions: `createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `userInbox`, `openDocumentInbox`, `documentInboxAddress`). `polyfill.ts` re-exports `CapRegistry` from `emulated-verifier/caps.ts`, `shareCap` from `inbox.ts`, `connectedUser` from `emulated-verifier/connect.ts`, `* as accounts` from `shared-wallet/virtualUsers.ts`, and the types `VirtualUserStorage`, `VirtualUserRecord`, `RegistrySession`. Everything else that carries `export` in a `src/` module is internal and inventoried here. Eight modules are internal in their entirety: `shared-wallet/access-log.ts`, `emulated-verifier/machinery.ts`, `surface/ng-proxy.ts`, `emulated-verifier/open-repo.ts`, `shared-wallet/outbox-log.ts`, `shared-wallet/physical.ts`, `emulated-verifier/reach.ts`, `emulated-verifier/read-filter.ts`. Four are internal in part: `nuri.ts`, `emulated-verifier/connect.ts`, `subscribe.ts`, `shared-wallet/account-registry.ts`. @@ -247,7 +249,7 @@ export function inspectOutbox(): void; **F3 — incomplete citation in `subscribe.ts`.** `subscribe.ts:31` cites the ORM fan-out abort as "`initialize.rs:125-128`" with no path. The file is `engine/verifier/src/orm/graph/initialize.rs`; lines 125-128 are the graph loop calling `self.open_for_target(&nuri.target, true).await?` — verified, the `?` propagates `RepoNotFound` and aborts the whole subscription. Substance correct; the bare filename is unfindable without this note. -**F4 — `docs/api-contract.md` lags the `surface/placement.ts` split.** Its § 12 and appendix still list `resolveAccount`, `ensureAccount`, `userStoreDoc`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`, `reservedAccount`, `resetRegistryCache` as the SDK entry's `storeRegistry` namespace, and § 13/§ 15 place `virtualUsers.*` on the SDK entry — since the split (`index.ts:34` routes through `surface/placement.ts`; `polyfill.ts:238` carries `accounts`) those are internal or `/polyfill`. That file is being edited concurrently; noted here, deliberately not fixed by this document. +**F4 — `docs/api-contract.md` lags the `surface/placement.ts` split.** Its § 12 and appendix still list `resolveAccount`, `ensureAccount`, `userStoreDoc`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`, `reservedAccount`, `resetRegistryCache` as the SDK entry's `storeRegistry` namespace, and § 13/§ 15 place `virtualUsers.*` on the SDK entry — since the split (`index.ts:34` routes through `surface/placement.ts`; `polyfill.ts:238` carries `virtualUsers`) those are internal or `/polyfill`. That file is being edited concurrently; noted here, deliberately not fixed by this document. **F5 — `reservedAccount`'s collision guarantee is asserted about code the lib does not own.** `store-registry.ts:200-206` states the injected `normalizeId` "strips a leading `@`, trims, and lowercases, so a NUL prefix is unreachable" — that describes ONE consumer's normalizer, not a contract; the lib's own default is `id.trim()` (`polyfill.ts:145`), which passes U+0000 through. The reserved namespace is disjoint only if every consumer's normalizer keeps it so. Either document the requirement on `StoreRegistryDeps.normalizeId`, or reject NUL-prefixed raw ids at `accountKey`. diff --git a/docs/migration-guide.md b/docs/migration-guide.md index db4a2ac..97a06e7 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -48,7 +48,7 @@ private store, and the `public|protected|private` scope is a logical label in the shim (see the two-axes section in [`simulation.md`](./simulation.md)). - `doc_create` cannot target a non-private native store today — verified: - `StoreRepo` is not JS-constructible from the SDK, so there is no way to pass + `StoreRepo` is not constructible from the WEB build of the SDK, so there is no way to pass a public/protected store as the create destination (`docCreate`'s trailing `store` arg is left `undefined` → private store). The private store works only because it opens without `RepoNotFound`. diff --git a/docs/read-model.md b/docs/read-model.md index ec23129..915efc1 100644 --- a/docs/read-model.md +++ b/docs/read-model.md @@ -104,7 +104,7 @@ There is no reactive union query. So reactivity is assembled: - keep a lightweight reactive subscription — `doc_subscribe`, or the ORM on an already-opened single store (never a per-entity fan-out) — on the synced docs; - on its change signal, re-run the bounded set of per-doc anchored - `sparql_query`s (`readModel.readUnion`) — never an anchorless union-scan. + `sparql_query`s (`readUnion`) — never an anchorless union-scan. Keep the reactive ORM strictly to already-opened single stores; it is a change *signal* source here, not the list source. @@ -187,7 +187,7 @@ sync an unknown repo.) ## Implementation — `surface/read-model.ts` -`readModel.readUnion(docs)` implements this: for each requested doc NURI (the +`readUnion(docs)` implements this: for each requested doc NURI (the bounded by-need set), run — in parallel, tolerant per-doc (a doc that fails is skipped, never aborting the batch like the ORM fan-out would) — one anchored `SELECT ?s ?p ?o WHERE { ?s ?p ?o }` with `anchor = docNuri`. The anchor restricts diff --git a/docs/simulation.md b/docs/simulation.md index 8b9d031..388fe96 100644 --- a/docs/simulation.md +++ b/docs/simulation.md @@ -95,7 +95,7 @@ multi-store. Do not read `Scope` (`types.ts`) as a physical store — it is the logical label the registry attaches. > Why `undefined` and not a real store? Because `doc_create` **cannot target a -> non-private native store** today: `StoreRepo` is not JS-constructible (verified +> non-private native store** today from the WEB build: `StoreRepo` is not constructible there (verified > — see the parked `getNativeStore` note in > [`migration-guide.md`](./migration-guide.md)). The private store is reachable > because it opens without `RepoNotFound`. @@ -135,7 +135,7 @@ public/protected/private stores — on top of one shared wallet. user, and the only listing there is: the cross-account fan-out (`listEntityDocs` / `resolveReadGraphs` / `allAccounts` / `loadShim`) was **removed on 2026-07-30**, being cross-user enumeration by construction. The real read path is - `readModel.readUnion(docs)`, which reads the by-need doc set with one per-doc + `readUnion(docs)`, which reads the by-need doc set with one per-doc anchored `sparql_query`, never an anchorless union-scan of the physical wallet (see [`read-model.md`](./read-model.md)). The consumer application resolves the by-need doc set from the current wallet's own scope index and diff --git a/packages/client/docs/sdk-reference.md b/packages/client/docs/sdk-reference.md index 6e12e14..323787d 100644 --- a/packages/client/docs/sdk-reference.md +++ b/packages/client/docs/sdk-reference.md @@ -166,7 +166,7 @@ In `@ng-eventually/client` the one-shot read is exposed as: - **`docs.sparqlQuery(sid, query, base?, anchor?)`** — a raw anchored SPARQL query ([`../src/surface/docs.ts`](../src/surface/docs.ts)). `anchor` = the document NURI to read; the anchor restricts the query to that one repo's graph. -- **`readModel.readUnion(docs)`** — read a **bounded, by-need set** of document NURIs, +- **`readUnion(docs)`** — read a **bounded, by-need set** of document NURIs, each with its own anchored query, grouped per subject ([`../src/surface/read-model.ts`](../src/surface/read-model.ts)). This is the polyfill's listing primitive (see [§ Current emulation status](#current-emulation-status) and @@ -284,7 +284,7 @@ from the reactive contract: `engine/verifier/src/request_processor.rs` `resolve_target` → `self.repos.get(...).ok_or(RepoNotFound)`; see [`nextgraph-current-state.md`](../../../docs/nextgraph-current-state.md) § *The ORM - fan-out hang*). So the lib reads entity lists with **`readModel.readUnion`** — a + fan-out hang*). So the lib reads entity lists with **`readUnion`** — a bounded set of one-shot anchored `sparql_query`s ([`read-model.md`](../../../docs/read-model.md)) — and reassembles reactivity by **re-querying on a change signal** (a lightweight `doc_subscribe` / single-store ORM diff --git a/packages/client/e2e/sdk-entry.ts b/packages/client/e2e/sdk-entry.ts index f93df9f..b6b1d67 100644 --- a/packages/client/e2e/sdk-entry.ts +++ b/packages/client/e2e/sdk-entry.ts @@ -384,7 +384,7 @@ const identity = new IdentityStore( // deposit into anyone's, you may only read your own. Establishing the identity // FIRST is what makes `userInbox` resolve (and file) that user's inbox. setCurrentUser(id); - const target = await storeRegistry.userInbox(id); + const target = await storeRegistry.userInbox(id, "protected"); await inbox.post(target, { payload: payloadA, from: null, ts: 1000 }); await inbox.post(target, { payload: payloadB, from: null, ts: 2000 }); const deposits = await inbox.read(target); @@ -398,7 +398,7 @@ const identity = new IdentityStore( // Watching an inbox is READING it continuously, so the watcher stays connected // for the whole probe — including across `inboxWatchDeposit`. setCurrentUser(id); - const target = await storeRegistry.userInbox(id); + const target = await storeRegistry.userInbox(id, "protected"); const rec = { fires: 0, lastLen: -1, unsub: () => {}, target }; (window as any).__sdk._inboxWatch = rec; rec.unsub = inbox.watch(target, (deposits) => { @@ -859,7 +859,7 @@ const identity = new IdentityStore( // the recipient's durable Links would grow run after run on a persistent wallet, // making every later `connectedUser()` re-apply a longer and longer history. setCurrentUser(friendId); - const friendInbox = await storeRegistry.userInbox(friendId); + const friendInbox = await storeRegistry.userInbox(friendId, "protected"); setCurrentUser("owner-O"); const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); diff --git a/packages/client/src/emulated-verifier/branch-registers.ts b/packages/client/src/emulated-verifier/branch-registers.ts index de641dc..afc6015 100644 --- a/packages/client/src/emulated-verifier/branch-registers.ts +++ b/packages/client/src/emulated-verifier/branch-registers.ts @@ -57,7 +57,7 @@ import { ensureAccount, type VirtualUserRecord, } from "../shared-wallet/account-registry"; -import type { Nuri, ReadCap, Scope } from "../model/types"; +import type { InboxScope, Nuri, ReadCap, Scope } from "../model/types"; /** * Does `nuri` belong to the CURRENT wallet as one of its inboxes? The predicate the @@ -67,7 +67,10 @@ import type { Nuri, ReadCap, Scope } from "../model/types"; export async function isOwnInbox(nuri: Nuri): Promise { const holder = getCurrentUser(); if (holder === null) return false; - if ((await userInbox(holder)) === nuri) return true; + // 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); @@ -323,7 +326,11 @@ export async function myInboxes(): Promise { const holder = getCurrentUser(); if (holder === null) return []; const out: Nuri[] = []; - if ((await resolveAccount(holder)) !== null) out.push(await userInbox(holder)); + // 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; } diff --git a/packages/client/src/model/types.ts b/packages/client/src/model/types.ts index f02e87a..0b4e12e 100644 --- a/packages/client/src/model/types.ts +++ b/packages/client/src/model/types.ts @@ -52,3 +52,14 @@ export type NgLike = Record; /** Loose shape of `@ng-org/orm`'s `useShape` (a generic hook). */ export type UseShapeLike = (...args: any[]) => any; + +/** + * The scopes that can carry an inbox. NOT `Scope`: upstream only the public and + * protected store repos get one — `new_store_default` attaches an inbox solely + * `if !private` (`engine/verifier/src/verifier.rs:2994`), and the engine's only two + * `AddInboxCap` commits are for those two (`engine/verifier/src/site.rs:127-152`). + * + * Typing it out means "the private inbox" cannot be written, rather than being written + * and returning nothing. + */ +export type InboxScope = Extract; diff --git a/packages/client/src/shared-wallet/account-registry.ts b/packages/client/src/shared-wallet/account-registry.ts index 9dd1c2f..6cffb53 100644 --- a/packages/client/src/shared-wallet/account-registry.ts +++ b/packages/client/src/shared-wallet/account-registry.ts @@ -83,7 +83,7 @@ import { hasReadCap, isNuri } from "../model/nuri"; import { mintCap } from "../emulated-verifier/caps"; import { mustNotAttempt } from "../emulated-verifier/reach"; import { accessLogPrefix, logStage, shortNuri } from "./access-log"; -import type { Nuri, ReadCap, Scope } from "../model/types"; +import type { InboxScope, Nuri, ReadCap, Scope } from "../model/types"; // --- sharedWalletShim model ---------------------------------------------- @@ -116,7 +116,7 @@ export const P = { docProtected: `${SHIM}:docProtected`, docPrivate: `${SHIM}:docPrivate`, contains: `${SHIM}:contains`, // scope-index → entity document NURI - docInbox: `${SHIM}:docInbox`, // account → ITS OWN inbox document + docInbox: `${SHIM}:docInbox`, // (user, inboxScope) → ITS inbox document 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 @@ -813,11 +813,29 @@ const inboxCache = new Map(); * Concurrency-safe (see {@link inboxInFlight}), and a fork is reconciled the same * content-addressed way as everything else ({@link canonicalDoc}). * - * At migration this becomes the identity's native inbox and the resolution moves - * here — the consumer-facing act (deposit to an inbox, process my own) is unchanged. + * ── TWO inboxes, because upstream a user has two ───────────────────────── + * A *site* carries an inbox on its **public** store repo and another on its + * **protected** one — the only two `AddInboxCap` commits in the engine + * (`engine/verifier/src/site.rs:127-152`), `new_store_default` attaching one solely + * `if !private` (`engine/verifier/src/verifier.rs:2994`). They are addressed + * separately right down to the contact records, which pick their predicate from the + * profile being reached: `ng:site_inbox` for a public profile, `ng:protected_inbox` + * otherwise (`engine/verifier/src/inbox_processor.rs:787,823-824`). + * + * This function used to expose ONE, which was a cardinality this library invented. + * Corrected 2026-08-03 by walking the cascade: neither the JS ORM nor the wasm binding + * says anything about inboxes — `@ng-org/web` has no method containing "inbox" and the + * session exposes none — so the engine's model is what decides, and it says two. + * + * **The private store has none**, hence {@link InboxScope} rather than `Scope`: asking + * for a private inbox is not a lookup that returns nothing, it is a question the model + * has no meaning for. + * + * At migration these become the site's native store inboxes and the resolution moves + * there — the consumer-facing act (deposit to an inbox, process my own) is unchanged. */ -export async function userInbox(id: string): Promise { - const key = accountKey(id); +export async function userInbox(id: string, scope: InboxScope): Promise { + const key = `${accountKey(id)}\u0000${scope}`; const cached = inboxCache.get(key); if (cached) { fileOwnInbox(id, cached); @@ -831,12 +849,15 @@ export async function userInbox(id: string): Promise { const shimDoc = await resolveShimDoc(); await ensureAccount(id); // the account must exist before it can own an inbox const subj = accountSubject(id); + // One triple per (user, scope): the two inboxes are distinct documents, as the two + // store repos that carry them are distinct upstream. + const pred = `${P.docInbox}:${scope}`; try { // The doc-shim is machinery: this reads WHICH inbox a virtual user owns, // which is exactly the kind of question that cannot be confined to that user. const res = await physicalQuery( s.sessionId, - `SELECT ?d WHERE { <${subj}> <${P.docInbox}> ?d }`, + `SELECT ?d WHERE { <${subj}> <${pred}> ?d }`, undefined, shimDoc, "userInbox", @@ -856,7 +877,7 @@ export async function userInbox(id: string): Promise { try { await physicalUpdate( s.sessionId, - `INSERT DATA { <${subj}> <${P.docInbox}> "${escapeLiteral(doc)}" }`, + `INSERT DATA { <${subj}> <${pred}> "${escapeLiteral(doc)}" }`, shimDoc, "userInbox", ); @@ -864,7 +885,7 @@ export async function userInbox(id: string): Promise { console.error(accessLogPrefix() + " userInbox persist failed:", error); } inboxCache.set(key, doc); - logStage("userInbox(" + key + ") → " + shortNuri(doc)); + logStage("userInbox(" + key + "/" + scope + ") → " + shortNuri(doc)); return doc; })(); diff --git a/packages/client/test/cross-user-access.test.ts b/packages/client/test/cross-user-access.test.ts index 150a05d..15ba1fb 100644 --- a/packages/client/test/cross-user-access.test.ts +++ b/packages/client/test/cross-user-access.test.ts @@ -262,7 +262,7 @@ test("Bob: reads the public document, sees the reference, and cannot read throug test("Charlie: same public document, same reference — and he reads through it", async () => { inject(); const { protDoc, pubDoc, pubLink, protCap } = await aliceSetsUpHerDocuments(); - const CHARLIE_INBOX = await userInbox("charlie"); + const CHARLIE_INBOX = await userInbox("charlie", "protected"); // Alice decides Charlie may read that ONE document, and delivers its cap to his // inbox. She names no principal to the registry; she addresses an inbox. @@ -282,7 +282,7 @@ test("Charlie: same public document, same reference — and he reads through it" test("the ONLY difference between Bob and Charlie is each of them holds", async () => { inject(); const { protDoc, pubLink, protCap } = await aliceSetsUpHerDocuments(); - const CHARLIE_INBOX = await userInbox("charlie"); + const CHARLIE_INBOX = await userInbox("charlie", "protected"); setCurrentUser("alice"); await shareCap(protCap, CHARLIE_INBOX); @@ -305,7 +305,7 @@ test("the ONLY difference between Bob and Charlie is each of them holds", async test("dynamic: a cap delivered to Bob's inbox makes the refused document readable, and signals it", async () => { inject(); const { pubDoc, pubLink, protCap } = await aliceSetsUpHerDocuments(); - const BOB_INBOX = await userInbox("bob"); + const BOB_INBOX = await userInbox("bob", "protected"); setCurrentUser("bob"); getCaps().learn(pubLink); @@ -361,7 +361,7 @@ test("a bare reference to the PUBLIC document is not enough either — the link test("a Link is APPLIED durably: the cap survives with the inbox emptied", async () => { const ng = inject(); const { protDoc, protCap } = await aliceSetsUpHerDocuments(); - const bobInbox = await userInbox("bob"); + const bobInbox = await userInbox("bob", "protected"); setCurrentUser("alice"); await shareCap(protCap, bobInbox); @@ -406,7 +406,7 @@ test("a document has its own inbox: anyone deposits, only the owner reads", asyn setCurrentUser("alice"); const doc = await createEntityDoc("alice", "public"); const aliceInbox = await openDocumentInbox(doc); - expect(aliceInbox).not.toBe(await userInbox("alice")); + expect(aliceInbox).not.toBe(await userInbox("alice", "protected")); const link = capFor(doc)!; // the repo link alice circulates — links DO travel // Bob RESOLVES the address himself, from the document. The only thing he is handed @@ -497,7 +497,7 @@ test("connecting drains BOTH levels: the user's inbox and its documents'", async const protDoc = await createEntityDoc("alice", "protected"); const pubDoc = await createEntityDoc("alice", "public"); const docInbox = await openDocumentInbox(pubDoc); - const aliceInbox = await userInbox("alice"); + const aliceInbox = await userInbox("alice", "protected"); // Two deposits, one at each level, both made by someone else. setCurrentUser("carol"); @@ -519,8 +519,8 @@ test("connecting drains BOTH levels: the user's inbox and its documents'", async test("a third party resolves another user's inbox (the wallet level)", async () => { inject(); setCurrentUser("alice"); - const aliceView = await userInbox("alice"); + const aliceView = await userInbox("alice", "protected"); setCurrentUser("bob"); - const bobView = await userInbox("alice"); + const bobView = await userInbox("alice", "protected"); expect(bobView).toBe(aliceView); }); diff --git a/packages/client/test/inbox.test.ts b/packages/client/test/inbox.test.ts index ce26b1c..08fefa7 100644 --- a/packages/client/test/inbox.test.ts +++ b/packages/client/test/inbox.test.ts @@ -165,7 +165,7 @@ beforeEach(async () => { fake = inject(); resetRegistryCache(); setCurrentUser("alice"); - TARGET = await userInbox("alice"); + TARGET = await userInbox("alice", "protected"); }); test("post writes via the real injected ng.sparql_update (not makeNg), scoped to the inbox", async () => { diff --git a/packages/client/test/isolation-active.test.ts b/packages/client/test/isolation-active.test.ts index 59c8b8b..9a44a14 100644 --- a/packages/client/test/isolation-active.test.ts +++ b/packages/client/test/isolation-active.test.ts @@ -228,7 +228,7 @@ test("(a) sharing one document's cap to ONE inbox reveals it there, and only the // The app decides alice↔bob are related: alice shares ONE document's cap into // bob's OWN inbox — the only cross-wallet act there is. - const bobInbox = await userInbox("bob"); + const bobInbox = await userInbox("bob", "protected"); setCurrentUser("alice"); await shareCap(capFor(shared)!, bobInbox); @@ -239,7 +239,7 @@ test("(a) sharing one document's cap to ONE inbox reveals it there, and only the // carol, who was not shared with, still reads nothing. setCurrentUser("carol"); - await readInbox(await userInbox("carol")); + await readInbox(await userInbox("carol", "protected")); expect(view(items)).toEqual([]); }); @@ -247,7 +247,7 @@ test("a cap deposit is absorbed, not surfaced as a consumer deposit", async () = inject(); setCurrentUser("alice"); const doc = await createEntityDoc("alice", "protected"); - const bobInbox = await userInbox("bob"); + const bobInbox = await userInbox("bob", "protected"); await shareCap(capFor(doc)!, bobInbox); setCurrentUser("bob"); @@ -320,7 +320,7 @@ test("an inbox may be DEPOSITED into by anyone, and READ only by its owner", asy inject(); setCurrentUser("alice"); const secret = await createEntityDoc("alice", "protected"); - const bobInbox = await userInbox("bob"); + const bobInbox = await userInbox("bob", "protected"); // Alice deposits into bob's inbox — allowed, and it grants her nothing back. await shareCap(capFor(secret)!, bobInbox); diff --git a/packages/client/test/reach.test.ts b/packages/client/test/reach.test.ts index 4e95788..1157d58 100644 --- a/packages/client/test/reach.test.ts +++ b/packages/client/test/reach.test.ts @@ -103,7 +103,7 @@ test("a user reaches its OWN stores and inbox — the boundary must not lock it inject(); setCurrentUser("alice"); await createEntityDoc("alice", "protected"); // provisions alice's account - const inbox = await userInbox("alice"); + const inbox = await userInbox("alice", "protected"); expect(mayReach(inbox)).toBe(true); await sparqlQuery(SESSION.sessionId, READ, undefined, inbox); @@ -116,7 +116,7 @@ test("a user reaches its OWN stores and inbox — the boundary must not lock it test("DEPOSITING into another user's inbox crosses the boundary, and gives nothing back", async () => { const { ng } = inject(); setCurrentUser("bob"); - const bobInbox = await userInbox("bob"); + const bobInbox = await userInbox("bob", "protected"); setCurrentUser("alice"); await createEntityDoc("alice", "private"); // alice now holds caps → guard is armed diff --git a/packages/client/test/store-registry.test.ts b/packages/client/test/store-registry.test.ts index 40f0470..10be98e 100644 --- a/packages/client/test/store-registry.test.ts +++ b/packages/client/test/store-registry.test.ts @@ -248,11 +248,11 @@ test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to // docCreate), not the private-store root, so deposits never bloat the shim graph. // Stable per wallet, and DISJOINT between wallets: reading someone else's inbox // would collect the caps addressed to them (see inbox.ts's read guard). - const mine = await userInbox("@alice"); + const mine = await userInbox("@alice", "protected"); expect(mine).toMatch(/^did:ng:o:doc/); expect(mine).not.toBe("did:ng:PRIV"); - expect(await userInbox("@alice")).toBe(mine); // stable - expect(await userInbox("@bob")).not.toBe(mine); // another wallet, another inbox + expect(await userInbox("@alice", "protected")).toBe(mine); // stable + expect(await userInbox("@bob", "protected")).not.toBe(mine); // another wallet, another inbox }); test("resolveScopeGraph falls back to the private store when no protected id is injected", async () => { @@ -379,3 +379,18 @@ test("normalizeId defaults to trim when not provided", async () => { expect(b).toEqual(a); expect(ng.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs }); + +test("a user has TWO inboxes — public and protected — and they are distinct documents", async () => { + // Upstream a site carries an inbox on its public store repo and another on its + // protected one (`engine/verifier/src/site.rs:127-152`), addressed separately down to + // the contact predicates (`ng:site_inbox` vs `ng:protected_inbox`). Exposing one was a + // cardinality this library invented; neither the ORM nor the wasm binding says + // anything about inboxes, so the engine's model is what decides. + resetRegistryCache(); + const pub = await userInbox("@dana", "public"); + const prot = await userInbox("@dana", "protected"); + expect(pub).not.toBe(prot); + // …and each is stable for its own scope. + expect(await userInbox("@dana", "public")).toBe(pub); + expect(await userInbox("@dana", "protected")).toBe(prot); +});