docs+fix: le modèle de sécurité local-first, et le retrait de linkTo qui le brisait

La règle d'accès est « qui a la référence ET la clé lit » — jamais « qui a la
référence lit ». Il n'y a pas de tiers dans cette phrase : le moteur ne vérifie
une permission qu'à l'ÉCRITURE, jamais à la lecture. Le contenu est chiffré, la
clé EST le droit. C'est ce que veut dire local-first ici : il n'y a personne à
qui demander, donc la possession est tout le mécanisme.

Ce que cette règle achète, et qui n'était consigné nulle part : une référence
n'accordant rien, **elle n'est pas récursive**. Un document largement diffusé
peut pointer vers un document restreint — la référence dit qu'il existe, la clé
dit qui le lit. D'où la confidentialité COMPOSABLE : un sommaire diffusé qui
renvoie à des chapitres restreints, un événement public qui renvoie à sa liste
de participants. L'auteur diffuse un document et décide encore, document
référencé par document référencé, qui reçoit la clé.

`linkTo` détruisait cette construction. Il rendait la CLÉ là où un appelant
demande une référence, transformant la règle en « qui a la référence lit » —
pour ce document et pour tout ce qu'il MENTIONNE. Ajouté et retiré le même jour.

Documenté en §0, avant tout le reste, avec la raison pour laquelle un agent
perd ce point avec constance : les réflexes client-serveur fournissent la moitié
manquante sans qu'on s'en aperçoive — quelque part un serveur vérifierait, un
lien serait un identifiant inoffensif, « Bob a-t-il le droit ? » aurait une
réponse. Aucun des trois n'est vrai ici, et du code écrit là-dessus n'échoue
pas : il accorde en silence. Rien ne passe au rouge.

Renommé au passage : `publishRepoLink`/`isPublished` → `recordInPublicStore`/
`isInPublicStore`. Ces méthodes n'ont pas de pendant amont et « publier » ne
désigne rien de précis ici.

171 tests unitaires, e2e 42/42 en 3,6 min.
This commit is contained in:
Sylvain Duchesne
2026-08-06 16:04:31 +02:00
parent 3c981ffadb
commit cb56f91b5e
6 changed files with 55 additions and 42 deletions
@@ -24,7 +24,6 @@ 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,hasCap,resetCaps,setCurrentUser,share,connectedUser} from "../src/polyfill";
@@ -220,7 +219,7 @@ async function aliceSetsUpHerDocuments() {
// grants nothing. This is the whole point of the scenario.
await write(pubDoc, REFERS_TO, protDoc);
const pubLink = linkTo(pubDoc); // the shareable repo link of the public doc
const pubLink = getCaps().capFor(pubDoc)!; // out-of-band: the test plays 'Alice sent Bob the link' // the shareable repo link of the public doc
return { protDoc, pubDoc, pubLink };
}
@@ -397,7 +396,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 = linkTo(doc); // the repo link alice circulates — links DO travel
const link = getCaps().capFor(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 +422,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 = linkTo(doc);
const link = getCaps().capFor(doc)!;
// Bob holds the document — that is a READ right, and it is not ownership.
setCurrentUser("bob");
@@ -437,7 +436,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 = linkTo(doc);
const link = getCaps().capFor(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 +456,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 = linkTo(doc);
const link = getCaps().capFor(doc)!;
setCurrentUser("bob");
getCaps().learn(link);
expect(await documentInboxAddress(doc)).toBe(dedicated);