refactor(api): partager nomme le document, détenir répond par oui ou non

`shareCap(cap, toUser)` faisait tenir une clé à l'appelant. En amont il n'en
tient aucune : c'est le verifier qui remplit `ContactDetails.read_cap`, et une
inbox se résout depuis un profil. Cette signature a déjà changé deux fois
aujourd'hui — `(cap, toInbox)` puis `(cap, toUser)` — et les deux laissaient à
l'app quelque chose qu'elle ne tiendra pas plus tard.

- `inbox.share(doc, toUser)` : les deux choses qu'une application a, un document
  et une personne. Ni la clé ni l'adresse n'apparaissent.
- `hasCap(doc)` remplace `capFor(doc)` et rend un BOOLÉEN. C'est la seule
  question que le modèle admette, et l'unique appelant qui utilisait la valeur
  s'en servait pour la passer à `shareCap`.

Les tests ont fait apparaître un besoin que ces retraits allaient casser :
obtenir le lien PARTAGEABLE d'un document publié, pour le faire circuler. C'est
distinct du partage dirigé et ça existe en amont — un `RepoLinkV0 { read_cap }`
est ce qu'on passe, `ContactDetails.read_cap` est la remise à quelqu'un. D'où
`linkTo(doc)`, seul endroit où une app tient légitimement une clé : on ne peut
pas faire circuler ce qu'on n'a pas le droit de toucher. La clé d'un document
protégé, elle, ne sort jamais par là — elle passe par `share`.

171 tests unitaires, e2e 42/42 en 3,5 min (synchro à froid 29s, stable contre
30s au run précédent — le wallet par batterie tient).
This commit is contained in:
Sylvain Duchesne
2026-08-06 11:37:47 +02:00
parent da6ef4b8b8
commit c8d02619b1
10 changed files with 117 additions and 80 deletions
+13 -10
View File
@@ -258,7 +258,7 @@ function capOfPayload(payload: unknown): ReadCap | null {
}
/**
* Share ONE document's read cap with ONE recipient, addressed by their inbox.
* Share ONE document with ONE recipient.
*
* The unit of sharing is the DOCUMENT: never hand over a store's cap, which would
* give away everything the store contains, present and future. The recipient needs
@@ -282,18 +282,21 @@ function capOfPayload(payload: unknown): ReadCap | null {
* The shape is right; the implementation is absent at both ends, so we emulate it
* meanwhile.
*/
export async function shareCap(cap: ReadCap, toUser: string): Promise<void> {
if (!hasReadCap(cap)) {
export async function share(doc: NuriLike, toUser: string): Promise<void> {
const target = toNuri(doc, "inbox.share");
// Names the DOCUMENT and the PERSON — the two things an application has. Neither the
// key nor the address appears, because a caller will handle neither once this is
// native: upstream the verifier fills `ContactDetails.read_cap` itself, and an inbox
// is resolved from a profile. This took `(cap, toInbox)` at first, then `(cap, toUser)`;
// both made the caller hold something it will not hold later.
const cap = getCaps().capFor(target);
if (!cap) {
throw new Error(
"[ng-eventually] inbox.shareCap: expected a ReadCap (a NURI carrying `:r:`), " +
`got a bare reference — naming is not reading: ${JSON.stringify(cap)}`,
"[ng-eventually] inbox.share: this document is not yours to share — you hold no cap " +
`for it. A cap is looked up in what you hold, or it was delivered to you: ${JSON.stringify(target)}`,
);
}
// Takes the RECIPIENT, not their inbox address. Sharing is an act toward someone;
// which inbox carries it is the library's business, and an address is exactly what a
// caller will not have to handle once this is native. It used to take `toInbox`, which
// forced every consumer to resolve an address first — a step it would then have to
// unlearn. Protected, because directed sharing is not a public announcement.
// Protected, because directed sharing is not a public announcement.
await post(await userInbox(toUser, "protected"), { payload: { kind: LINK_KIND, cap } });
}
+33
View File
@@ -39,4 +39,37 @@ export {
/** Open an inbox on a document you OWN, so others can deposit into it. */
/** WHERE to deposit for a document — readable by any holder of it. `undefined` if none. */
} from "../shared-wallet/account-registry";
import { getCaps } from "../shared-wallet/bootstrap";
import { toNuri } from "../model/nuri";
import type { NuriLike, ReadCap } from "../model/types";
export { openDocumentInbox } from "../emulated-verifier/branch-registers";
/**
* The shareable link of a document — what you circulate so someone can open it.
*
* Distinct from {@link share}, and both are needed: a link is what TRAVELS (a message,
* a QR code, a page), whereas sharing hands the access to one named person through
* their inbox. Upstream the same split exists — a `RepoLinkV0 { read_cap }` is the
* thing you pass around, and `ContactDetails.read_cap` is the directed delivery.
*
* This is the one place an application legitimately holds a key, because a public
* document's link IS meant to be handled: you cannot circulate what you may not touch.
* A protected document's key never comes out this way — it goes through `share`.
*
* Typed `ReadCap`, since that is what it is — a reference with the key inside. A
* `ReadCap` is assignable wherever a `Nuri` is expected (a cap IS a NURI carrying the
* key, upstream's one `NuriV0`), so it hands straight to any call that takes a
* reference. Throws if you hold nothing: a link you cannot open is not a link.
*/
export function linkTo(doc: NuriLike): ReadCap {
const target = toNuri(doc, "linkTo");
const cap = getCaps().capFor(target);
if (!cap) {
throw new Error(
"[ng-eventually] linkTo: you hold no key for this document, so there is no link " +
`to hand out: ${JSON.stringify(target)}`,
);
}
return cap;
}