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:
@@ -26,12 +26,22 @@ import {
|
||||
} from "../src/shared-wallet/account-registry";
|
||||
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";
|
||||
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,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";
|
||||
import type { Nuri } from "../src/model/types";
|
||||
|
||||
/**
|
||||
* Do I hold this document's cap? Possession, asked of the internal registry — the
|
||||
* polyfill door stopped publishing this (see `polyfill.ts`), because as an app-facing
|
||||
* question it reads like "may I read this?" and a public store's document answers
|
||||
* `false` until something has asked for its cap.
|
||||
*/
|
||||
function hasCap(nuri: Nuri): boolean {
|
||||
return getCaps().capFor(nuri) !== undefined;
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
resetConfig();
|
||||
resetStoreRegistry();
|
||||
@@ -168,6 +178,10 @@ function makeFakeNg() {
|
||||
if (query.includes(`<${SHIM}:link>`)) {
|
||||
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:link`).map((q) => ({ c: { value: q.o } })) } };
|
||||
}
|
||||
// Header-branch `exposedReadCap` SELECT — what a PUBLIC store serves to anyone.
|
||||
if (query.includes(`<${SHIM}:exposedReadCap>`)) {
|
||||
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:exposedReadCap`).map((q) => ({ c: { value: q.o } })) } };
|
||||
}
|
||||
if (query.includes(`<${SHIM}:contains>`)) {
|
||||
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`).map((q) => ({ e: { value: q.o } })) } };
|
||||
}
|
||||
@@ -207,7 +221,13 @@ async function readValues(docs: Nuri[], p: string): Promise<string[]> {
|
||||
|
||||
/**
|
||||
* Alice's world: a protected document holding a secret, and a public document that
|
||||
* REFERS to it by bare NURI. Returns what each actor could plausibly come to hold.
|
||||
* REFERS to it by bare NURI.
|
||||
*
|
||||
* What crosses to the other actors is **the bare reference of the public document and
|
||||
* nothing else** — no cap, no link with a key in it. That is the whole discipline of
|
||||
* this file: an application circulates references, and if a test had to hand a key
|
||||
* across an identity boundary through a JS variable, the feature it claims to prove
|
||||
* would have no path in any real application.
|
||||
*/
|
||||
async function aliceSetsUpHerDocuments() {
|
||||
setCurrentUser("alice");
|
||||
@@ -219,8 +239,7 @@ async function aliceSetsUpHerDocuments() {
|
||||
// grants nothing. This is the whole point of the scenario.
|
||||
await write(pubDoc, REFERS_TO, protDoc);
|
||||
|
||||
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 };
|
||||
return { protDoc, pubDoc };
|
||||
}
|
||||
|
||||
/** Follow the reference found in the public document — what a reader actually does. */
|
||||
@@ -232,11 +251,11 @@ function referenceFoundIn(values: string[]): Nuri {
|
||||
|
||||
test("Bob: reads the public document, sees the reference, and cannot read through it", async () => {
|
||||
inject();
|
||||
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments();
|
||||
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
|
||||
|
||||
setCurrentUser("bob");
|
||||
// Bob was given the public document's link — "whoever has the URL reads it".
|
||||
getCaps().learn(pubLink);
|
||||
// Bob holds the BARE reference and nothing else. The document sits in a public
|
||||
// store, so the store serves him its cap — he never received a key from anyone.
|
||||
|
||||
// He reads the public document and finds the reference.
|
||||
const refs = await readValues([pubDoc], REFERS_TO);
|
||||
@@ -250,7 +269,7 @@ test("Bob: reads the public document, sees the reference, and cannot read throug
|
||||
|
||||
test("Charlie: same public document, same reference — and he reads through it", async () => {
|
||||
inject();
|
||||
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments();
|
||||
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
|
||||
const CHARLIE_INBOX = await userInbox("charlie", "protected");
|
||||
|
||||
// Alice decides Charlie may read that ONE document, and delivers its cap to his
|
||||
@@ -259,7 +278,6 @@ test("Charlie: same public document, same reference — and he reads through it"
|
||||
await share(protDoc, "charlie");
|
||||
|
||||
setCurrentUser("charlie");
|
||||
getCaps().learn(pubLink);
|
||||
await readInbox(CHARLIE_INBOX); // processing the inbox files the cap
|
||||
|
||||
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
|
||||
@@ -270,18 +288,16 @@ test("Charlie: same public document, same reference — and he reads through it"
|
||||
|
||||
test("the ONLY difference between Bob and Charlie is each of them holds", async () => {
|
||||
inject();
|
||||
const { protDoc, pubLink } = await aliceSetsUpHerDocuments();
|
||||
const { protDoc } = await aliceSetsUpHerDocuments();
|
||||
const CHARLIE_INBOX = await userInbox("charlie", "protected");
|
||||
|
||||
setCurrentUser("alice");
|
||||
await share(protDoc, "charlie");
|
||||
|
||||
setCurrentUser("bob");
|
||||
getCaps().learn(pubLink);
|
||||
const bobSees = await readValues([protDoc], SECRET);
|
||||
|
||||
setCurrentUser("charlie");
|
||||
getCaps().learn(pubLink);
|
||||
await readInbox(CHARLIE_INBOX);
|
||||
const charlieSees = await readValues([protDoc], SECRET);
|
||||
|
||||
@@ -293,11 +309,10 @@ 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 { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments();
|
||||
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
|
||||
const BOB_INBOX = await userInbox("bob", "protected");
|
||||
|
||||
setCurrentUser("bob");
|
||||
getCaps().learn(pubLink);
|
||||
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
|
||||
|
||||
// Before: named, unreadable.
|
||||
@@ -331,16 +346,24 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
|
||||
unsub();
|
||||
});
|
||||
|
||||
test("a bare reference to the PUBLIC document is not enough either — the link is", async () => {
|
||||
// The property this whole batch exists for, stated on its own: WHERE a document sits
|
||||
// decides whether a bare reference is enough. Upstream a public store's repos are
|
||||
// served on the outer overlay and their ReadCap is downloaded from it
|
||||
// (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`) — so the same value transmitted
|
||||
// (a bare reference) yields a different outcome depending on the store, and never
|
||||
// because a key travelled.
|
||||
test("a bare reference is enough for a PUBLIC document, and not for a protected one", async () => {
|
||||
inject();
|
||||
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments();
|
||||
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
|
||||
|
||||
setCurrentUser("bob");
|
||||
// Bob knows the public document's NURI but was never given its link.
|
||||
expect(await readValues([pubDoc], REFERS_TO)).toEqual([]);
|
||||
|
||||
getCaps().learn(pubLink);
|
||||
// Bob has been given nothing but the two NURIs.
|
||||
expect((await readValues([pubDoc], REFERS_TO)).length).toBe(1);
|
||||
expect(await readValues([protDoc], SECRET)).toEqual([]);
|
||||
|
||||
// And what he obtained for the public one is a READ grant, not a write right: a
|
||||
// public store serves its read cap, no store hands out the write cap.
|
||||
await expect(write(pubDoc, SECRET, "bob-was-here")).rejects.toThrow(/public store/i);
|
||||
});
|
||||
|
||||
// THE POINT OF THE LINK: a cap survives because it was APPLIED, not because the
|
||||
@@ -369,7 +392,10 @@ test("a Link is APPLIED durably: the cap survives with the inbox emptied", async
|
||||
setCurrentUser("alice");
|
||||
await createEntityDoc("alice", "private"); // re-arms: a cap exists again
|
||||
setCurrentUser("bob");
|
||||
expect(await readValues([protDoc], SECRET)).toEqual([]); // bob holds nothing yet
|
||||
// Checked SYNCHRONOUSLY, before yielding: `setCurrentUser` fires the connection work
|
||||
// itself, and that work is precisely what restores the cap. An awaited check here
|
||||
// would be asserting who won a race, not what the library does.
|
||||
expect(hasCap(protDoc)).toBe(false); // bob holds nothing yet
|
||||
|
||||
// Connecting restores it — from the User branch, since the inbox has nothing left.
|
||||
await connectedUser();
|
||||
@@ -396,13 +422,12 @@ 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 = 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
|
||||
// passed to him — if it had to be, there would be no way for an app to get it.
|
||||
// Bob RESOLVES the address himself, from the BARE reference — the only thing he is
|
||||
// handed, and the only thing an application circulates. The document is in a public
|
||||
// store, so the store serves him its read cap; the address is not passed to him,
|
||||
// because if it had to be there would be no way for an app to get it.
|
||||
setCurrentUser("bob");
|
||||
getCaps().learn(link);
|
||||
const bobTarget = await documentInboxAddress(doc);
|
||||
expect(bobTarget).toBe(aliceInbox); // …and it is the SAME inbox alice reads
|
||||
await post(bobTarget!, { payload: { joining: true }, ts: 1 });
|
||||
@@ -422,11 +447,8 @@ 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 = getCaps().capFor(doc)!;
|
||||
|
||||
// Bob holds the document — that is a READ right, and it is not ownership.
|
||||
// Bob can READ the document (it is in a public store) — and reading is not ownership.
|
||||
setCurrentUser("bob");
|
||||
getCaps().learn(link);
|
||||
await expect(openDocumentInbox(doc)).rejects.toThrow(/already has an inbox|you may only open an inbox/i);
|
||||
// The address he resolves is still alice's, so his deposits reach her.
|
||||
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
|
||||
@@ -436,13 +458,11 @@ 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 = 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
|
||||
// at one inbox is a relation the model cannot express.
|
||||
setCurrentUser("bob");
|
||||
getCaps().learn(link);
|
||||
expect(await documentInboxAddress(doc)).toBeUndefined();
|
||||
// …and depositing THROWS rather than vanishing — a lost deposit is the bug this
|
||||
// whole path exists to close.
|
||||
@@ -456,9 +476,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 = getCaps().capFor(doc)!;
|
||||
setCurrentUser("bob");
|
||||
getCaps().learn(link);
|
||||
expect(await documentInboxAddress(doc)).toBe(dedicated);
|
||||
// The deposit reaches the owner, addressed by the document alone.
|
||||
await postToDocument(doc, { payload: { signingUp: true } });
|
||||
|
||||
Reference in New Issue
Block a user