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
+23 -23
View File
@@ -24,9 +24,10 @@ import {
resetRegistryCache,
userInbox,
} from "../src/shared-wallet/account-registry";
import { linkTo } from "../src/surface/placement";
import { documentInboxAddress, openDocumentInbox } from "../src/emulated-verifier/branch-registers";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,capFor,resetCaps,setCurrentUser,shareCap,connectedUser} from "../src/polyfill";
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,hasCap,resetCaps,setCurrentUser,share,connectedUser} from "../src/polyfill";
import { post, postToDocument, read as readInbox } from "../src/surface/inbox";
import { readUnion } from "../src/surface/read-model";
import { sparqlUpdate } from "../src/surface/docs";
@@ -219,9 +220,8 @@ async function aliceSetsUpHerDocuments() {
// grants nothing. This is the whole point of the scenario.
await write(pubDoc, REFERS_TO, protDoc);
const pubLink = capFor(pubDoc)!; // the shareable repo link of the public doc
const protCap = capFor(protDoc)!; // the cap Alice may hand to whoever she chooses
return { protDoc, pubDoc, pubLink, protCap };
const pubLink = linkTo(pubDoc); // the shareable repo link of the public doc
return { protDoc, pubDoc, pubLink };
}
/** Follow the reference found in the public document — what a reader actually does. */
@@ -245,19 +245,19 @@ test("Bob: reads the public document, sees the reference, and cannot read throug
expect(ref).toBe(protDoc); // he can NAME Alice's protected document
// …and that is all it gets him: no cap, no read. Publication is NOT recursive.
expect(capFor(ref)).toBeUndefined();
expect(hasCap(ref)).toBe(false);
expect(await readValues([ref], SECRET)).toEqual([]);
});
test("Charlie: same public document, same reference — and he reads through it", async () => {
inject();
const { protDoc, pubDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments();
const CHARLIE_INBOX = await userInbox("charlie", "protected");
// Alice decides Charlie may read that ONE document, and delivers its cap to his
// inbox. She names no principal to the registry; she addresses an inbox.
setCurrentUser("alice");
await shareCap(protCap, "charlie");
await share(protDoc, "charlie");
setCurrentUser("charlie");
getCaps().learn(pubLink);
@@ -265,17 +265,17 @@ test("Charlie: same public document, same reference — and he reads through it"
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
expect(ref).toBe(protDoc);
expect(capFor(ref)).toBe(protCap);
expect(hasCap(ref)).toBe(true);
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
});
test("the ONLY difference between Bob and Charlie is each of them holds", async () => {
inject();
const { protDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
const { protDoc, pubLink } = await aliceSetsUpHerDocuments();
const CHARLIE_INBOX = await userInbox("charlie", "protected");
setCurrentUser("alice");
await shareCap(protCap, "charlie");
await share(protDoc, "charlie");
setCurrentUser("bob");
getCaps().learn(pubLink);
@@ -294,7 +294,7 @@ test("the ONLY difference between Bob and Charlie is each of them holds", async
// that was empty becomes full — with nothing re-declared and nobody re-authorized.
test("dynamic: a cap delivered to Bob's inbox makes the refused document readable, and signals it", async () => {
inject();
const { pubDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments();
const BOB_INBOX = await userInbox("bob", "protected");
setCurrentUser("bob");
@@ -316,7 +316,7 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
// Alice delivers the cap. Bob's client processes his inbox — the only thing that
// happens; no "receive" call exists.
setCurrentUser("alice");
await shareCap(protCap, "bob");
await share(protDoc, "bob");
setCurrentUser("bob");
await readInbox(BOB_INBOX);
@@ -326,7 +326,7 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
await new Promise((r) => setTimeout(r, 0));
// …and the read that was empty now yields the content.
expect(capFor(ref)).toBe(protCap);
expect(hasCap(ref)).toBe(true);
expect(latest).toEqual(["the-protected-content"]);
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
unsub();
@@ -334,7 +334,7 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
test("a bare reference to the PUBLIC document is not enough either — the link is", async () => {
inject();
const { pubDoc, pubLink } = await aliceSetsUpHerDocuments();
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments();
setCurrentUser("bob");
// Bob knows the public document's NURI but was never given its link.
@@ -350,11 +350,11 @@ test("a bare reference to the PUBLIC document is not enough either — the link
// Re-reading a queue to recover state is using it as a database.
test("a Link is APPLIED durably: the cap survives with the inbox emptied", async () => {
const ng = inject();
const { protDoc, protCap } = await aliceSetsUpHerDocuments();
const { protDoc } = await aliceSetsUpHerDocuments();
const bobInbox = await userInbox("bob", "protected");
setCurrentUser("alice");
await shareCap(protCap, "bob");
await share(protDoc, "bob");
// Bob connects: the library restores + drains, with nothing asked of the app.
setCurrentUser("bob");
@@ -374,7 +374,7 @@ test("a Link is APPLIED durably: the cap survives with the inbox emptied", async
// Connecting restores it — from the User branch, since the inbox has nothing left.
await connectedUser();
expect(capFor(protDoc)).toBe(protCap);
expect(hasCap(protDoc)).toBe(true);
expect(await readValues([protDoc], SECRET)).toEqual(["the-protected-content"]);
});
@@ -397,7 +397,7 @@ test("a document has its own inbox: anyone deposits, only the owner reads", asyn
const doc = await createEntityDoc("alice", "public");
const aliceInbox = await openDocumentInbox(doc);
expect(aliceInbox).not.toBe(await userInbox("alice", "protected"));
const link = capFor(doc)!; // the repo link alice circulates — links DO travel
const link = linkTo(doc); // the repo link alice circulates — links DO travel
// Bob RESOLVES the address himself, from the document. The only thing he is handed
// is the link, which is the one thing the model says circulates. The address is not
@@ -423,7 +423,7 @@ test("opening an inbox on someone else's document is refused, not silently forke
const doc = await createEntityDoc("alice", "public");
const aliceInbox = await openDocumentInbox(doc);
const link = capFor(doc)!;
const link = linkTo(doc);
// Bob holds the document — that is a READ right, and it is not ownership.
setCurrentUser("bob");
@@ -437,7 +437,7 @@ test("a fresh document has NO inbox — one belongs to one document, and only it
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const link = capFor(doc)!;
const link = linkTo(doc);
// Not "the owner's inbox by default": upstream an inbox belongs to exactly ONE repo
// (the verifier routes by `inboxes: PubKey → RepoId`), so pointing several documents
@@ -457,7 +457,7 @@ test("opening an inbox publishes ONE address, and re-opening does not accumulate
const dedicated = await openDocumentInbox(doc);
expect(await openDocumentInbox(doc)).toBe(dedicated); // idempotent
const link = capFor(doc)!;
const link = linkTo(doc);
setCurrentUser("bob");
getCaps().learn(link);
expect(await documentInboxAddress(doc)).toBe(dedicated);
@@ -492,14 +492,14 @@ test("connecting drains BOTH levels: the user's inbox and its documents'", async
// Two deposits, one at each level, both made by someone else.
setCurrentUser("carol");
const carolDoc = await createEntityDoc("carol", "protected");
await shareCap(capFor(carolDoc)!, "alice"); // a Link, to alice herself
await share(carolDoc, "alice"); // a Link, to alice herself
await post(docInbox, { payload: { onTheDocument: true }, ts: 2 });
// Alice connects: one call, both queues.
setCurrentUser("alice");
await connectedUser();
expect(capFor(carolDoc)).toBeDefined(); // the Link was applied
expect(hasCap(carolDoc)).toBe(true); // the Link was applied
expect(await readValues([protDoc], SECRET)).toEqual([]); // (protDoc holds no secret here)
const left = await readInbox(docInbox);
expect(left.map((d) => d.payload)).toEqual([{ onTheDocument: true }]); // consumer data stays