feat(inbox): un utilisateur a DEUX inboxes, comme en amont
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.
This commit is contained in:
@@ -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<StoreRepo>` 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<String>` + `store_repo: Option<String>` 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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<Nuri[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -52,3 +52,14 @@ export type NgLike = Record<string, any>;
|
||||
|
||||
/** 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<Scope, "public" | "protected">;
|
||||
|
||||
@@ -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<string, Nuri>();
|
||||
* 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<Nuri> {
|
||||
const key = accountKey(id);
|
||||
export async function userInbox(id: string, scope: InboxScope): Promise<Nuri> {
|
||||
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<Nuri> {
|
||||
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<Nuri> {
|
||||
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<Nuri> {
|
||||
console.error(accessLogPrefix() + " userInbox persist failed:", error);
|
||||
}
|
||||
inboxCache.set(key, doc);
|
||||
logStage("userInbox(" + key + ") → " + shortNuri(doc));
|
||||
logStage("userInbox(" + key + "/" + scope + ") → " + shortNuri(doc));
|
||||
return doc;
|
||||
})();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user