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.
165 lines
6.2 KiB
TypeScript
165 lines
6.2 KiB
TypeScript
/**
|
|
* public-store.test.ts — the emulated *"downloaded from the outerOverlay"*, in isolation.
|
|
*
|
|
* `cross-user-access.test.ts` proves the consequence end to end (Bob reads Alice's
|
|
* public document from a bare reference). This file pins the primitive itself: what it
|
|
* asks, what it refuses, and when it says nothing at all.
|
|
*/
|
|
import { test, expect, mock, afterEach } from "bun:test";
|
|
import { exposeReadCap, fetchReadCap, resetPublicStoreFetches } from "../src/emulated-verifier/public-store";
|
|
import { mintCap } from "../src/emulated-verifier/caps";
|
|
import { getCaps } from "../src/shared-wallet/bootstrap";
|
|
import { configure } from "../src/index";
|
|
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
|
|
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
|
|
import type { Nuri } from "../src/model/types";
|
|
|
|
const SHIM = "urn:ng-eventually:shim";
|
|
const SESSION = { sessionId: "sid-ps", privateStoreId: "PRIV-PS" };
|
|
|
|
interface Quad { g: string; s: string; p: string; o: string }
|
|
|
|
/** A fake `ng` holding just enough to answer the Header-branch `exposedReadCap` query. */
|
|
function inject() {
|
|
const quads: Quad[] = [];
|
|
const sparql_update = mock(async (...a: unknown[]) => {
|
|
const query = a[1] as string;
|
|
const anchor = a[2] as string;
|
|
if (/^\s*DELETE WHERE/.test(query)) {
|
|
for (let i = quads.length - 1; i >= 0; i--) if (quads[i]!.g === anchor) quads.splice(i, 1);
|
|
return undefined;
|
|
}
|
|
const m = query.match(/<([^>]+)>\s+<([^>]+)>\s+"([^"]*)"/);
|
|
if (m) quads.push({ g: anchor, s: m[1]!, p: m[2]!, o: m[3]! });
|
|
return undefined;
|
|
});
|
|
const sparql_query = mock(async (...a: unknown[]) => ({
|
|
results: {
|
|
bindings: quads
|
|
.filter((q) => q.g === (a[3] as string) && q.p === `${SHIM}:exposedReadCap`)
|
|
.map((q) => ({ c: { value: q.o } })),
|
|
},
|
|
}));
|
|
configure({ ng: { doc_create: mock(async () => "did:ng:o:x"), sparql_update, sparql_query } as any, useShape: (() => {}) as any });
|
|
configureStoreRegistry({ getSession: async () => SESSION });
|
|
resetCaps();
|
|
resetPublicStoreFetches();
|
|
setCurrentUser(null);
|
|
return { sparql_query, quads };
|
|
}
|
|
|
|
afterEach(() => {
|
|
resetConfig();
|
|
resetStoreRegistry();
|
|
resetCaps();
|
|
setCurrentUser(null);
|
|
});
|
|
|
|
/** Arm the emulation without giving the current holder anything: some OTHER document. */
|
|
function armEmulation(): void {
|
|
setCurrentUser("someone-else");
|
|
getCaps().mint("did:ng:o:unrelated");
|
|
}
|
|
|
|
const PUB = "did:ng:o:pub" as Nuri;
|
|
|
|
test("a cap exposed on a document is downloaded by a holder that has nothing", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
await exposeReadCap(PUB, mintCap(PUB));
|
|
|
|
setCurrentUser("bob");
|
|
armEmulation();
|
|
setCurrentUser("bob");
|
|
expect(getCaps().capFor(PUB)).toBeUndefined();
|
|
|
|
expect(await fetchReadCap(PUB)).toBe(true);
|
|
expect(getCaps().capFor(PUB)).toBe(mintCap(PUB));
|
|
expect(getCaps().isInPublicStore(PUB)).toBe(true);
|
|
});
|
|
|
|
test("a document that exposes nothing yields nothing — that is the normal case, not an error", async () => {
|
|
inject();
|
|
armEmulation();
|
|
setCurrentUser("bob");
|
|
expect(await fetchReadCap("did:ng:o:protected" as Nuri)).toBe(false);
|
|
expect(getCaps().capFor("did:ng:o:protected" as Nuri)).toBeUndefined();
|
|
});
|
|
|
|
// A document speaks for itself and for nothing else. Without this, whoever can write
|
|
// into one public document could file caps for every document they care to name.
|
|
test("a cap naming ANOTHER document is refused, not filed", async () => {
|
|
const { quads } = inject();
|
|
setCurrentUser("alice");
|
|
await exposeReadCap(PUB, mintCap(PUB));
|
|
// Forge the exposed value so it names a different document.
|
|
quads[0]!.o = mintCap("did:ng:o:someone-elses" as Nuri);
|
|
|
|
armEmulation();
|
|
setCurrentUser("bob");
|
|
expect(await fetchReadCap(PUB)).toBe(false);
|
|
expect(getCaps().capFor(PUB)).toBeUndefined();
|
|
expect(getCaps().capFor("did:ng:o:someone-elses" as Nuri)).toBeUndefined();
|
|
});
|
|
|
|
test("inert while no cap has been issued at all — nothing to obtain, nothing asked", async () => {
|
|
const { sparql_query } = inject();
|
|
setCurrentUser("bob");
|
|
expect(await fetchReadCap(PUB)).toBe(false);
|
|
expect(sparql_query).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
// REGRESSION (2026-08-07, found adversarially). The memo used to cache a BOOLEAN, so the
|
|
// first holder to ask triggered the download, the cap was filed for THEM, and every later
|
|
// holder got `true` while holding nothing — their next read was refused. Upstream a broker
|
|
// serving a pinned outer overlay answers EVERY asker.
|
|
test("a public store serves every asker, not only the first", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
await exposeReadCap(PUB, mintCap(PUB));
|
|
armEmulation();
|
|
|
|
setCurrentUser("bob");
|
|
expect(await fetchReadCap(PUB)).toBe(true);
|
|
expect(getCaps().capFor(PUB)).toBe(mintCap(PUB));
|
|
|
|
setCurrentUser("carol");
|
|
expect(await fetchReadCap(PUB)).toBe(true);
|
|
expect(getCaps().capFor(PUB)).toBe(mintCap(PUB)); // …and she HOLDS it, not just "true"
|
|
});
|
|
|
|
test("asked once per document: the outcome is memoised, in both directions", async () => {
|
|
const { sparql_query } = inject();
|
|
setCurrentUser("alice");
|
|
await exposeReadCap(PUB, mintCap(PUB));
|
|
armEmulation();
|
|
setCurrentUser("bob");
|
|
|
|
await fetchReadCap(PUB);
|
|
const afterHit = sparql_query.mock.calls.length;
|
|
await fetchReadCap(PUB); // held now → not even the memo is consulted
|
|
expect(sparql_query.mock.calls.length).toBe(afterHit);
|
|
|
|
const absent = "did:ng:o:nothing-here" as Nuri;
|
|
await fetchReadCap(absent);
|
|
const afterMiss = sparql_query.mock.calls.length;
|
|
await fetchReadCap(absent); // a miss is remembered too
|
|
expect(sparql_query.mock.calls.length).toBe(afterMiss);
|
|
});
|
|
|
|
test("resetting the caps forgets the memo — a stale yes would hand back what is no longer held", async () => {
|
|
const { sparql_query } = inject();
|
|
setCurrentUser("alice");
|
|
await exposeReadCap(PUB, mintCap(PUB));
|
|
armEmulation();
|
|
setCurrentUser("bob");
|
|
await fetchReadCap(PUB);
|
|
|
|
resetCaps(); // also calls resetPublicStoreFetches
|
|
armEmulation();
|
|
setCurrentUser("bob");
|
|
const before = sparql_query.mock.calls.length;
|
|
expect(await fetchReadCap(PUB)).toBe(true);
|
|
expect(sparql_query.mock.calls.length).toBeGreaterThan(before); // asked again
|
|
});
|