fix: écrire est une PROPRIÉTÉ, et trois portes qui n'auraient pas dû être ouvertes

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.
This commit is contained in:
Sylvain Duchesne
2026-08-07 13:59:13 +02:00
parent c5878c6126
commit 0b936d2119
19 changed files with 426 additions and 223 deletions
+9 -19
View File
@@ -104,32 +104,22 @@ test("markInPublicStore records where a document sits, and mints nothing", () =>
expect(caps.capFor(doc)).toBeUndefined();
});
test("a cap SERVED by a public store reads, and is refused a write", () => {
test("a cap SERVED by a public store is held like any other — possession is the read criterion", () => {
const { caps, become } = registry("alice");
const doc = "did:ng:o:public-doc";
const served = mintCap(doc);
become("bob");
caps.learnFromPublicStore(served);
expect(caps.capFor(doc)).toBe(served); // he reads it, like any held cap
expect(caps.isReadOnlyPublicCap(doc)).toBe(true); // …and only that
expect(caps.capFor(doc)).toBe(served);
// A stronger claim supersedes it: a cap DEPOSITED for me is not the network's copy.
caps.learn(served);
expect(caps.isReadOnlyPublicCap(doc)).toBe(false);
});
test("the owner of a public document is never read-only on it", () => {
const { caps, become } = registry("alice");
const doc = "did:ng:o:mine";
caps.open(doc, "public"); // alice created it
// A third party fetching the same document must not affect her claim on it.
become("bob");
caps.learnFromPublicStore(mintCap(doc));
expect(caps.isReadOnlyPublicCap(doc)).toBe(true);
become("alice");
expect(caps.isReadOnlyPublicCap(doc)).toBe(false);
// No read-only mark, and that absence is the point. It existed until 2026-08-07 and
// fed the write guard, which was the wrong predicate in both directions — writing is
// OWNERSHIP, and how a read key arrived says nothing about it (see `reach.ts`).
// Carol, in the same registry, holds nothing until she asks in her turn: what a public
// store serves is per-asker, not once-for-everyone.
become("carol");
expect(caps.capFor(doc)).toBeUndefined();
});
test("open(): a public document is marked as sitting in a public store, a private one is not", () => {
@@ -26,7 +26,6 @@ import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetInfrastructure } from "../src/emulated-verifier/reach";
const SESSION = { sessionId: "sid-cold", privateStoreId: "PRIV-COLD" };
const ANCHOR = `did:ng:${SESSION.privateStoreId}`;
@@ -37,7 +36,6 @@ afterAll(() => {
resetRegistryCache();
resetOpenedRepos();
resetCaps();
resetInfrastructure();
setCurrentUser(null);
});
@@ -48,7 +46,6 @@ beforeEach(() => {
resetRegistryCache();
resetOpenedRepos();
resetCaps();
resetInfrastructure();
setCurrentUser(null);
});
@@ -416,6 +416,52 @@ test("connecting a user that does not exist provisions nothing", async () => {
expect(getCaps().isEnforcing()).toBe(false);
});
// 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
-2
View File
@@ -23,7 +23,6 @@ import { readUnion } from "../src/surface/read-model";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetInfrastructure } from "../src/emulated-verifier/reach";
import { resetRegistryCache } from "../src/shared-wallet/account-registry";
afterAll(() => {
@@ -40,7 +39,6 @@ beforeEach(() => {
resetOpenedRepos();
resetRegistryCache();
resetCaps();
resetInfrastructure();
setCurrentUser(null);
});
+19 -2
View File
@@ -75,8 +75,6 @@ test("a cap exposed on a document is downloaded by a holder that has nothing", a
expect(await fetchReadCap(PUB)).toBe(true);
expect(getCaps().capFor(PUB)).toBe(mintCap(PUB));
// …and what he got is a READ grant, recorded as such.
expect(getCaps().isReadOnlyPublicCap(PUB)).toBe(true);
expect(getCaps().isInPublicStore(PUB)).toBe(true);
});
@@ -111,6 +109,25 @@ test("inert while no cap has been issued at all — nothing to obtain, nothing a
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");
+2 -1
View File
@@ -11,7 +11,8 @@
* 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, depositInto } from "../src/surface/docs";
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";
+32
View File
@@ -74,6 +74,38 @@ test("makeReadFilteredView forwards mutations and membership to the target", ()
expect(set.has(C)).toBe(false);
});
// REGRESSION (2026-08-07, found adversarially). The view forwarded every member it did
// not name, bound to the TARGET — so `.values()`, `.map()`, `.getById()` returned another
// identity's items. Those are the members a reactive-set API puts forward, so a consumer
// reaches for them first. A filtered view may show LESS than the set holds; never more.
test("every item-yielding member is filtered, not just iteration", () => {
const items = [MINE, LINKED, FOREIGN, NOGRAPH];
const set = new Set<Item>(items);
const { caps, become } = setup("alice");
const view = makeReadFilteredView(set, caps) as any;
become("bob"); // holds nothing: only the graphless item may surface
expect([...view.values()].map((i: Item) => i.id)).toEqual(["x"]);
expect([...view.keys()].map((i: Item) => i.id)).toEqual(["x"]);
expect([...view.entries()].map(([i]: [Item]) => i.id)).toEqual(["x"]);
expect(view.map((i: Item) => i.id)).toEqual(["x"]);
expect(view.filter((i: Item) => true).map((i: Item) => i.id)).toEqual(["x"]);
expect(view.find((i: Item) => i.id === "a")).toBeUndefined();
expect(view.some((i: Item) => i.id === "a")).toBe(false);
expect(view.has(MINE)).toBe(false);
});
// An unknown member must REFUSE, not forward: forwarding is a silent leak, and this
// view's one job is that it cannot show more than the holder may read.
test("an unfiltered member throws rather than leaking", () => {
const set = new Set<Item>([MINE]) as any;
set.sample = () => [...set][0];
const { caps, become } = setup("alice");
const view = makeReadFilteredView(set, caps) as any;
become("bob");
expect(() => view.sample()).toThrow(/not filtered/i);
});
test("forEach is filtered too", () => {
const set = new Set<Item>([MINE, LINKED]);
const seen: string[] = [];