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
+13 -11
View File
@@ -345,7 +345,7 @@ Three ways a cap arrives, and there are no others:
recompute anything: that is the whole reason for storing them, and it is what lets
a **fresh session** read its own documents again with nothing re-declared — the
durability the old in-memory ACL faked and lost every reload.
- **Delivery.** `shareCap(cap, toInbox)` deposits one document's cap into one
- **Delivery.** `inbox.share(doc, toUser)` deposits one document's cap into one
recipient's inbox; `inbox.read` applies it inline, exactly as the recipient's own
verifier applies queued messages upstream. **Receiving needs no operation** — a
consumer already watching its inbox gets them, and the resulting change
@@ -364,17 +364,19 @@ another name.
- **`setCurrentUser(id)` (`polyfill.ts`)** — the SDK's "current identity" call.
It selects *whose* caps are consulted, lazily, so the delivered subset always
reflects the identity in effect at read time.
- **`shareCap(cap, toInbox)`** — the one sharing act the lib exposes. Recipients
- **`inbox.share(doc, toUser)`** — the one sharing act the lib exposes. Recipients
are addressed as **inboxes**, which `inbox.post(targetInbox)` already does here;
there is no `PrincipalId` in this surface, because that notion exists nowhere
upstream. Reaching several recipients means calling it once per inbox, which is
what the real model does too (each delivery is sealed to one recipient).
- **`getCaps().publishRepoLink(doc)`** — upstream `RepoLinkV0`: a shareable link
**whoever receives it** can open. Put the *link* in what you make discoverable, not
the bare NURI, or no reader can open it. Publication is **not recursive**: a public
document may reference private ones, and the reference grants nothing on what it
references — which is what lets a public object point at a private identity without
disclosing it.
- **A document created in the `public` scope** needs no sharing act at all. The store
serves its ReadCap to whoever asks (`emulated-verifier/public-store.ts`, emulating
*"the latest ReadCap will be downloaded from the outerOverlay"* — `PublicRepoLinkV0`,
`engine/net/src/types.rs:5098`), so what an application circulates is the **bare
reference**, exactly as it will after migration. Never recursive: a public document
may reference private ones, and the reference grants nothing on what it references —
which is what lets a public object point at a private identity without disclosing it.
And never a write right: what the store serves is a read cap.
Upstream, directed delivery is a **gap, not a disagreement**: `ContactDetails.read_cap`
exists, but the message construction is `unimplemented!()`, its only caller passes
@@ -442,8 +444,8 @@ natively at migration); the read side is what makes isolation observably active.
Isolation is enforced by the per-document ReadCap (`emulated-verifier/caps.ts` + `emulated-verifier/read-filter.ts`)
alone: the access unit is the document (`@graph` = repo), and the only acts are
possession-shaped (`createEntityDoc` files a cap, `shareCap` delivers one,
`publishRepoLink` emits an openable link). Because the consumer application writes
possession-shaped (`createEntityDoc` files a cap, `inbox.share` delivers one, a public
store serves one to whoever asks). Because the consumer application writes
one document per entity, the per-document cap discriminates at entity granularity —
the target's behaviour.
@@ -512,7 +514,7 @@ deposit — **whose JS name and signature are not known**, since none is exposed
announced — and the read side is served by the recipient's own verifier unsealing
queued messages inline.
The inbox + watcher is the one deposit/read mechanism a consumer reuses for its own
purposes — a registration/deposit, a cap delivery (`shareCap`), a link handed to
purposes — a registration/deposit, a cap delivery (`inbox.share`), a link handed to
someone — same `post` API, same watcher.
## The virtual user boundary (`emulated-verifier/reach.ts` + `shared-wallet/physical.ts`)