Files
ng-eventually/packages/polyfill/test/cross-user-access.test.ts
T
Sylvain Duchesne 76ae9ffbb7 fix: trois chemins vers une inbox en double, et la lecture qui manquait
Une application a rapporté quatre appels simultanés sur un même document
enregistrant trois inboxes. Le contrat garantissait l'inverse. En cherchant, on
en a trouvé DEUX autres, indépendantes, qui produisent le même dégât durable :
le propriétaire surveille une inbox pendant que les dépôts arrivent dans une
autre.

La concurrence. openDocumentInbox ne partageait rien avec userInbox — module
différent, registre propre, aucune coalescence. Reproduit pire que rapporté :
quatre appels donnaient QUATRE inboxes. Une carte en vol par (détenteur,
document), et le corps déplacé pour que l'invariant soit porté par la
composition plutôt que par la position d'une vérification.

La limite est nommée plutôt que cachée : deux onglets ne partagent aucune carte,
chacun lit, chacun ne trouve rien, chacun frappe. Ce n'est pas réparable ici —
une branche est en ajout seul, et ça ne se réconcilie pas après coup, le
propriétaire lisant sa branche User quand un déposant lit l'adresse publiée du
document. Le contrat porte donc une garantie positive ET une non-garantie.

La page froide. readInboxCapPairs était le seul lecteur de store sans barrière,
correct uniquement parce qu'une autre fonction s'exécutait avant lui à la
connexion. Une dépendance d'ordre, pas une garantie portée par la lecture : sur
une page froide il lisait le store privé non synchronisé, répondait « aucune
inbox » et en frappait une seconde. Un seul appel, aucune concurrence. La
barrière est désormais dans la lecture, et elle ne coûte rien aux chemins
connectés, la connexion ayant déjà ouvert les trois stores.

Et la lecture qui manquait. readSynced donnait la garantie, readForDocument
l'adressage, pas leur intersection — si bien que matérialiser des dépôts
obligeait une application à résoudre une adresse d'inbox elle-même, ce que le
contrat lui interdit explicitement. inbox.readSyncedForDocument la lui épargne.
Elle traverse deux dépôts, l'adresse vivant sur l'en-tête du document et les
dépôts sur l'inbox — franchir la barrière sur la seule inbox ne réparait rien.

Au passage, le compteur d'identifiants de la doublure était par page : une page
rechargée refrappait le même identifiant PAR-DESSUS une inbox existante,
aliasant deux dépôts en silence. Il est monotone.
2026-08-17 09:46:16 +02:00

773 lines
35 KiB
TypeScript

/**
* Cross-user access — the scenario that proves the model end to end.
*
* Alice owns a PROTECTED document and a PUBLIC one, and the public one carries a
* REFERENCE to the protected one. Then:
*
* - **Bob** has the public document's link. He reads it, sees the reference, and
* cannot read what it points at. Naming is not reading, and publication is
* **not recursive**: a public object may point at private content without
* disclosing it.
* - **Charlie** has the public document's link AND was given the protected
* document's cap. Same reference, same path — he reads through it.
* - **Bob, dynamically**: Alice delivers the cap to Bob's inbox. Processing the
* inbox files it, which fires the held-caps signal, which re-runs the read — the
* protected document appears with nothing else happening.
*
* The difference between Bob and Charlie is ONLY each of them holds. There is
* no authorization list anywhere, and nobody was named to the registry.
*/
import { getCaps } from "../src/shared-wallet/bootstrap";
import { test, expect, mock, afterAll } from "bun:test";
import {
createEntityDoc,
resetRegistryCache,
resolveWriteGraph,
userInbox,
} from "../src/shared-wallet/account-registry";
import {
documentInboxAddress,
openDocumentInbox,
readInboxCapPairs,
} from "../src/emulated-verifier/branch-registers";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { connectedUser } from "../src/emulated-verifier/connect";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { share } from "../src/surface/inbox";
import { post, postToDocument, read as readInbox } from "../src/surface/inbox";
import { readUnion } from "../src/surface/read-model";
import { sparqlUpdate } from "../src/surface/docs";
import type { Nuri } from "../src/model/types";
/**
* Do I hold this document's cap? Possession, asked of the internal registry — the
* polyfill door stopped publishing this (see `polyfill.ts`), because as an app-facing
* question it reads like "may I read this?" and a public store's document answers
* `false` until something has asked for its cap.
*/
function hasCap(nuri: Nuri): boolean {
return getCaps().capFor(nuri) !== undefined;
}
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
setCurrentUser(null);
});
const SESSION: RegistrySession = { sessionId: "sid-x", privateStoreId: "PRIV-X" };
const SHIM = "urn:ng-eventually:shim";
const INBOX = "urn:ng-eventually:inbox";
/** The predicate Alice uses to point from her public doc at her protected one. */
const REFERS_TO = "urn:e2e:refersTo";
const SECRET = "urn:e2e:secret";
interface Quad { g: string; s: string; p: string; o: string }
function unescapeLiteral(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
} else out += s[i];
}
return out;
}
/** A stateful fake `ng`: the shim SPARQL, the inbox SPARQL, and the anchored
* per-doc `?s ?p ?o` read the read-model uses. */
function makeFakeNg() {
const quads: Quad[] = [];
let docCounter = 0;
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
if (!anchor) return undefined;
// `DELETE WHERE { <s> <p> ?var }` — the form the lib uses to REPLACE a value
// (see docs/decisions/sparql-delete-for-orm-objects.md). Without this arm the
// fake would treat the delete as an insert and the replacement would silently
// become an accumulation — the exact bug a replacement exists to prevent.
const del = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+<([^>]+)>\s+\?/);
if (del) {
const [s0, p0] = [del[1]!, del[2]!];
for (let i = quads.length - 1; i >= 0; i--) {
const q = quads[i]!;
if (q.g === anchor && q.s === s0 && q.p === p0) quads.splice(i, 1);
}
return undefined;
}
// `INSERT DATA { GRAPH <g> { … } }` — the shape the store-ROOT pointer write uses.
// Without this arm the first `<…>` in the body is the GRAPH NAME, so the pointer was
// stored with the graph as its subject and its predicate as its object. The pointer
// SELECT then found nothing, every cold `resolveShimDoc` forked a NEW shim, and the
// suite never noticed because the module cache carried the previous answer. Added
// 2026-08-10; the other fakes had it already.
const gm = query.match(/GRAPH\s+<([^>]+)>\s*\{([\s\S]*)\}/);
const body = gm
? gm[2]!
: query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? (query.includes(`${INBOX}:Deposit`) ? `${INBOX}:Deposit` : `${SHIM}:Account`);
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g: anchor, s, p, o });
}
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
if (query.includes(`<${SHIM}:shimDoc>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`).map((q) => ({ shimDoc: { value: q.o } })) } };
}
if (query.includes(`<${SHIM}:id>`)) {
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
const only = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (only !== null && q.s !== only) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${SHIM}:id`) rec.id = q.o;
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
bySubject.set(q.s, rec);
}
return {
results: {
bindings: [...bySubject.values()].filter((r) => r.id).map((r) => ({
id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" },
})),
},
};
}
if (query.includes(`<${INBOX}:payload>`)) {
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
if (q.p === `${INBOX}:from`) rec.from = q.o;
bySubject.set(q.s, rec);
}
return {
results: {
bindings: [...bySubject.values()]
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = { payload: { value: r.payload! }, ts: { value: r.ts! } };
if (r.from !== undefined) row.from = { value: r.from };
return row;
}),
},
};
}
// User-branch `link` SELECT (the emulated AddLink records).
// User-branch `inboxCap` SELECT (the emulated AddInboxCap records).
if (query.includes(`<${SHIM}:inboxCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxCap`).map((q) => ({ c: { value: q.o } })) } };
}
// Header-branch `inboxAddress` SELECT (where to deposit for this document).
if (query.includes(`<${SHIM}:inboxAddress>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxAddress`).map((q) => ({ a: { value: q.o } })) } };
}
// Store-branch `readCap` SELECT (the emulated AddRepo records).
if (query.includes(`<${SHIM}:readCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:readCap`).map((q) => ({ c: { value: q.o } })) } };
}
if (query.includes(`<${SHIM}:link>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:link`).map((q) => ({ c: { value: q.o } })) } };
}
// Shim `isInbox` SELECT — the emulated "the broker knows this is an inbox". Absent at
// first, so `isKnownInbox` answered from its in-memory set alone: the durable half was
// never exercised, which is the very fault this pass was fixing elsewhere.
if (query.includes(`${SHIM}:isInbox`)) {
return { results: { bindings: quads
.filter((q) => q.g === anchor && q.p === `${SHIM}:isInbox`)
.map((q) => ({ i: { value: q.o } })) } };
}
// Shim `docInbox:<scope>` SELECT — WHICH inbox a virtual user owns. Absent until
// 2026-08-10, so `userInbox` never found a persisted address and answered from the
// module cache alone: two actors in one JS realm agreed, two SESSIONS would not have.
// The suite's "a third party resolves another user's inbox" was proving the cache.
if (query.includes(`${SHIM}:docInbox`)) {
const pm = query.match(/<(urn:ng-eventually:shim:docInbox:[a-z]+)>/);
const pred = pm ? pm[1]! : "";
const sm = query.match(/<([^>]+)>\s+<urn:ng-eventually:shim:docInbox/);
const subj = sm ? sm[1]! : null;
return { results: { bindings: quads
.filter((q) => q.g === anchor && q.p === pred && (subj === null || q.s === subj))
.map((q) => ({ d: { value: q.o } })) } };
}
// Header-branch `exposedReadCap` SELECT — what a PUBLIC store serves to anyone.
if (query.includes(`<${SHIM}:exposedReadCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:exposedReadCap`).map((q) => ({ c: { value: q.o } })) } };
}
if (query.includes(`<${SHIM}:contains>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`).map((q) => ({ e: { value: q.o } })) } };
}
// Anchored per-doc read (readUnion `SELECT ?s ?p ?o`) — the document's content.
return {
results: {
bindings: quads
.filter((q) => q.g === anchor)
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } })),
},
};
});
return { doc_create, sparql_update, sparql_query, _quads: quads };
}
function inject() {
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim().toLowerCase() });
resetRegistryCache();
resetCaps();
setCurrentUser(null);
return ng;
}
/** Write one triple into `doc`, as the consumer's write path would. */
async function write(doc: Nuri, p: string, o: string): Promise<void> {
await sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${doc}> <${p}> "${o}" }`, doc, "test");
}
/** The values `p` carries in the documents `docs`, as the current holder reads them. */
async function readValues(docs: Nuri[], p: string): Promise<string[]> {
const subjects = await readUnion(docs);
return subjects.flatMap((s) => s.props[p] ?? []);
}
/**
* Alice's world: a protected document holding a secret, and a public document that
* REFERS to it by bare NURI.
*
* What crosses to the other actors is **the bare reference of the public document and
* nothing else** — no cap, no link with a key in it. That is the whole discipline of
* this file: an application circulates references, and if a test had to hand a key
* across an identity boundary through a JS variable, the feature it claims to prove
* would have no path in any real application.
*/
async function aliceSetsUpHerDocuments() {
setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected");
await write(protDoc, SECRET, "the-protected-content");
const pubDoc = await createEntityDoc("alice", "public");
// The reference is the BARE NURI of the protected document: it names it, and
// grants nothing. This is the whole point of the scenario.
await write(pubDoc, REFERS_TO, protDoc);
return { protDoc, pubDoc };
}
/** Follow the reference found in the public document — what a reader actually does. */
function referenceFoundIn(values: string[]): Nuri {
const ref = values[0];
expect(ref).toBeDefined();
return ref as Nuri;
}
test("Bob: reads the public document, sees the reference, and cannot read through it", async () => {
inject();
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
setCurrentUser("bob");
// Bob holds the BARE reference and nothing else. The document sits in a public
// store, so the store serves him its cap — he never received a key from anyone.
// He reads the public document and finds the reference.
const refs = await readValues([pubDoc], REFERS_TO);
const ref = referenceFoundIn(refs);
expect(ref).toBe(protDoc); // he can NAME Alice's protected document
// …and that is all it gets him: no cap, no read. Publication is NOT recursive.
expect(hasCap(ref)).toBe(false);
expect(await readValues([ref], SECRET)).toEqual([]);
});
test("Charlie: same public document, same reference — and he reads through it", async () => {
inject();
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
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.
setCurrentUser("alice");
await share(protDoc, "charlie");
setCurrentUser("charlie");
await readInbox(CHARLIE_INBOX); // processing the inbox files the cap
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
expect(ref).toBe(protDoc);
expect(hasCap(ref)).toBe(true);
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
});
test("the ONLY difference between Bob and Charlie is each of them holds", async () => {
inject();
const { protDoc } = await aliceSetsUpHerDocuments();
const CHARLIE_INBOX = await userInbox("charlie", "protected");
setCurrentUser("alice");
await share(protDoc, "charlie");
setCurrentUser("bob");
const bobSees = await readValues([protDoc], SECRET);
setCurrentUser("charlie");
await readInbox(CHARLIE_INBOX);
const charlieSees = await readValues([protDoc], SECRET);
expect(bobSees).toEqual([]);
expect(charlieSees).toEqual(["the-protected-content"]);
});
// The dynamic version: Bob is refused, then the cap lands in his inbox and the read
// that was empty becomes full — with nothing re-declared and nobody re-authorized.
test("dynamic: a cap delivered to Bob's inbox makes the refused document readable, and signals it", async () => {
inject();
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
const BOB_INBOX = await userInbox("bob", "protected");
setCurrentUser("bob");
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
// Before: named, unreadable.
expect(await readValues([ref], SECRET)).toEqual([]);
// A reader that re-reads whenever what it holds changes — this is exactly what
// `watchShape` wires internally, played here on an ad-hoc read.
let reread = 0;
let latest: string[] = [];
const unsub = getCaps().onChange(() => {
reread += 1;
void readValues([ref], SECRET).then((v) => (latest = v));
});
// Alice delivers the cap. Bob's client processes his inbox — the only thing that
// happens; no "receive" call exists.
setCurrentUser("alice");
await share(protDoc, "bob");
setCurrentUser("bob");
await readInbox(BOB_INBOX);
// Filing the cap fired the signal…
expect(reread).toBeGreaterThan(0);
await Promise.resolve();
await new Promise((r) => setTimeout(r, 0));
// …and the read that was empty now yields the content.
expect(hasCap(ref)).toBe(true);
expect(latest).toEqual(["the-protected-content"]);
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
unsub();
});
// The property this whole batch exists for, stated on its own: WHERE a document sits
// decides whether a bare reference is enough. Upstream a public store's repos are
// served on the outer overlay and their ReadCap is downloaded from it
// (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`) — so the same value transmitted
// (a bare reference) yields a different outcome depending on the store, and never
// because a key travelled.
test("a bare reference is enough for a PUBLIC document, and not for a protected one", async () => {
inject();
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
setCurrentUser("bob");
// Bob has been given nothing but the two NURIs.
expect((await readValues([pubDoc], REFERS_TO)).length).toBe(1);
expect(await readValues([protDoc], SECRET)).toEqual([]);
// And what he obtained for the public one is a READ grant, not a write right: a
// public store serves its read cap, no store hands out the write cap.
await expect(write(pubDoc, SECRET, "bob-was-here")).rejects.toThrow(/public store/i);
});
// THE POINT OF THE LINK: a cap survives because it was APPLIED, not because the
// inbox is re-read. Upstream, processing an inbox message files it — `AddLink
// { read_cap }` on the User branch of the private store — and the queue is consumed.
// Re-reading a queue to recover state is using it as a database.
test("a Link is APPLIED durably: the cap survives with the inbox emptied", async () => {
const ng = inject();
const { protDoc } = await aliceSetsUpHerDocuments();
const bobInbox = await userInbox("bob", "protected");
setCurrentUser("alice");
await share(protDoc, "bob");
// Bob connects: the library restores + drains, with nothing asked of the app.
setCurrentUser("bob");
await connectedUser();
expect(await readValues([protDoc], SECRET)).toEqual(["the-protected-content"]);
// Now EMPTY the inbox — as a consumed queue would be — and drop every in-memory
// cap, then re-arm the emulation so the boundary is actually in force again.
for (let k = ng._quads.length - 1; k >= 0; k--) {
if (ng._quads[k]!.g === bobInbox) ng._quads.splice(k, 1);
}
resetCaps();
setCurrentUser("alice");
await createEntityDoc("alice", "private"); // re-arms: a cap exists again
setCurrentUser("bob");
// Checked SYNCHRONOUSLY, before yielding: `setCurrentUser` fires the connection work
// itself, and that work is precisely what restores the cap. An awaited check here
// would be asserting who won a race, not what the library does.
expect(hasCap(protDoc)).toBe(false); // bob holds nothing yet
// Connecting restores it — from the User branch, since the inbox has nothing left.
await connectedUser();
expect(hasCap(protDoc)).toBe(true);
expect(await readValues([protDoc], SECRET)).toEqual(["the-protected-content"]);
});
test("connecting a user that does not exist provisions nothing", async () => {
inject();
setCurrentUser("nobody");
await connectedUser();
// No account, no stores, no caps — connecting must not create a user as a side
// effect, or the emulation would arm itself in the background.
expect(getCaps().isEnforcing()).toBe(false);
});
// REGRESSION (second adversarial pass). `inbox.post` is a published door that skips both
// guards by design — the deposit is the one write that legitimately crosses. It accepted
// ANY NURI, so it wrote into a document its caller could not even read. Upstream the
// confusion cannot arise: a deposit carries an inbox key, not a document reference.
test("a deposit is addressed to an inbox, never to a document", async () => {
inject();
setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected");
await write(protDoc, SECRET, "alice's own");
setCurrentUser("bob");
await expect(post(protDoc, { payload: { x: 1 }, ts: 1 })).rejects.toThrow(/not an inbox/i);
setCurrentUser("alice");
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // untouched
});
// REGRESSION (second adversarial pass). The write guard reads ownership from the store
// index — and the holder's own store document was marked "created by me", so it was
// writable through the PUBLISHED `docs.sparqlUpdate`. One insert into it and you were
// the owner of anything you cared to name.
test("a holder cannot write into their own store index and forge ownership", async () => {
inject();
setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected");
await write(protDoc, SECRET, "alice's own");
setCurrentUser("bob");
await createEntityDoc("bob", "protected"); // bob has his own stores
const bobStore = await resolveWriteGraph("bob", "protected");
await expect(
sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${SHIM}:index> <${SHIM}:contains> "${protDoc}" }`, bobStore, "forge"),
).rejects.toThrow(/WRITE cap/i);
// …and he is still refused the write itself — here by rule 1 (he cannot even reach
// alice's protected document), which fires before the ownership guard. Both say no.
await expect(write(protDoc, SECRET, "bob was here")).rejects.toThrow(/refused/i);
});
// WRITING IS OWNERSHIP — the two regressions that replaced the old write guard.
//
// It used to ask "was this cap served to me by a public store?", which was wrong in both
// directions at once. Both are pinned here, because one predicate pushed two ways is
// exactly how a fix trades one bug for a worse one.
// Direction 1 — TOO STRICT. The owner opening her own public note from its reference,
// before her store has been listed (a deep link, a fresh session), got the "served by a
// public store" mark on her own document and was refused a write to it.
test("the owner writes to her own public note, even after opening it from its reference", async () => {
inject();
setCurrentUser("alice");
const pubDoc = await createEntityDoc("alice", "public");
await write(pubDoc, SECRET, "v1");
// She arrives at it the way a deep link would: by reference, with nothing held.
resetCaps();
setCurrentUser("bob");
await createEntityDoc("bob", "private"); // re-arms the emulation
setCurrentUser("alice");
await readValues([pubDoc], SECRET); // this is what files the served cap
await write(pubDoc, SECRET, "v2"); // must not throw
expect((await readValues([pubDoc], SECRET)).includes("v2")).toBe(true);
});
// Direction 2 — TOO LAX. A cap received in an inbox let its recipient WRITE into the
// owner's document. Upstream impossible: writing is repo membership, and a Link is
// "external repos only". An application could have shipped collaborative editing on it.
test("a cap received in an inbox reads, and does NOT write", async () => {
inject();
setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected");
await write(protDoc, SECRET, "alice's own");
const BOB_INBOX = await userInbox("bob", "protected");
await share(protDoc, "bob");
setCurrentUser("bob");
await readInbox(BOB_INBOX);
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // he reads it
await expect(write(protDoc, SECRET, "bob was here")).rejects.toThrow(/WRITE cap/i);
setCurrentUser("alice");
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // untouched
});
// PER-DOCUMENT INBOXES. Upstream a repo carries `inbox: Option<PrivKey>` and its
// owner records the private half with `AddInboxCap` on the User branch — the same
// branch as `AddLink`. So "which inboxes may I read" has one answer, and connecting
// drains them all: the user's own, and one per document it opened an inbox on.
test("a document has its own inbox: anyone deposits, only the owner reads", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const aliceInbox = await openDocumentInbox(doc);
expect(aliceInbox).not.toBe(await userInbox("alice", "protected"));
// Bob RESOLVES the address himself, from the BARE reference — the only thing he is
// handed, and the only thing an application circulates. The document is in a public
// store, so the store serves him its read cap; the address is not passed to him,
// because if it had to be there would be no way for an app to get it.
//
// The registry cache is dropped first: Bob is another session, and an address he can
// only find because Alice's session warmed a module map is an address no second browser
// page would find.
resetRegistryCache();
setCurrentUser("bob");
const bobTarget = await documentInboxAddress(doc);
expect(bobTarget).toBe(aliceInbox); // …and it is the SAME inbox alice reads
await post(bobTarget!, { payload: { joining: true }, ts: 1 });
// …and he cannot read it back: depositing grants nothing.
await expect(readInbox(bobTarget!)).rejects.toThrow(/does not belong to the connected wallet/i);
// Alice reads her document's inbox, because she opened it.
setCurrentUser("alice");
const deposits = await readInbox(aliceInbox);
expect(deposits.map((d) => d.payload)).toEqual([{ joining: true }]);
});
test("opening an inbox on someone else's document is refused, not silently forked", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const aliceInbox = await openDocumentInbox(doc);
// Bob can READ the document (it is in a public store) — and reading is not ownership.
resetRegistryCache(); // another session, not a warmed cache
setCurrentUser("bob");
await expect(openDocumentInbox(doc)).rejects.toThrow(/already has an inbox|you may only open an inbox/i);
// The address he resolves is still alice's, so his deposits reach her.
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
});
test("a fresh document has NO inbox — one belongs to one document, and only its owner opens it", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
// Not "the owner's inbox by default": upstream an inbox belongs to exactly ONE repo
// (the verifier routes by `inboxes: PubKey → RepoId`), so pointing several documents
// at one inbox is a relation the model cannot express.
resetRegistryCache(); // another session, not a warmed cache
setCurrentUser("bob");
expect(await documentInboxAddress(doc)).toBeUndefined();
// …and depositing THROWS rather than vanishing — a lost deposit is the bug this
// whole path exists to close.
await expect(postToDocument(doc, { payload: { x: 1 } })).rejects.toThrow(/has no inbox/i);
});
test("opening an inbox publishes ONE address, and re-opening does not accumulate", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const dedicated = await openDocumentInbox(doc);
expect(await openDocumentInbox(doc)).toBe(dedicated); // idempotent
resetRegistryCache(); // another session, not a warmed cache
setCurrentUser("bob");
expect(await documentInboxAddress(doc)).toBe(dedicated);
// The deposit reaches the owner, addressed by the document alone.
await postToDocument(doc, { payload: { signingUp: true } });
setCurrentUser("alice");
expect((await readInbox(dedicated)).map((d) => d.payload)).toEqual([{ signingUp: true }]);
});
// CONCURRENCY, and it is NOT the "a failure resolved like a success" family this suite is
// otherwise full of: nothing here fails. `openDocumentInbox` reads "have I already opened
// one" and mints when the answer is no, with a dozen awaits between the two — so callers
// that ask at the same time each look, each honestly finds nothing, and each mints. The
// sequential test above passes because the first call's write has landed before the second
// one reads. Reported by a consuming application: four simultaneous calls on one document
// registered THREE inboxes in 0.3s, after which the owner drained one while deposits
// arrived in another — the same end state as a fork, reached without an error anywhere.
test("concurrent opens on ONE document converge on ONE inbox", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
// Four at once — no await between them, which is what an application does when four
// components mount together and each opens the inbox of the document it renders.
const handed = await Promise.all([
openDocumentInbox(doc),
openDocumentInbox(doc),
openDocumentInbox(doc),
openDocumentInbox(doc),
]);
expect(new Set(handed).size).toBe(1);
// …and the DURABLE record has to agree, which is the half that actually bites: a second
// `AddInboxCap` pair means `readInboxCapsFor` picks one of two afterwards, and the
// published address is whichever write landed last. One pair, one address, and the
// address is what every caller was handed.
const pairs = (await readInboxCapPairs()).filter((p) => p.doc === doc);
expect(pairs.map((p) => p.inbox)).toEqual([handed[0]!]);
resetRegistryCache(); // a depositor's session, not a warmed cache
setCurrentUser("bob");
expect(await documentInboxAddress(doc)).toBe(handed[0]!);
await postToDocument(doc, { payload: { racing: true } });
setCurrentUser("alice");
expect((await readInbox(handed[0]!)).map((d) => d.payload)).toEqual([{ racing: true }]);
});
// The coalescing must not outlive the call that needed it, nor answer for a DIFFERENT
// document: a map keyed too coarsely (or never emptied) would pass the test above while
// handing document B the inbox opened for A.
test("concurrent opens on DIFFERENT documents get their own inbox each", async () => {
inject();
setCurrentUser("alice");
const a = await createEntityDoc("alice", "public");
const b = await createEntityDoc("alice", "public");
const [inboxA, inboxB] = await Promise.all([openDocumentInbox(a), openDocumentInbox(b)]);
expect(inboxA).not.toBe(inboxB);
expect((await readInboxCapPairs()).filter((p) => p.doc === a).map((p) => p.inbox)).toEqual([inboxA!]);
expect((await readInboxCapPairs()).filter((p) => p.doc === b).map((p) => p.inbox)).toEqual([inboxB!]);
// …and a LATER burst, once the record exists, still answers with the recorded inbox
// rather than treating "the in-flight map is empty" as "nobody opened one".
const again = await Promise.all([openDocumentInbox(a), openDocumentInbox(a)]);
expect(again).toEqual([inboxA!, inboxA!]);
});
// A refusal must not be cached as an answer, and must not leave a poisoned entry behind
// for the callers that follow: Bob asking four times at once gets four refusals, and
// Alice's own record is untouched.
test("concurrent opens by a NON-owner are all refused, and leave no residue", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const aliceInbox = await openDocumentInbox(doc);
resetRegistryCache(); // another session, not a warmed cache
setCurrentUser("bob");
const outcomes = await Promise.allSettled([
openDocumentInbox(doc),
openDocumentInbox(doc),
openDocumentInbox(doc),
openDocumentInbox(doc),
]);
expect(outcomes.map((o) => o.status)).toEqual(["rejected", "rejected", "rejected", "rejected"]);
setCurrentUser("alice");
expect((await readInboxCapPairs()).filter((p) => p.doc === doc).map((p) => p.inbox)).toEqual([aliceInbox]);
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
});
test("the inbox address is machinery: it never surfaces as the document's data", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
await write(doc, SECRET, "s1");
await openDocumentInbox(doc);
// The consumer read returns the entity's properties and nothing of the compartment
// that carries the address — the Header branch is beside the content, not in it.
const subjects = await readUnion([doc]);
const props = subjects[0]?.props ?? {};
expect(Object.keys(props)).toEqual([SECRET]);
});
// CONNECTING APPLIES WHAT WAS DEPOSITED — and the honest scope of that claim.
//
// This was called "connecting drains BOTH levels" and asserted nothing about the second.
// An adversarial review replayed it against a `connectedUser` that drained ONLY the
// user's own two inboxes: all three assertions still passed. The reason is not a weak
// test, it is that the second level currently has **no producer**: the one call that
// deposits a cap is `inbox.share(doc, toUser)`, which resolves `userInbox(toUser,
// "protected")` (`surface/inbox.ts`) — a USER's inbox, never a document's. Nothing
// published can address a cap to a document's inbox, so draining one applies nothing and
// there is nothing to observe.
//
// That the drain covers document inboxes is therefore an ANTICIPATION, and a legitimate
// one: upstream `AddInboxCap` is generic over repos (no `is_store` check,
// `engine/verifier/src/verifier.rs:1916-1930`) and `InboxMsgContent::Link` exists as a
// variant. What is NOT legitimate is a test title asserting a property no code exercises.
// So this test states what it proves; the anticipation is named, not dressed up.
test("connecting applies the Links waiting for me, and leaves consumer deposits alone", async () => {
inject();
setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected");
const pubDoc = await createEntityDoc("alice", "public");
const docInbox = await openDocumentInbox(pubDoc);
// Two deposits, one at each level, both made by someone else.
resetRegistryCache();
setCurrentUser("carol");
const carolDoc = await createEntityDoc("carol", "protected");
await share(carolDoc, "alice"); // a Link, into ALICE's own inbox — the only cap path
await post(docInbox, { payload: { onTheDocument: true }, ts: 2 }); // consumer data
// Alice connects: one call, and she calls nothing to "receive".
setCurrentUser("alice");
await connectedUser();
expect(hasCap(carolDoc)).toBe(true); // the Link was applied
expect(await readValues([protDoc], SECRET)).toEqual([]); // (protDoc holds no secret here)
const left = await readInbox(docInbox);
expect(left.map((d) => d.payload)).toEqual([{ onTheDocument: true }]); // consumer data stays
});
// The same resolution property one level up: a user's own inbox.
//
// REGRESSION (2026-08-10, found adversarially). This test used to pass on the module
// CACHE: `userInbox` keys by (account, scope) regardless of who is asking, so Bob hit the
// entry Alice had just warmed. Nothing about persistence was exercised — the fake did not
// even answer the shim query — so in a second SESSION (or a second browser page, which is
// what the applicative e2e runs) Bob would have got a DIFFERENT inbox, and his deposit
// would have gone where nobody reads. That is the exact failure this library already paid
// for once. Dropping the cache between the two actors is what makes it a real test.
test("a third party resolves another user's inbox, from the shim and not from a cache", async () => {
inject();
setCurrentUser("alice");
const aliceView = await userInbox("alice", "protected");
resetRegistryCache(); // Bob is another session: nothing of Alice's is in memory
setCurrentUser("bob");
const bobView = await userInbox("alice", "protected");
expect(bobView).toBe(aliceView);
});