44a9b6ee04
Lot D de la revue adverse. Aucun changement de comportement de la bibliothèque : ce sont
les tests qui mentaient, et deux faux `ng` qui fabriquaient un état que le vrai broker ne
produit pas.
**« Un tiers résout l'inbox d'un autre utilisateur » prouvait le CACHE.** `userInbox`
indexe par (compte, portée) sans regarder qui demande, donc Bob tombait sur l'entrée que
la session d'Alice venait de chauffer. Rien de la persistance n'était exercé — le faux ne
servait même pas la requête `docInbox` — si bien que dans une seconde SESSION, ou une
seconde page de navigateur comme en pilote la suite applicative, Bob aurait obtenu une
inbox DIFFÉRENTE et son dépôt serait parti où personne ne lit. C'est la panne que cette
bibliothèque a déjà payée une fois.
Deux causes, toutes deux dans les faux : la requête `docInbox` n'était servie nulle part,
et le faux de `cross-user-access` prenait le **nom de graphe** pour le sujet dans un
`INSERT DATA { GRAPH <g> { … } }` — donc le pointeur du shim n'était jamais retrouvé et
chaque résolution à froid créait un nouveau shim. Les deux corrigées, et les tests qui
franchissent une frontière d'identité purgent maintenant le cache à la frontière.
**Mon propre index d'inbox avait le même défaut**, découvert en faisant ça : `isKnownInbox`
ne répondait que par sa mémoire, parce qu'aucun faux ne servait la requête. La moitié
durable n'était pas exercée — exactement la faute que ce lot corrigeait ailleurs.
**« Connecting drains BOTH levels » n'observait pas le second niveau.** Rejoué contre un
`connectedUser` qui ne draine que les inbox de l'utilisateur, il restait vert. La raison
n'est pas un test faible : le second niveau n'a **aucun producteur**. Le seul appel qui
dépose un cap est `inbox.share(doc, toUser)`, qui résout l'inbox d'un UTILISATEUR, jamais
celle d'un document. Drainer une inbox de document n'applique donc rien. Le test dit
désormais ce qu'il prouve, et l'anticipation est nommée comme telle : en amont
`AddInboxCap` est générique sur les repos et `InboxMsgContent::Link` existe, donc viser
cela est légitime — annoncer que c'est exercé ne l'était pas.
**« La liste de Bob ne contient pas la note d'Alice » n'avait pas de contrôle positif.**
Bob n'écrivait jamais de note publique : sa liste était vide quoi qu'il arrive. Il en
écrit une maintenant, et la vérification symétrique est ajoutée. Au passage, `showScope`
lisait le DOM avant le rendu — le gestionnaire `change` de l'application lance
`refresh()` sans l'attendre.
189 tests unitaires, e2e 40/40 et applicatif 12/12.
687 lines
31 KiB
TypeScript
687 lines
31 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 } 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 }]);
|
|
});
|
|
|
|
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);
|
|
});
|