0b936d2119
Suite de la revue adverse. Quatre trous de frontière, tous hors du champ « l'isolation est fausse jusqu'à P1b » — P1b parle de matériau de clé, ceux-ci sont des défauts de FORME et resteraient des trous avec une vraie clé. **La garde d'écriture reposait sur la mauvaise question.** Elle demandait « ce cap m'a-t-il été servi par un store public ? ». Ce prédicat était faux dans les deux sens à la fois : trop laxiste — une clé reçue dans une inbox donnait l'écriture, alors qu'en amont un Link est « external repos only » et qu'écrire est l'appartenance au repo ; trop strict — la propriétaire de son propre document public était refusée dès qu'elle l'ouvrait depuis sa référence avant que son store ne soit listé. Un prédicat poussé dans deux sens est le signe que c'était le mauvais prédicat. Écrire dépend désormais de la PROPRIÉTÉ, lue sur la branche Store (l'`AddRepo` émulé), plus la paternité de session pour les documents créés par la primitive brute qui n'a aucun store où s'inscrire. Conséquence assumée et documentée : seul le propriétaire écrit, ce qui est l'état amont d'un repo tant qu'aucun membre n'a été ajouté — mécanisme qu'on n'émule pas. **`docs.depositInto` quittait la frontière en la publiant.** Sa doc disait « `inbox.post` est le seul appelant » : vrai dans la bibliothèque, faux dès qu'on le publie. Démontré : avec la seule référence nue d'un document public, on réécrit l'adresse d'inbox posée dessus et on détourne les dépôts destinés à son propriétaire. Une porte qui saute une garde ne doit pas être ouvrable par une application — elle rejoint la machinerie. **Le filtre de lecture n'interceptait que trois membres** et transmettait tout le reste lié à la CIBLE : `.values()`, `.map()`, `.getById()` rendaient le contenu d'un autre utilisateur — précisément les membres qu'une API de set réactif met en avant. Les membres qui rendent des éléments sont désormais filtrés, les mutations passent (elles ne rendent rien), et **tout membre inconnu lève** au lieu de transmettre : une transmission est une fuite silencieuse, une levée est bruyante et greppable. **Le mémo du store public était par document.** Le premier demandeur déclenchait le téléchargement, le cap était classé chez LUI, et tout demandeur suivant recevait « oui » en ne détenant rien. En amont un broker qui sert un overlay externe répond à TOUS. Le mémo garde la valeur, l'appelant la classe pour qui est connecté. Aussi : l'exemption `declareInfrastructure` supprimée — zéro appelant, ensemble toujours vide, et une doc décrivant deux documents exemptés qui ne l'ont jamais été. Et les caps d'écriture décrits comme « partiels » sont dits **inertes**, ce qu'ils sont : `grantWrite` n'a aucun appelant de production. **Ce que l'e2e a rattrapé.** Ma première version de la garde refusait au créateur l'écriture sur un document fait par `docs.docCreate` — 7 étapes rouges contre le broker, après une suite unitaire restée verte. La primitive brute n'inscrit la paternité nulle part ; c'est ce que `mintedHere` couvre désormais. 185 tests unitaires (dont quatre régressions : la propriétaire écrit, le destinataire non, le store public sert tout demandeur, aucun membre non filtré ne transmet), e2e 40/40 et applicatif 10/10.
217 lines
9.4 KiB
TypeScript
217 lines
9.4 KiB
TypeScript
/**
|
|
* reach.test.ts — the virtual user boundary, at the passage points.
|
|
*
|
|
* A virtual user must simulate the boundary of the future single-user wallet: the
|
|
* access functions are confined to the user currently connected, and no cross-user
|
|
* access is permitted. Before this, `docs.sparqlQuery`/`sparqlUpdate` — both
|
|
* exported from the SDK entry — reached ANY document of ANY identity given a
|
|
* session id and a NURI.
|
|
*
|
|
* The one act that legitimately crosses: DEPOSITING into someone's inbox. It is
|
|
* how a link travels between users at all, and it gives the depositor nothing back.
|
|
*/
|
|
import { test, expect, mock, afterAll } from "bun:test";
|
|
import { sparqlQuery, sparqlUpdate } from "../src/surface/docs";
|
|
import { depositInto } from "../src/emulated-verifier/register-write";
|
|
import { createEntityDoc, resetRegistryCache, userInbox } from "../src/shared-wallet/account-registry";
|
|
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
|
import { configure } from "../src/index";
|
|
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
|
|
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
|
|
import { mayReach, mustNotAttempt } from "../src/emulated-verifier/reach";
|
|
import { hasReadCap } from "../src/model/nuri";
|
|
|
|
afterAll(() => {
|
|
resetConfig();
|
|
resetStoreRegistry();
|
|
resetCaps();
|
|
setCurrentUser(null);
|
|
});
|
|
|
|
const SESSION: RegistrySession = { sessionId: "sid-reach", privateStoreId: "PRIV-REACH" };
|
|
|
|
function inject() {
|
|
let n = 0;
|
|
const quads: Array<{ g: string; s: string; p: string; o: string }> = [];
|
|
const ng = {
|
|
doc_create: mock(async () => `did:ng:o:reach${++n}`),
|
|
sparql_update: mock(async (...a: unknown[]) => {
|
|
quads.push({ g: String(a[2]), s: "", p: "", o: String(a[1]) });
|
|
return undefined;
|
|
}),
|
|
sparql_query: mock(async () => ({ results: { bindings: [] } })),
|
|
};
|
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
|
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
|
|
resetRegistryCache();
|
|
resetCaps();
|
|
setCurrentUser(null);
|
|
return { ng, quads };
|
|
}
|
|
|
|
const READ = "SELECT ?s ?p ?o WHERE { ?s ?p ?o }";
|
|
|
|
test("the guard is inert until the first cap exists (no regression for a cap-free consumer)", async () => {
|
|
const { ng } = inject();
|
|
// Nothing has been created, so no cap has been issued: everything flows.
|
|
expect(mayReach("did:ng:o:anything")).toBe(true);
|
|
await sparqlQuery(SESSION.sessionId, READ, undefined, "did:ng:o:anything");
|
|
expect(ng.sparql_query).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("once caps exist, a document outside the connected user's reach is refused — read AND write", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const mine = await createEntityDoc("alice", "private");
|
|
|
|
// Mine: reachable.
|
|
expect(mayReach(mine)).toBe(true);
|
|
await sparqlQuery(SESSION.sessionId, READ, undefined, mine);
|
|
|
|
// A well-formed NURI I hold nothing for: named, unreachable. Both directions.
|
|
const theirs = "did:ng:o:someone-elses-doc" as const;
|
|
expect(mayReach(theirs)).toBe(false);
|
|
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, theirs)).rejects.toThrow(
|
|
/does not hold this document.s cap/i,
|
|
);
|
|
await expect(
|
|
sparqlUpdate(SESSION.sessionId, "INSERT DATA { <a> <b> \"c\" }", theirs),
|
|
).rejects.toThrow(/does not hold this document.s cap/i);
|
|
});
|
|
|
|
test("the boundary follows the connected user — one user's document is another's forbidden NURI", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const aliceDoc = await createEntityDoc("alice", "private");
|
|
setCurrentUser("bob");
|
|
const bobDoc = await createEntityDoc("bob", "private");
|
|
|
|
expect(mayReach(bobDoc)).toBe(true);
|
|
expect(mayReach(aliceDoc)).toBe(false); // bob is connected
|
|
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, aliceDoc)).rejects.toThrow();
|
|
|
|
setCurrentUser("alice");
|
|
expect(mayReach(aliceDoc)).toBe(true);
|
|
expect(mayReach(bobDoc)).toBe(false);
|
|
});
|
|
|
|
test("a user reaches its OWN stores and inbox — the boundary must not lock it out of itself", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
await createEntityDoc("alice", "protected"); // provisions alice's account
|
|
const inbox = await userInbox("alice", "protected");
|
|
|
|
expect(mayReach(inbox)).toBe(true);
|
|
await sparqlQuery(SESSION.sessionId, READ, undefined, inbox);
|
|
|
|
// …and not another user's inbox.
|
|
setCurrentUser("bob");
|
|
expect(mayReach(inbox)).toBe(false);
|
|
});
|
|
|
|
test("DEPOSITING into another user's inbox crosses the boundary, and gives nothing back", async () => {
|
|
const { ng } = inject();
|
|
setCurrentUser("bob");
|
|
const bobInbox = await userInbox("bob", "protected");
|
|
|
|
setCurrentUser("alice");
|
|
await createEntityDoc("alice", "private"); // alice now holds caps → guard is armed
|
|
expect(mayReach(bobInbox)).toBe(false); // she holds no cap for it
|
|
|
|
// The deposit goes through anyway — it is the one legitimate cross-user act.
|
|
const before = ng.sparql_update.mock.calls.length;
|
|
await depositInto(SESSION.sessionId, 'INSERT DATA { <a> <b> "c" }', bobInbox);
|
|
expect(ng.sparql_update.mock.calls.length).toBe(before + 1);
|
|
|
|
// …and it grants her nothing: she still cannot read that inbox.
|
|
expect(mayReach(bobInbox)).toBe(false);
|
|
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, bobInbox)).rejects.toThrow(
|
|
/does not hold this document.s cap/i,
|
|
);
|
|
});
|
|
|
|
test("the shim is reached by the MACHINERY, not by an exemption in the boundary", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
await createEntityDoc("alice", "private"); // arms the emulation, resolves the shim
|
|
|
|
// The store-root and the doc-shim are NOT reachable through the virtual-user
|
|
// surface — there is no exemption list any more. The machinery reaches them
|
|
// through its own primitives (`physical.ts`), which the boundary never sees and
|
|
// which are never exported from the package.
|
|
expect(mayReach(`did:ng:${SESSION.privateStoreId}`)).toBe(false);
|
|
await expect(
|
|
sparqlQuery(SESSION.sessionId, READ, undefined, `did:ng:${SESSION.privateStoreId}`),
|
|
).rejects.toThrow(/does not hold this document's cap/i);
|
|
|
|
// …yet the registry works, because it never asked through that door.
|
|
const doc = await createEntityDoc("alice", "protected");
|
|
expect(mayReach(doc)).toBe(true);
|
|
});
|
|
|
|
// The two rules are deliberately redundant, and this is what that buys.
|
|
test("rule 1 and rule 2 are independent — the guard still holds if a caller forgets to check", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
await createEntityDoc("alice", "private"); // arms the emulation
|
|
const theirs = "did:ng:o:not-mine" as const;
|
|
|
|
// RULE 2 — a caller that checks first simply does not issue the operation.
|
|
expect(mustNotAttempt(theirs)).toBe(true);
|
|
|
|
// RULE 1 — and a caller that does NOT check is refused anyway. This is the whole
|
|
// point of implementing the same criterion in two places: rule 2 is where the
|
|
// model lives (you cannot address what you hold no cap for), rule 1 is what makes
|
|
// a lapse in rule 2 fail loudly instead of quietly succeeding.
|
|
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, theirs)).rejects.toThrow(
|
|
/does not hold this document's cap/i,
|
|
);
|
|
});
|
|
|
|
// Possession decides, not the shape of the reference the caller happens to hold.
|
|
test("a BARE reference is reachable when the cap is possessed elsewhere", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const doc = await createEntityDoc("alice", "private");
|
|
|
|
// `doc` is the bare form — it carries no cap — yet alice possesses that cap, so
|
|
// reaching it is legitimate. Manipulating a bare NURI is normal: references travel
|
|
// bare through content and indexes while the cap sits in what the user holds.
|
|
expect(hasReadCap(doc)).toBe(false);
|
|
expect(mayReach(doc)).toBe(true);
|
|
await sparqlQuery(SESSION.sessionId, READ, undefined, doc);
|
|
|
|
// The cap-bearing form of the same document answers alike.
|
|
expect(mayReach(`${doc}:r:OK`)).toBe(true);
|
|
|
|
// And bob, holding neither, cannot reach it in either form.
|
|
setCurrentUser("bob");
|
|
expect(mayReach(doc)).toBe(false);
|
|
expect(mayReach(`${doc}:r:OK`)).toBe(false);
|
|
});
|
|
|
|
// The whole point of splitting the machinery out: one API is the app's, the other
|
|
// must never be. A regression here is silent and total — an app holding the
|
|
// machinery reaches every virtual user's documents.
|
|
test("the machinery is NOT part of the package's public surface", async () => {
|
|
const entry: Record<string, unknown> = await import("../src/index");
|
|
|
|
for (const name of Object.keys(entry)) {
|
|
expect(name).not.toMatch(/^physical/);
|
|
}
|
|
// Named explicitly, so adding one and forgetting the rule fails here.
|
|
for (const forbidden of ["physicalQuery", "physicalUpdate", "physicalCreate", "subscribePhysicalDoc"]) {
|
|
expect(entry[forbidden]).toBeUndefined();
|
|
}
|
|
// …and the machinery accessors the merged entry deliberately stopped publishing
|
|
// (2026-08-07): internal wiring and test resets are reached by their internal path.
|
|
for (const unpublished of ["getConfig", "getStoreRegistryDeps", "resetConfig", "resetStoreRegistry", "resetCaps", "getCaps", "getCurrentUser"]) {
|
|
expect(entry[unpublished]).toBeUndefined();
|
|
}
|
|
// The cross-account fan-out is gone from the registry entirely.
|
|
const registry = entry.storeRegistry as Record<string, unknown>;
|
|
for (const gone of ["listEntityDocs", "resolveReadGraphs", "allAccounts", "loadShim"]) {
|
|
expect(registry[gone]).toBeUndefined();
|
|
}
|
|
});
|