Files
ng-eventually/packages/client/test/isolation-active.test.ts
T
Sylvain Duchesne 0832338201 feat: un document en store public sert son ReadCap, une référence nue suffit
Le modèle amont est explicite dans `PublicRepoLinkV0` : le lien ne porte AUCUN
`read_cap`, et son commentaire dit pourquoi — *"The latest ReadCap of the branch
will be downloaded from the outerOverlay, if the peer brokers listed below allow
it […] the public site are served differently by brokers"*
(engine/net/src/types.rs:5098). La clé n'est pas remise par un émetteur : elle est
donnée par le réseau à qui la demande, parce que le broker a épinglé l'overlay
externe (`expose_outer`).

La bibliothèque refusait jusqu'ici la forme sans cap quel que soit le store. Sûr
dans le bon sens, mais une application ne pouvait pas exprimer « fais circuler, la
référence suffit » — le seul acte que le modèle rend gratuit — et son unique
contournement était de distribuer la clé, ce qui détruit la confidentialité
composable.

`emulated-verifier/public-store.ts` émule le mécanisme SANS toucher à la garde. La
possession reste l'unique critère : un document public est lisible non par exception
mais parce que son cap est *obtenable*. Chaque porte de lecture demande d'abord
(`readUnion`, `docs.sparqlQuery`, `ensureRepoOpen`, `documentInboxAddress`), puis le
chemin ordinaire s'applique.

Lire n'est pas écrire. Ce que le store sert est un droit de LECTURE :
`learnFromPublicStore` le classe à part et `assertMayWrite` refuse l'écriture
dessus. Sans cela une référence nue achetait une écriture, ce qu'aucun store amont
n'accorde.

Autres conséquences :

- `recordInPublicStore` (marquer + frapper) devient `markInPublicStore` (marquer).
  Frapper un second cap à côté de celui qu'on vient de télécharger donnerait deux
  clés différentes le jour où la constante devient un secret.
- `hasCap` quitte la porte polyfill : il se lisait « ai-je le droit de lire ceci ? »
  et un document public y répondait `false` jusqu'à ce qu'on demande son cap. Aucun
  appelant hors des tests.
- Les tests cross-user ne font plus traverser de cap par une variable JS : Bob
  n'obtient que la référence nue, comme une vraie application.

Écarts documentés plutôt que masqués : le pari sur un modèle DÉCLARÉ (`expose_outer`
est câblé à `false` côté client et `ExtTopicSyncReq` est `unimplemented!()`), la
découverte limitée à ce qu'on sait déjà nommer, `useShape` qui n'a pas d'await à
dépenser, et l'absence de `locator`.

179 tests unitaires, e2e 42/42 contre le broker en ligne.
2026-08-06 19:55:32 +02:00

417 lines
17 KiB
TypeScript

/**
* ReadCap ACTIVE — end-to-end proof that the emulated SDK enforces per-DOCUMENT
* isolation, driven by per-entity documents + KEY POSSESSION.
*
* Mirrors what the app does: create an entity document through the REAL registry
* (`createEntityDoc`) — which files its cap in the creator's held caps, the emulated
* `AddRepo { read_cap }` — and, when the app decides two identities are related,
* SHARE that one document's cap to the other's inbox (`shareCap`). The recipient
* needs no dedicated operation: processing their inbox absorbs it.
*
* What the read filter then shows:
* (a) a document nobody shared is unreadable, and stays unreadable for a third
* party after a share to someone else — sharing is per-document, per-inbox;
* (b) the read-filtered VIEW decides on possession alone — it is synchronous, so it
* asks no store anything (a public store WOULD serve its cap; that is proven on
* the read paths, in `cross-user-access.test.ts`);
* (c) switching identity SWITCHES heldByHolder — it never wipes one.
*/
import { getCaps } from "../src/shared-wallet/bootstrap";
import { test, expect, mock, afterAll } from "bun:test";
import { createEntityDoc, resetRegistryCache, userInbox, listMyEntityDocs } from "../src/shared-wallet/account-registry";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import type { Nuri, ReadCap } from "../src/model/types";
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,resetCaps,setCurrentUser,share} from "../src/polyfill";
import { read as readInbox } from "../src/surface/inbox";
import { filterReadable } from "../src/emulated-verifier/read-filter";
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
setCurrentUser(null);
});
const SESSION: RegistrySession = { sessionId: "sid", privateStoreId: "PRIV" };
const SHIM = "urn:ng-eventually:shim";
const INBOX = "urn:ng-eventually:inbox";
/** Possession, asked of the internal registry — see `polyfill.ts` on why the door
* stopped publishing it. */
function hasCap(nuri: Nuri): boolean {
return getCaps().capFor(nuri) !== undefined;
}
interface Quad { g: string; s: string; p: string; o: string }
/** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */
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` serving BOTH the shim SPARQL and the inbox SPARQL. */
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;
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
let g: string;
let body: string;
if (gm) {
g = gm[1]!;
body = gm[2]!;
} else {
if (!anchor) return undefined;
g = anchor;
body = 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, 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;
// Pointer SELECT (store-root → doc-shim).
if (query.includes(`<${SHIM}:shimDoc>`)) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`)
.map((q) => ({ shimDoc: { value: q.o } }));
return { results: { bindings } };
}
// Account SELECT.
if (query.includes(`<${SHIM}:id>`)) {
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
const onlySubject = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) 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);
}
const 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 ?? "" },
}));
return { results: { bindings } };
}
// Inbox deposit SELECT.
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);
}
const 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;
});
return { results: { bindings } };
}
// 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 } })) } };
}
// 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>`)) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === `${SHIM}:link`)
.map((q) => ({ c: { value: q.o } }));
return { results: { bindings } };
}
// Scope-index `contains` SELECT.
if (query.includes(`<${SHIM}:contains>`)) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`)
.map((q) => ({ e: { value: q.o } }));
return { results: { bindings } };
}
return { results: { bindings: [] } };
});
return { doc_create, sparql_update, sparql_query, _quads: quads };
}
function inject(normalizeId: (id: string) => string = (id) => id.trim()) {
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId });
resetRegistryCache();
resetCaps();
setCurrentUser(null);
return ng;
}
/** The items an ORM set would carry, one per document. */
const item = (doc: string, id: string) => ({ "@graph": doc, "@id": id });
/** What the current holder reads out of `items`. */
const view = (items: Array<{ "@graph": string; "@id": string }>) =>
filterReadable(items, getCaps()).map((i) => i["@id"]).sort();
test("a created document is readable by its creator and by nobody else", async () => {
inject();
setCurrentUser("alice");
const aliceDoc = await createEntityDoc("alice", "private");
setCurrentUser("bob");
const bobDoc = await createEntityDoc("bob", "private");
const items = [item(aliceDoc, "a1"), item(bobDoc, "b1")];
setCurrentUser("alice");
expect(view(items)).toEqual(["a1"]);
setCurrentUser("bob");
expect(view(items)).toEqual(["b1"]);
setCurrentUser(null);
expect(view(items)).toEqual([]); // anonymous holds nothing
expect(getCaps().isEnforcing()).toBe(true);
});
// (a) Sharing is per-document AND per-recipient: a share to bob leaves carol out.
test("(a) sharing one document's cap to ONE inbox reveals it there, and only there", async () => {
inject();
setCurrentUser("alice");
const shared = await createEntityDoc("alice", "protected");
const kept = await createEntityDoc("alice", "protected");
const items = [item(shared, "s1"), item(kept, "k1")];
// BEFORE the share: bob reads nothing of alice's.
setCurrentUser("bob");
expect(view(items)).toEqual([]);
// The app decides alice↔bob are related: alice shares ONE document's cap into
// bob's OWN inbox — the only cross-wallet act there is.
const bobInbox = await userInbox("bob", "protected");
setCurrentUser("alice");
await share(shared, "bob");
// bob processes his inbox — no dedicated "receive" operation exists.
setCurrentUser("bob");
await readInbox(bobInbox);
expect(view(items)).toEqual(["s1"]); // the shared one only — not `kept`
// carol, who was not shared with, still reads nothing.
setCurrentUser("carol");
await readInbox(await userInbox("carol", "protected"));
expect(view(items)).toEqual([]);
});
test("a cap deposit is absorbed, not surfaced as a consumer deposit", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
const bobInbox = await userInbox("bob", "protected");
await share(doc, "bob");
setCurrentUser("bob");
const deposits = await readInbox(bobInbox);
expect(deposits).toEqual([]); // infrastructure, not consumer data
expect(hasCap(doc)).toBe(true); // …but it landed in bob's held caps
});
// (b) The ORM read filter is PURE POSSESSION — it asks nothing of anyone.
//
// Note what this does NOT say: that a bare reference to a public document is
// unreadable. It is readable, through the read paths, because a public store serves
// its cap (`emulated-verifier/public-store.ts`, and `cross-user-access.test.ts` proves
// it). This filter sits below that: it is synchronous, it decides from what the holder
// holds AT THAT MOMENT, and a document whose cap was never obtained is filtered out
// whatever store it sits in. The library's own read paths ask first; the reactive ORM
// view has no door to ask through, and that limit is recorded in `read-filter.ts`.
test("(b) the read-filtered view decides on possession alone, with no lookup", async () => {
inject();
setCurrentUser("alice");
const pub = await createEntityDoc("alice", "public");
const items = [item(pub, "u1")];
expect(getCaps().isInPublicStore(pub)).toBe(true);
const cap = getCaps().capFor(pub)!;
// bob HAS the document's bare NURI (it is right there in `items`), holds no cap for
// it, and the view drops it — no question asked of any store.
setCurrentUser("bob");
expect(view(items)).toEqual([]);
// Once the cap IS among what he holds — however it got there — the same view yields it.
getCaps().learn(cap);
expect(view(items)).toEqual(["u1"]);
});
// (c) Identity change switches heldByHolder; it does not wipe them.
test("(c) switching identity switches heldByHolder — a returning identity keeps its caps", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
expect(hasCap(doc)).toBe(true);
setCurrentUser("bob");
expect(hasCap(doc)).toBe(false);
setCurrentUser("alice");
expect(hasCap(doc)).toBe(true); // durable across the switch — nothing re-declared
});
// A virtual user IS a shim account, and the shim keys accounts through the
// consumer's `normalizeId`. The held caps must key the SAME way: otherwise an app
// that spells its own identity differently between two calls ("@Alice" at login,
// "alice" later) gets a second held caps and stops reading its own documents.
test("one held caps per virtual WALLET, not per spelling of its id", async () => {
inject((id) => id.trim().replace(/^@+/, "").toLowerCase());
setCurrentUser("@Alice");
const doc = await createEntityDoc("@Alice", "protected");
expect(hasCap(doc)).toBe(true);
// Same account, spelled differently — same shim account, so the same held caps.
setCurrentUser("alice");
expect(hasCap(doc)).toBe(true);
setCurrentUser(" ALICE ");
expect(hasCap(doc)).toBe(true);
// A genuinely different account still holds nothing.
setCurrentUser("bob");
expect(hasCap(doc)).toBe(false);
});
// THE BREACH P1a OPENED. Caps travel as inbox deposits, so an unguarded inbox read
// let anyone who knew an inbox NURI collect the caps addressed to its owner —
// defeating directed sharing entirely. Depositing stays open (it is the only way a
// link crosses between wallets at all); reading does not.
test("an inbox may be DEPOSITED into by anyone, and READ only by its owner", async () => {
inject();
setCurrentUser("alice");
const secret = await createEntityDoc("alice", "protected");
const bobInbox = await userInbox("bob", "protected");
// Alice deposits into bob's inbox — allowed, and it grants her nothing back.
await share(secret, "bob");
await expect(readInbox(bobInbox)).rejects.toThrow(/does not belong to the connected wallet/i);
expect(hasCap(secret)).toBe(true); // still hers, obviously
// Mallory knows the NURI of bob's inbox and tries to pocket what is in it.
setCurrentUser("mallory");
await expect(readInbox(bobInbox)).rejects.toThrow(/does not belong to the connected wallet/i);
expect(hasCap(secret)).toBe(false); // nothing was absorbed
// Anonymous owns no inbox at all.
setCurrentUser(null);
await expect(readInbox(bobInbox)).rejects.toThrow(/no identity is set/i);
// Bob reads his own, and only then does the cap land.
setCurrentUser("bob");
await readInbox(bobInbox);
expect(hasCap(secret)).toBe(true);
});
test("a fresh session rebuilds the held caps from the scope index (the emulated AddRepo)", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
const items = [item(doc, "p1")];
// Simulate a new session over the same wallet: caps are in memory, so they go —
// the registry cache too. Only the persisted documents remain.
resetCaps();
resetRegistryCache();
expect(view(items)).toEqual([]);
// Listing my own documents refiles their caps: this is the store branch that
// carries `AddRepo { read_cap }` upstream.
const { listMyEntityDocs } = await import("../src/shared-wallet/account-registry");
expect(await listMyEntityDocs("alice", "protected")).toEqual([doc]);
expect(view(items)).toEqual(["p1"]);
});
// The Store branch exists so a cap is READ back, not recomputed. Without this test
// the two are indistinguishable: with a stand-in value, re-minting happens to give
// the same string. So corrupt the stored cap and check the corruption wins — proof
// the value comes from the store, and proof that P1b's real key will too.
test("a document's cap is READ from the Store branch, never recomputed", async () => {
const ng = inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
// The store recorded `AddRepo { read_cap }` beside the `contains` listing.
const stored = ng._quads.filter((q) => q.p === "urn:ng-eventually:shim:readCap");
expect(stored.length).toBe(1);
expect(stored[0]!.o).toBe(`${doc}:r:OK`);
// Rewrite it to a DIFFERENT value, then start a fresh session.
stored[0]!.o = `${doc}:r:FROM-THE-STORE`;
resetCaps();
resetRegistryCache();
expect(await listMyEntityDocs("alice", "protected")).toEqual([doc]);
// Recomputing would have produced `:r:OK`; this is what was stored.
expect(getCaps().capFor(doc)).toBe(`${doc}:r:FROM-THE-STORE` as ReadCap);
});
// The listing and the keys are separate upstream (Main vs Store branch), and the
// separation has to survive here or a document could be listed without its cap.
test("the listing and the caps are two separate records", async () => {
const ng = inject();
setCurrentUser("alice");
await createEntityDoc("alice", "private");
const subjects = new Set(ng._quads.filter((q) => q.p.startsWith("urn:ng-eventually:shim:")).map((q) => q.s));
expect(subjects.has("urn:ng-eventually:shim:index")).toBe(true); // Main branch: contains
expect(subjects.has("urn:ng-eventually:shim:storeBranch")).toBe(true); // Store branch: readCap
});
// P1b will make the stand-in value a real, non-derivable key. The moment it does,
// any path that mints a SECOND cap instead of using the stored one breaks: the
// creator would hold a key that does not open its own document. This pins that the
// creation path mints exactly once.
test("creation mints the cap ONCE — the stored value is the one held", async () => {
const ng = inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
const stored = ng._quads.find((q) => q.p === "urn:ng-eventually:shim:readCap")!;
expect(getCaps().capFor(doc)).toBe(stored.o as ReadCap); // same value, not two mints that agree by luck
});