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:
Sylvain Duchesne
2026-08-04 16:19:49 +02:00
parent b62bfe1e63
commit 3257afe8c0
16 changed files with 103 additions and 45 deletions
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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;
}
+11
View File
@@ -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);
});
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -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
+18 -3
View File
@@ -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);
});