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:
@@ -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
|
||||
});
|
||||
Reference in New Issue
Block a user