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.
This commit is contained in:
Sylvain Duchesne
2026-08-06 19:55:32 +02:00
parent 32ef756b0b
commit 0832338201
28 changed files with 764 additions and 162 deletions
+38 -10
View File
@@ -7,7 +7,7 @@
* function turns a bare reference into a cap.
*/
import { test, expect } from "bun:test";
import { CapRegistry } from "../src/emulated-verifier/caps";
import { CapRegistry, mintCap } from "../src/emulated-verifier/caps";
import { hasReadCap, targetOf } from "../src/model/nuri";
import type { ReadCap } from "../src/model/types";
@@ -87,24 +87,52 @@ test("a cap received (learn) reads, exactly like one minted", () => {
expect(bob.caps.capFor(doc)).toBe(cap);
});
test("recordInPublicStore returns a cap-bearing link; reading it still means HOLDING it", () => {
// A public store SERVES its documents' caps (`emulated-verifier/public-store.ts`).
// This registry is one level below that: it records WHERE a document sits, and it
// files a served cap apart from one that was minted or deposited — because the two
// grant different things.
test("markInPublicStore records where a document sits, and mints nothing", () => {
const { caps, become } = registry("alice");
const doc = "did:ng:o:public-doc";
const link = caps.recordInPublicStore(doc);
caps.markInPublicStore(doc);
expect(hasReadCap(link)).toBe(true);
expect(targetOf(link)).toBe(doc);
expect(caps.isInPublicStore(doc)).toBe(true);
expect(caps.isInPublicStore("did:ng:o:other")).toBe(false);
// Publication is not a world-wide read grant: whoever HAS the URL reads it.
// Marking is not holding: the fact is about the document, the cap is about a holder.
expect(caps.capFor(doc)).toBeUndefined();
become("bob");
expect(caps.capFor(doc)).toBeUndefined();
caps.learn(link); // bob received the link (e.g. from the discovery index)
expect(caps.capFor(doc)).toBe(link);
});
test("open(): a public document is published as a link, a private one is not", () => {
test("a cap SERVED by a public store reads, and is refused a write", () => {
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
// 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);
});
test("open(): a public document is marked as sitting in a public store, a private one is not", () => {
const { caps } = registry();
const pub = caps.open("did:ng:o:pub", "public");
const prot = caps.open("did:ng:o:prot", "protected");