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
+4 -4
View File
@@ -298,7 +298,7 @@ async function main(): Promise<void> {
const t = Date.now();
const r = await sdk<any>(frame, "documentInboxDeposit", "@owner-" + t, "@depositor-" + t);
check(
"the depositor RESOLVES the same inbox from the document, deposits into it, and the address stays out of the data",
"the depositor holds only the BARE reference, resolves the same inbox from it, deposits, and the address stays out of the data",
r.sameInbox === true &&
r.openRefused === true &&
JSON.stringify(r.deposits) === JSON.stringify([{ viaPostToDocument: true }, { joining: true }]) &&
@@ -383,15 +383,15 @@ async function main(): Promise<void> {
const linkOpensPublic =
r.strangerWithLinkView.length === 1 && r.strangerWithLinkView.includes("public-item");
check(
"owner reads held docs only; stranger reads nothing; the repo link opens the published one",
"the read-filtered view decides on possession alone: owner sees what he holds, a stranger nothing, and a filed cap opens it",
ownerReadsHeld && ownerMissesUnheld && strangerReadsNothing && linkOpensPublic,
`owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)} withLink=${JSON.stringify(r.strangerWithLinkView)}`,
`owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)} withCap=${JSON.stringify(r.strangerWithLinkView)}`,
);
});
await step("shareCap: a cap delivered to an inbox reveals the doc", async () => {
const r = await sdk<any>(frame, "capsShareCap", "@friend-" + Date.now());
check(
"shareCap → inbox processed → the shared doc becomes readable, and the delivery is not surfaced",
"share → inbox processed → the shared doc becomes readable, and the delivery is not surfaced",
r.before === 0 && r.after === 1 && r.surfacedDeposits === 0,
`before=${r.before} after=${r.after} surfaced=${r.surfacedDeposits}`,
);
+9 -9
View File
@@ -837,10 +837,11 @@ const identity = new IdentityStore(
];
setCurrentUser("owner-O");
getCaps().open("did:ng:o:protdoc", "protected");
const link = getCaps().recordInPublicStore("did:ng:o:pubdoc");
const link = getCaps().open("did:ng:o:pubdoc", "public");
const ownerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
// A stranger holds nothing — including the PUBLISHED document, until the repo
// link reaches them (§5: whoever has the URL reads the content).
// A stranger holds nothing, and this VIEW asks nobody: it is pure possession, with
// no round-trip to spend (see `read-filter.ts`). That a public store would serve
// the cap is proven on the read paths, not here.
setCurrentUser("stranger");
const strangerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
getCaps().learn(link);
@@ -861,20 +862,19 @@ const identity = new IdentityStore(
* owner opens it, a third party RESOLVES its address from the document itself and
* deposits, the owner reads it back.
*
* The point of the step is the resolution: nothing hands `depositorId` the address.
* It gets the document's link (which is what circulates in this model) and must find
* where to deposit on its own — which is exactly what a consumer app has to do, and
* what a unit test passing the NURI through a variable cannot prove.
* The point of the step is the resolution: the depositor is handed the document's
* BARE reference — the only thing an application circulates — and must find where to
* deposit on its own. It reads the document at all because the document sits in a
* public store, which serves its read cap to whoever asks (`public-store.ts`); no key
* crosses the identity boundary, here or in any real application.
*/
async documentInboxDeposit(ownerId: string, depositorId: string) {
registryInternals.resetRegistryCache();
setCurrentUser(ownerId);
const doc = await storeRegistry.createEntityDoc(ownerId, "public");
const ownerInbox = await storeRegistry.openDocumentInbox(doc);
const link = getCaps().capFor(doc)!; // out-of-band: the harness plays 'the owner sent it'
setCurrentUser(depositorId);
getCaps().learn(link);
const resolved = await documentInboxAddress(doc);
// The one-call form an app actually uses: it names the DOCUMENT, never an inbox.
await inbox.postToDocument(doc, { payload: { viaPostToDocument: true }, ts: 900 });