Files
ng-eventually/packages/polyfill/test/cross-user-access.test.ts
T
Sylvain Duchesne 0c21adebf2 feat!: retirer readUnion de la surface publiee, et lire un document par requete ancree
readUnion etait N requetes ancrees plus une politique de lot : une lecture
qui echoue avalee en reponse vide, un document dont on ne detient pas la cle
ecarte au lieu d'etre refuse, un regroupement en sacs de proprietes, et le
filtrage du prefixe reserve. Rien en amont ne repond a cette composition, et
son annotation citait sparql_query -- une citation exacte attachee a une
affirmation fausse. Une composition ne s'aligne pas sur une fonction.

Ce qui reste est ce qu'il y avait dessous : docs.sparqlQuery ancre sur un
document, qui va deja chercher la cle servie par le magasin public -- donc
une reference nue suffit toujours pour lire un document public, sans rien
changer a useShapeQuery. Lire plusieurs documents, c'est cet appel une fois
par document, et la tolerance a une lecture qui echoue revient a l'appelant,
la ou l'amont la laisse : une requete ancree sur un depot que le verifier ne
detient pas rejette.

Cote src, l'unique lecture interne restante devient read-document.ts : un
document, une requete ancree, precedee de l'ouverture du depot -- barriere
que index-deposit consulte pour distinguer une absence d'un depot non
synchronise. Le harnais e2e et l'exemple composent la leur, avec leur catch
visible au point d'appel.

Version majeure : 1.0.0-dev.8.
2026-09-21 14:08:05 +02:00

890 lines
41 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 { insertObject } from "../src/surface/orm";
import type { InsertObjectLike } from "../src/model/types";
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
import { post, postToDocument, read as readInbox } from "../src/surface/inbox";
import { readDocumentSubjects } from "../src/surface/read-document";
import { sparqlQuery, 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;
}
// Retiring a DEPOSIT: every triple of one subject, whatever predicates it carries
// (`emulated-verifier/inbox-consume.removeDeposit`). Modelled rather than ignored — a
// fake that let this delete fall through would leave every deposit in place, and a
// suite asserting convergence would pass on a queue that never shrinks.
const delDeposit = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+\?\w+\s+\?\w+\s*\}/);
if (delDeposit) {
const subject = delDeposit[1]!;
for (let i = quads.length - 1; i >= 0; i--) {
const q = quads[i]!;
if (q.s === subject) 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) ?? {};
// The deposit's own subject — the key a drain names to retire it.
rec.d = 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 }> = { d: { value: r.d! }, 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 } })) } };
}
// The anchored per-document read (`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 };
}
/** The SDK's `insertObject`, stood in for - and a record of whether it was reached. */
const ormInserts: unknown[][] = [];
const fakeInsertObject: InsertObjectLike = <T extends BaseType>(
shapeType: ShapeType<T>,
object: T,
): Promise<T> => {
ormInserts.push([shapeType, object]);
return Promise.resolve(object);
};
function inject() {
const ng = makeFakeNg();
ormInserts.length = 0;
configure({ ng: ng as any, useShape: (() => {}) as any, insertObject: fakeInsertObject });
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 `doc`, as the current holder reads them.
*
* ONE document, one anchored query - what `readUnion` did per entry before it was
* removed in `1.0.0-dev.8`. The difference that shows up all over this file: a document
* the holder may NOT reach used to be dropped from the batch and read as `[]`, and now
* the read is REFUSED. Both say "you cannot read this"; only the second says it out
* loud, which is why {@link expectRefused} exists rather than another `toEqual([])`.
*/
async function readValues(doc: Nuri, p: string): Promise<string[]> {
const subjects = await readDocumentSubjects(doc);
return subjects.flatMap((s) => s.props[p] ?? []);
}
/** Assert the current holder is REFUSED `doc`: the answer to "I hold no cap for this". */
async function expectRefused(doc: Nuri, p: string): Promise<void> {
await expect(readValues(doc, p)).rejects.toThrow(/does not hold this document/i);
}
/**
* 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);
await expectRefused(ref, SECRET);
});
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");
// Obtained under BOB, so what is compared below is each holder's own answer to the
// same question. His is a refusal; hers is the content.
const bobIsRefused = await readValues(protDoc, SECRET).then(
() => false,
() => true,
);
setCurrentUser("charlie");
await readInbox(CHARLIE_INBOX);
const charlieSees = await readValues(protDoc, SECRET);
expect(bobIsRefused).toBe(true);
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.
await expectRefused(ref, SECRET);
// 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;
// While the cap is still missing the read is REFUSED rather than empty, and a reader
// keeps what it had - which is what any real view does, and what makes `latest`
// meaningful when the cap finally lands.
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.
//
// It is also the property `readUnion`'s removal rests on. `readUnion([doc])` was how a
// stranger reached a public index from its reference alone; what actually did the work
// was the anchored `docs.sparqlQuery` underneath, which fetches the public store's key
// at its own door (`surface/docs.ts`). This test exercises exactly that path with no
// batch above it, which is why the key fetch is asserted here rather than assumed.
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 — no key, for either of them.
expect(getCaps().isEnforcing()).toBe(true);
expect(hasCap(pubDoc)).toBe(false);
expect(hasCap(protDoc)).toBe(false);
expect((await readValues(pubDoc, REFERS_TO)).length).toBe(1);
// …and the key he now holds for it came from the DOCUMENT, on the way through the
// anchored read. Nothing in this file handed it to him.
expect(hasCap(pubDoc)).toBe(true);
await expectRefused(protDoc, SECRET);
// 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: a SUBJECT beside the data, never mixed into it", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
await write(doc, SECRET, "s1");
await openDocumentInbox(doc);
// Both halves are asserted, because `readUnion`'s removal changed which one an
// APPLICATION sees. The compartment is a distinct reserved SUBJECT, so it is never
// mixed into the entity's properties whichever read is used - that is the property
// that protects the data, and it is structural.
const subjects = await readDocumentSubjects(doc);
const props = subjects[0]?.props ?? {};
expect(Object.keys(props)).toEqual([SECRET]);
// The filter that DROPS it, however, lives in this library's internal read. A raw
// anchored `docs.sparqlQuery` - which is what an application composes now that
// `readUnion` is gone - returns the Header-branch rows too. That is not a gain of
// rights: the two registers an entity document carries (`inboxAddress`,
// `exposedReadCap`) are the compartment meant for what any reader may see, and the
// cap-bearing `inboxCap` register lives in its owner's PRIVATE store, not here.
const raw = await sparqlQuery(
SESSION.sessionId,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined,
doc,
"test",
);
const rawSubjects = (raw as { results: { bindings: Array<{ s: { value: string } }> } })
.results.bindings.map((b) => b.s.value);
expect(rawSubjects).toContain(`${SHIM}:headerBranch`);
});
// 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);
});
// The same asymmetry on the ORM's write, and it is the one an application is most
// likely to meet: `insertObject` needs the document's `@graph`, which a recipient HAS -
// the reference travelled with the key. Holding the key is what lets Bob read; it is
// not what would let him write, and upstream it never was: `verify_permission` fires on
// the commit and asks membership of the repo, never how the reader came by the key.
//
// Bob OBTAINS the capability the only way an application can - Alice deposits it, he
// connects - so what he holds here is what he would really hold.
test("a cap received in an inbox does not let the ORM insert into that document", async () => {
inject();
const { protDoc } = await aliceSetsUpHerDocuments();
await userInbox("bob", "protected"); // Bob exists - `share` refuses a recipient nobody is
setCurrentUser("alice");
await share(protDoc, "bob");
setCurrentUser("bob");
await connectedUser();
expect(hasCap(protDoc)).toBe(true); // he reads it...
const shape = { shape: "urn:e2e:S", schema: {} } as ShapeType<BaseType>;
// ...and the insert is refused before it is attempted.
await expect(
insertObject(shape, { "@id": "", "@graph": protDoc } as BaseType),
).rejects.toThrow(/refused/i);
expect(ormInserts).toHaveLength(0);
// Alice, who owns it, writes through the same call.
setCurrentUser("alice");
await insertObject(shape, { "@id": "", "@graph": protDoc } as BaseType);
expect(ormInserts).toHaveLength(1);
});