0832338201
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.
194 lines
8.0 KiB
TypeScript
194 lines
8.0 KiB
TypeScript
/**
|
|
* caps.test.ts — the cap surface as KEY POSSESSION.
|
|
*
|
|
* What these prove is a SHAPE, not a protection (the library is deliberately
|
|
* insecure until P1b): the only question the registry can answer is "do I hold
|
|
* this document's cap?", there is no principal to look up in a list, and no
|
|
* function turns a bare reference into a cap.
|
|
*/
|
|
import { test, expect } from "bun:test";
|
|
import { CapRegistry, mintCap } from "../src/emulated-verifier/caps";
|
|
import { hasReadCap, targetOf } from "../src/model/nuri";
|
|
import type { ReadCap } from "../src/model/types";
|
|
|
|
/** A registry whose holder the test drives. */
|
|
function registry(initial: string | null = "alice") {
|
|
let holder = initial;
|
|
const caps = new CapRegistry(() => holder);
|
|
return { caps, become: (id: string | null) => (holder = id) };
|
|
}
|
|
|
|
test("a cap NAMES and READS; the bare reference only names", () => {
|
|
const { caps } = registry();
|
|
const doc = "did:ng:o:doc1:v:overlay";
|
|
|
|
// Before anything: naming a document tells you nothing about reading it.
|
|
expect(caps.capFor(doc)).toBeUndefined();
|
|
|
|
const cap = caps.mint(doc);
|
|
expect(hasReadCap(cap)).toBe(true); // carries `:r:`
|
|
expect(hasReadCap(doc)).toBe(false);
|
|
expect(targetOf(cap)).toBe(doc); // same object, key inside
|
|
expect(caps.capFor(doc)).toBe(cap);
|
|
// Looking the cap up by the cap-bearing form resolves the same document.
|
|
expect(caps.capFor(cap)).toBe(cap);
|
|
});
|
|
|
|
test("no cap is derivable from a bare reference — you look it up or you were given it", () => {
|
|
const { caps } = registry();
|
|
caps.mint("did:ng:o:mine");
|
|
// A document that never entered the held caps stays unreadable, however well-formed
|
|
// its reference is. There is no `grantRead`, and no principal to name.
|
|
expect(caps.capFor("did:ng:o:someone-else")).toBeUndefined();
|
|
});
|
|
|
|
// Passing the naming form where the reading form is meant is now a COMPILE error
|
|
// (`ReadCap` is a template literal type). The runtime refusal still has to hold,
|
|
// because a JavaScript consumer — or a cap read back from storage, a URL or JSON
|
|
// and cast rather than narrowed — never meets the compiler. The `as` below is
|
|
// exactly that consumer: it is how the mistake reaches the library at all.
|
|
// Unchecked, it would file a bare reference as its own cap and make the document
|
|
// read — the exact inversion this batch removes.
|
|
test("learn REFUSES a bare reference, even when the compiler was bypassed", () => {
|
|
const { caps } = registry();
|
|
const bare = "did:ng:o:someone-elses-doc" as ReadCap; // a JS consumer / an unchecked cast
|
|
expect(() => caps.learn(bare)).toThrow(/naming is not reading|bare reference/i);
|
|
expect(caps.capFor("did:ng:o:someone-elses-doc")).toBeUndefined(); // nothing was filed
|
|
expect(caps.isEnforcing()).toBe(false); // and nothing was issued
|
|
});
|
|
|
|
test("holding one document's cap grants nothing on another (no inheritance)", () => {
|
|
const { caps } = registry();
|
|
caps.mint("did:ng:o:doc1");
|
|
expect(caps.capFor("did:ng:o:doc1")).toBeDefined();
|
|
expect(caps.capFor("did:ng:o:doc2")).toBeUndefined(); // separate repo, separate cap
|
|
});
|
|
|
|
test("one set of held caps PER holder: switching identity switches heldByHolder, it does not wipe", () => {
|
|
const { caps, become } = registry("alice");
|
|
const doc = "did:ng:o:alice-doc";
|
|
const cap = caps.mint(doc);
|
|
|
|
become("bob");
|
|
expect(caps.capFor(doc)).toBeUndefined(); // bob holds nothing of alice's
|
|
|
|
become("alice");
|
|
expect(caps.capFor(doc)).toBe(cap); // …and alice did not lose hers
|
|
});
|
|
|
|
test("a cap received (learn) reads, exactly like one minted", () => {
|
|
const alice = registry("alice");
|
|
const doc = "did:ng:o:shared";
|
|
const cap = alice.caps.mint(doc);
|
|
|
|
const bob = registry("bob");
|
|
expect(bob.caps.capFor(doc)).toBeUndefined();
|
|
bob.caps.learn(cap); // delivered to bob's inbox, absorbed
|
|
expect(bob.caps.capFor(doc)).toBe(cap);
|
|
});
|
|
|
|
// 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";
|
|
caps.markInPublicStore(doc);
|
|
|
|
expect(caps.isInPublicStore(doc)).toBe(true);
|
|
expect(caps.isInPublicStore("did:ng:o:other")).toBe(false);
|
|
// 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();
|
|
});
|
|
|
|
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");
|
|
const priv = caps.open("did:ng:o:priv", "private");
|
|
|
|
expect(caps.isInPublicStore("did:ng:o:pub")).toBe(true);
|
|
expect(caps.isInPublicStore("did:ng:o:prot")).toBe(false);
|
|
expect(caps.isInPublicStore("did:ng:o:priv")).toBe(false);
|
|
// All three are readable BY THEIR OWNER — a creator is never locked out.
|
|
for (const [doc, cap] of [["did:ng:o:pub", pub], ["did:ng:o:prot", prot], ["did:ng:o:priv", priv]] as const) {
|
|
expect(caps.capFor(doc)).toBe(cap);
|
|
}
|
|
});
|
|
|
|
test("open() is idempotent — re-listing my own documents refiles the same caps", () => {
|
|
const { caps } = registry();
|
|
const first = caps.open("did:ng:o:doc", "protected");
|
|
let fired = 0;
|
|
caps.onChange(() => (fired += 1));
|
|
expect(caps.open("did:ng:o:doc", "protected")).toBe(first);
|
|
expect(fired).toBe(0); // nothing changed → no spurious re-read
|
|
});
|
|
|
|
test("isEnforcing is false until the first cap exists, then holds for every holder", () => {
|
|
const { caps, become } = registry("alice");
|
|
expect(caps.isEnforcing()).toBe(false);
|
|
caps.mint("did:ng:o:doc1");
|
|
expect(caps.isEnforcing()).toBe(true);
|
|
// …including for a holder whose own holds nothing: that IS the isolation.
|
|
become("bob");
|
|
expect(caps.isEnforcing()).toBe(true);
|
|
expect(caps.capFor("did:ng:o:doc1")).toBeUndefined();
|
|
});
|
|
|
|
test("a cap arriving fires the change signal — an asynchronous delivery must re-trigger reads", () => {
|
|
const { caps } = registry();
|
|
let fired = 0;
|
|
const unsub = caps.onChange(() => (fired += 1));
|
|
|
|
caps.learn(caps.mint("did:ng:o:doc1")); // mint fires once; the learn is a no-op
|
|
expect(fired).toBe(1);
|
|
|
|
unsub();
|
|
caps.mint("did:ng:o:doc2");
|
|
expect(fired).toBe(1); // unsubscribed
|
|
});
|
|
|
|
test("write is restricted to write-cap holders (decorative until P1b)", () => {
|
|
const { caps } = registry();
|
|
expect(caps.hasWritePolicy()).toBe(false);
|
|
caps.grantWrite("did:ng:o:doc", "alice");
|
|
expect(caps.hasWritePolicy()).toBe(true);
|
|
expect(caps.governsWrite("did:ng:o:doc")).toBe(true);
|
|
expect(caps.governsWrite("did:ng:o:unknown")).toBe(false); // not declared → not enforced
|
|
expect(caps.canWrite("did:ng:o:doc", "alice")).toBe(true);
|
|
expect(caps.canWrite("did:ng:o:doc", "bob")).toBe(false);
|
|
expect(caps.canWrite("did:ng:o:doc", null)).toBe(false);
|
|
});
|