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:
@@ -160,6 +160,11 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => {
|
||||
it("sparqlQuery emits a READ line with identity, nuri, label, and row-count", async () => {
|
||||
injectFake(true);
|
||||
setCurrentUser("alice");
|
||||
// `setCurrentUser` FIRES the connection work; draining it here (and only then
|
||||
// dropping the caps it filed) is what keeps this test about the log and not about
|
||||
// whether a background connect happened to win the race.
|
||||
await connectedUser();
|
||||
resetCaps();
|
||||
const { lines, restore } = spyConsoleLog();
|
||||
try {
|
||||
await sparqlQuery("sid-log", "SELECT * {}", undefined, "did:ng:o:q", "myLabel");
|
||||
@@ -178,6 +183,11 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => {
|
||||
it("sparqlUpdate emits a WRITE line with identity, anchor nuri, and label", async () => {
|
||||
injectFake(true);
|
||||
setCurrentUser("alice");
|
||||
// `setCurrentUser` FIRES the connection work; draining it here (and only then
|
||||
// dropping the caps it filed) is what keeps this test about the log and not about
|
||||
// whether a background connect happened to win the race.
|
||||
await connectedUser();
|
||||
resetCaps();
|
||||
const { lines, restore } = spyConsoleLog();
|
||||
try {
|
||||
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:w", "writeLabel");
|
||||
@@ -194,6 +204,11 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => {
|
||||
it("docCreate emits a WRITE line with identity and the returned nuri", async () => {
|
||||
injectFake(true);
|
||||
setCurrentUser("alice");
|
||||
// `setCurrentUser` FIRES the connection work; draining it here (and only then
|
||||
// dropping the caps it filed) is what keeps this test about the log and not about
|
||||
// whether a background connect happened to win the race.
|
||||
await connectedUser();
|
||||
resetCaps();
|
||||
const { lines, restore } = spyConsoleLog();
|
||||
try {
|
||||
await docCreate("sid-log", "Graph", "data:graph", "store");
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* function turns a bare reference into a cap.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import { CapRegistry } from "../src/emulated-verifier/caps";
|
||||
import { CapRegistry, mintCap } from "../src/emulated-verifier/caps";
|
||||
import { hasReadCap, targetOf } from "../src/model/nuri";
|
||||
import type { ReadCap } from "../src/model/types";
|
||||
|
||||
@@ -87,24 +87,52 @@ test("a cap received (learn) reads, exactly like one minted", () => {
|
||||
expect(bob.caps.capFor(doc)).toBe(cap);
|
||||
});
|
||||
|
||||
test("recordInPublicStore returns a cap-bearing link; reading it still means HOLDING it", () => {
|
||||
// A public store SERVES its documents' caps (`emulated-verifier/public-store.ts`).
|
||||
// This registry is one level below that: it records WHERE a document sits, and it
|
||||
// files a served cap apart from one that was minted or deposited — because the two
|
||||
// grant different things.
|
||||
test("markInPublicStore records where a document sits, and mints nothing", () => {
|
||||
const { caps, become } = registry("alice");
|
||||
const doc = "did:ng:o:public-doc";
|
||||
const link = caps.recordInPublicStore(doc);
|
||||
caps.markInPublicStore(doc);
|
||||
|
||||
expect(hasReadCap(link)).toBe(true);
|
||||
expect(targetOf(link)).toBe(doc);
|
||||
expect(caps.isInPublicStore(doc)).toBe(true);
|
||||
expect(caps.isInPublicStore("did:ng:o:other")).toBe(false);
|
||||
|
||||
// Publication is not a world-wide read grant: whoever HAS the URL reads it.
|
||||
// Marking is not holding: the fact is about the document, the cap is about a holder.
|
||||
expect(caps.capFor(doc)).toBeUndefined();
|
||||
become("bob");
|
||||
expect(caps.capFor(doc)).toBeUndefined();
|
||||
caps.learn(link); // bob received the link (e.g. from the discovery index)
|
||||
expect(caps.capFor(doc)).toBe(link);
|
||||
});
|
||||
|
||||
test("open(): a public document is published as a link, a private one is not", () => {
|
||||
test("a cap SERVED by a public store reads, and is refused a write", () => {
|
||||
const { caps, become } = registry("alice");
|
||||
const doc = "did:ng:o:public-doc";
|
||||
const served = mintCap(doc);
|
||||
|
||||
become("bob");
|
||||
caps.learnFromPublicStore(served);
|
||||
expect(caps.capFor(doc)).toBe(served); // he reads it, like any held cap
|
||||
expect(caps.isReadOnlyPublicCap(doc)).toBe(true); // …and only that
|
||||
|
||||
// A stronger claim supersedes it: a cap DEPOSITED for me is not the network's copy.
|
||||
caps.learn(served);
|
||||
expect(caps.isReadOnlyPublicCap(doc)).toBe(false);
|
||||
});
|
||||
|
||||
test("the owner of a public document is never read-only on it", () => {
|
||||
const { caps, become } = registry("alice");
|
||||
const doc = "did:ng:o:mine";
|
||||
caps.open(doc, "public"); // alice created it
|
||||
|
||||
// A third party fetching the same document must not affect her claim on it.
|
||||
become("bob");
|
||||
caps.learnFromPublicStore(mintCap(doc));
|
||||
expect(caps.isReadOnlyPublicCap(doc)).toBe(true);
|
||||
become("alice");
|
||||
expect(caps.isReadOnlyPublicCap(doc)).toBe(false);
|
||||
});
|
||||
|
||||
test("open(): a public document is marked as sitting in a public store, a private one is not", () => {
|
||||
const { caps } = registry();
|
||||
const pub = caps.open("did:ng:o:pub", "public");
|
||||
const prot = caps.open("did:ng:o:prot", "protected");
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -11,16 +11,17 @@
|
||||
* What the read filter then shows:
|
||||
* (a) a document nobody shared is unreadable, and stays unreadable for a third
|
||||
* party after a share to someone else — sharing is per-document, per-inbox;
|
||||
* (b) a bare reference grants NOTHING (naming is not reading), while the repo
|
||||
* link of a published document opens it for whoever receives it;
|
||||
* (b) the read-filtered VIEW decides on possession alone — it is synchronous, so it
|
||||
* asks no store anything (a public store WOULD serve its cap; that is proven on
|
||||
* the read paths, in `cross-user-access.test.ts`);
|
||||
* (c) switching identity SWITCHES heldByHolder — it never wipes one.
|
||||
*/
|
||||
import { getCaps } from "../src/shared-wallet/bootstrap";
|
||||
import { test, expect, mock, afterAll } from "bun:test";
|
||||
import { createEntityDoc, resetRegistryCache, userInbox, listMyEntityDocs } from "../src/shared-wallet/account-registry";
|
||||
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
||||
import type { ReadCap } from "../src/model/types";
|
||||
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,hasCap,resetCaps,setCurrentUser,share} from "../src/polyfill";
|
||||
import type { Nuri, ReadCap } from "../src/model/types";
|
||||
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,resetCaps,setCurrentUser,share} from "../src/polyfill";
|
||||
import { read as readInbox } from "../src/surface/inbox";
|
||||
import { filterReadable } from "../src/emulated-verifier/read-filter";
|
||||
|
||||
@@ -35,6 +36,12 @@ const SESSION: RegistrySession = { sessionId: "sid", privateStoreId: "PRIV" };
|
||||
const SHIM = "urn:ng-eventually:shim";
|
||||
const INBOX = "urn:ng-eventually:inbox";
|
||||
|
||||
/** Possession, asked of the internal registry — see `polyfill.ts` on why the door
|
||||
* stopped publishing it. */
|
||||
function hasCap(nuri: Nuri): boolean {
|
||||
return getCaps().capFor(nuri) !== undefined;
|
||||
}
|
||||
|
||||
interface Quad { g: string; s: string; p: string; o: string }
|
||||
|
||||
/** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */
|
||||
@@ -247,21 +254,30 @@ test("a cap deposit is absorbed, not surfaced as a consumer deposit", async () =
|
||||
expect(hasCap(doc)).toBe(true); // …but it landed in bob's held caps
|
||||
});
|
||||
|
||||
// (b) A bare reference grants nothing; the repo link of a published document does.
|
||||
test("(b) a bare reference reads nothing; the repo link of a published document opens it", async () => {
|
||||
// (b) The ORM read filter is PURE POSSESSION — it asks nothing of anyone.
|
||||
//
|
||||
// Note what this does NOT say: that a bare reference to a public document is
|
||||
// unreadable. It is readable, through the read paths, because a public store serves
|
||||
// its cap (`emulated-verifier/public-store.ts`, and `cross-user-access.test.ts` proves
|
||||
// it). This filter sits below that: it is synchronous, it decides from what the holder
|
||||
// holds AT THAT MOMENT, and a document whose cap was never obtained is filtered out
|
||||
// whatever store it sits in. The library's own read paths ask first; the reactive ORM
|
||||
// view has no door to ask through, and that limit is recorded in `read-filter.ts`.
|
||||
test("(b) the read-filtered view decides on possession alone, with no lookup", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const pub = await createEntityDoc("alice", "public");
|
||||
const items = [item(pub, "u1")];
|
||||
expect(getCaps().isInPublicStore(pub)).toBe(true);
|
||||
const link = getCaps().capFor(pub)!;
|
||||
const cap = getCaps().capFor(pub)!;
|
||||
|
||||
// bob HAS the document's bare NURI (it is right there in `items`) and reads nothing.
|
||||
// bob HAS the document's bare NURI (it is right there in `items`), holds no cap for
|
||||
// it, and the view drops it — no question asked of any store.
|
||||
setCurrentUser("bob");
|
||||
expect(view(items)).toEqual([]);
|
||||
|
||||
// Receiving the repo link — what a discovery entry actually carries — opens it.
|
||||
getCaps().learn(link);
|
||||
// Once the cap IS among what he holds — however it got there — the same view yields it.
|
||||
getCaps().learn(cap);
|
||||
expect(view(items)).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* public-store.test.ts — the emulated *"downloaded from the outerOverlay"*, in isolation.
|
||||
*
|
||||
* `cross-user-access.test.ts` proves the consequence end to end (Bob reads Alice's
|
||||
* public document from a bare reference). This file pins the primitive itself: what it
|
||||
* asks, what it refuses, and when it says nothing at all.
|
||||
*/
|
||||
import { test, expect, mock, afterEach } from "bun:test";
|
||||
import { exposeReadCap, fetchReadCap, resetPublicStoreFetches } from "../src/emulated-verifier/public-store";
|
||||
import { mintCap } from "../src/emulated-verifier/caps";
|
||||
import { getCaps } from "../src/shared-wallet/bootstrap";
|
||||
import {
|
||||
configure,
|
||||
configureStoreRegistry,
|
||||
resetConfig,
|
||||
resetStoreRegistry,
|
||||
resetCaps,
|
||||
setCurrentUser,
|
||||
} from "../src/polyfill";
|
||||
import type { Nuri } from "../src/model/types";
|
||||
|
||||
const SHIM = "urn:ng-eventually:shim";
|
||||
const SESSION = { sessionId: "sid-ps", privateStoreId: "PRIV-PS" };
|
||||
|
||||
interface Quad { g: string; s: string; p: string; o: string }
|
||||
|
||||
/** A fake `ng` holding just enough to answer the Header-branch `exposedReadCap` query. */
|
||||
function inject() {
|
||||
const quads: Quad[] = [];
|
||||
const sparql_update = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[2] as string;
|
||||
if (/^\s*DELETE WHERE/.test(query)) {
|
||||
for (let i = quads.length - 1; i >= 0; i--) if (quads[i]!.g === anchor) quads.splice(i, 1);
|
||||
return undefined;
|
||||
}
|
||||
const m = query.match(/<([^>]+)>\s+<([^>]+)>\s+"([^"]*)"/);
|
||||
if (m) quads.push({ g: anchor, s: m[1]!, p: m[2]!, o: m[3]! });
|
||||
return undefined;
|
||||
});
|
||||
const sparql_query = mock(async (...a: unknown[]) => ({
|
||||
results: {
|
||||
bindings: quads
|
||||
.filter((q) => q.g === (a[3] as string) && q.p === `${SHIM}:exposedReadCap`)
|
||||
.map((q) => ({ c: { value: q.o } })),
|
||||
},
|
||||
}));
|
||||
configure({ ng: { doc_create: mock(async () => "did:ng:o:x"), sparql_update, sparql_query } as any, useShape: (() => {}) as any });
|
||||
configureStoreRegistry({ getSession: async () => SESSION });
|
||||
resetCaps();
|
||||
resetPublicStoreFetches();
|
||||
setCurrentUser(null);
|
||||
return { sparql_query, quads };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetConfig();
|
||||
resetStoreRegistry();
|
||||
resetCaps();
|
||||
setCurrentUser(null);
|
||||
});
|
||||
|
||||
/** Arm the emulation without giving the current holder anything: some OTHER document. */
|
||||
function armEmulation(): void {
|
||||
setCurrentUser("someone-else");
|
||||
getCaps().mint("did:ng:o:unrelated");
|
||||
}
|
||||
|
||||
const PUB = "did:ng:o:pub" as Nuri;
|
||||
|
||||
test("a cap exposed on a document is downloaded by a holder that has nothing", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
await exposeReadCap(PUB, mintCap(PUB));
|
||||
|
||||
setCurrentUser("bob");
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
expect(getCaps().capFor(PUB)).toBeUndefined();
|
||||
|
||||
expect(await fetchReadCap(PUB)).toBe(true);
|
||||
expect(getCaps().capFor(PUB)).toBe(mintCap(PUB));
|
||||
// …and what he got is a READ grant, recorded as such.
|
||||
expect(getCaps().isReadOnlyPublicCap(PUB)).toBe(true);
|
||||
expect(getCaps().isInPublicStore(PUB)).toBe(true);
|
||||
});
|
||||
|
||||
test("a document that exposes nothing yields nothing — that is the normal case, not an error", async () => {
|
||||
inject();
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
expect(await fetchReadCap("did:ng:o:protected" as Nuri)).toBe(false);
|
||||
expect(getCaps().capFor("did:ng:o:protected" as Nuri)).toBeUndefined();
|
||||
});
|
||||
|
||||
// A document speaks for itself and for nothing else. Without this, whoever can write
|
||||
// into one public document could file caps for every document they care to name.
|
||||
test("a cap naming ANOTHER document is refused, not filed", async () => {
|
||||
const { quads } = inject();
|
||||
setCurrentUser("alice");
|
||||
await exposeReadCap(PUB, mintCap(PUB));
|
||||
// Forge the exposed value so it names a different document.
|
||||
quads[0]!.o = mintCap("did:ng:o:someone-elses" as Nuri);
|
||||
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
expect(await fetchReadCap(PUB)).toBe(false);
|
||||
expect(getCaps().capFor(PUB)).toBeUndefined();
|
||||
expect(getCaps().capFor("did:ng:o:someone-elses" as Nuri)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("inert while no cap has been issued at all — nothing to obtain, nothing asked", async () => {
|
||||
const { sparql_query } = inject();
|
||||
setCurrentUser("bob");
|
||||
expect(await fetchReadCap(PUB)).toBe(false);
|
||||
expect(sparql_query).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test("asked once per document: the outcome is memoised, in both directions", async () => {
|
||||
const { sparql_query } = inject();
|
||||
setCurrentUser("alice");
|
||||
await exposeReadCap(PUB, mintCap(PUB));
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
|
||||
await fetchReadCap(PUB);
|
||||
const afterHit = sparql_query.mock.calls.length;
|
||||
await fetchReadCap(PUB); // held now → not even the memo is consulted
|
||||
expect(sparql_query.mock.calls.length).toBe(afterHit);
|
||||
|
||||
const absent = "did:ng:o:nothing-here" as Nuri;
|
||||
await fetchReadCap(absent);
|
||||
const afterMiss = sparql_query.mock.calls.length;
|
||||
await fetchReadCap(absent); // a miss is remembered too
|
||||
expect(sparql_query.mock.calls.length).toBe(afterMiss);
|
||||
});
|
||||
|
||||
test("resetting the caps forgets the memo — a stale yes would hand back what is no longer held", async () => {
|
||||
const { sparql_query } = inject();
|
||||
setCurrentUser("alice");
|
||||
await exposeReadCap(PUB, mintCap(PUB));
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
await fetchReadCap(PUB);
|
||||
|
||||
resetCaps(); // also calls resetPublicStoreFetches
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
const before = sparql_query.mock.calls.length;
|
||||
expect(await fetchReadCap(PUB)).toBe(true);
|
||||
expect(sparql_query.mock.calls.length).toBeGreaterThan(before); // asked again
|
||||
});
|
||||
@@ -18,7 +18,7 @@ function setup(initial: string | null = "alice") {
|
||||
const before = holder;
|
||||
holder = "alice";
|
||||
caps.mint("did:ng:o:alice");
|
||||
const link = caps.recordInPublicStore("did:ng:o:public");
|
||||
const link = caps.open("did:ng:o:public", "public");
|
||||
holder = before;
|
||||
return { caps, link, become: (id: string | null) => (holder = id) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user