Align the cap emulation on NextGraph's model, and confine it to a virtual user
Two batches, verified against nextgraph-rs throughout. P1a — the capability surface. Reading was an ACL (Map<doc, Set<principal>>), the exact inversion of key possession. It is now possession: `capFor(nuri)` is the only question, there is no principal parameter anywhere, and nothing turns a bare reference into a cap. Sharing is `shareCap(cap, toInbox)`, a Link deposit; receiving needs no operation. `Nuri` and `ReadCap` are template literal types, so passing a bare reference where a cap belongs is a compile error, with runtime guards behind it for JavaScript callers. The virtual user boundary. Every access function is now confined to the connected user, through two rules on one criterion (possession), implemented in two places so a lapse in either is caught by the other: authorization at the passage points, and "do not even attempt" at the callers. The polyfill's own machinery moved to physical.ts — unguarded, never exported — which replaced an exemption list: the machinery no longer gets waved through the guard, it calls something the guard never saw. Removed, as emulating capabilities the target does not have: - discovery.ts and its global index. There is no discovery in NextGraph; you follow links. It also pooled user data across wallets. - the cross-account fan-out (listEntityDocs, resolveReadGraphs, allAccounts, loadShim), which was cross-user enumeration by construction. - resolveInboxAnchor, a single inbox common to every user. Caps are now stored where NextGraph stores them, and read back rather than recomputed: AddRepo on the store's Store branch for documents a user creates, AddLink on its User branch for caps received. Inboxes belong to someone — the user's own, plus one per document — and connecting a user drains them all; that is the library's job, not the app's. Corrections worth recording: a ReadCap is `r:`, not `:k:` (reported by NextGraph's developer, verified in BlockRef::readcap_nuri); received caps DO have a register (AddLink), contrary to what this repo's notes claimed; and "wallet" upstream means keyring — what owns three stores is a user, so the vocabulary follows. The cap value is the constant OK: the only question the emulation answers is whether a cap is held. P1b replaces that one constant with a real key. After this the shape is right and the isolation is still fake. Nothing here may be described as anonymous or private.
This commit is contained in:
@@ -1,83 +1,165 @@
|
||||
/**
|
||||
* 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 } from "../src/caps";
|
||||
import { hasReadCap, targetOf } from "../src/nuri";
|
||||
import type { ReadCap } from "../src/types";
|
||||
|
||||
test("public documents are readable by anyone, even anonymous", () => {
|
||||
const caps = new CapRegistry();
|
||||
caps.open("did:ng:o:pub", "public", "alice");
|
||||
expect(caps.canRead("did:ng:o:pub", null)).toBe(true);
|
||||
expect(caps.canRead("did:ng:o:pub", "bob")).toBe(true);
|
||||
/** 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("protected documents: owner + explicitly granted principals only", () => {
|
||||
const caps = new CapRegistry();
|
||||
caps.open("did:ng:o:prot", "protected", "alice");
|
||||
expect(caps.canRead("did:ng:o:prot", "alice")).toBe(true);
|
||||
expect(caps.canRead("did:ng:o:prot", "bob")).toBe(false);
|
||||
caps.grantRead("did:ng:o:prot", "bob"); // a directed grant issues bob the read cap
|
||||
expect(caps.canRead("did:ng:o:prot", "bob")).toBe(true);
|
||||
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();
|
||||
});
|
||||
|
||||
test("private documents: owner only", () => {
|
||||
const caps = new CapRegistry();
|
||||
caps.open("did:ng:o:priv", "private", "alice");
|
||||
expect(caps.canRead("did:ng:o:priv", "alice")).toBe(true);
|
||||
expect(caps.canRead("did:ng:o:priv", "bob")).toBe(false);
|
||||
expect(caps.canRead("did:ng:o:priv", null)).toBe(false);
|
||||
// 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("protectedDocsOf surfaces an owner's protected documents for directed grants", () => {
|
||||
const caps = new CapRegistry();
|
||||
caps.open("did:ng:o:prot1", "protected", "alice");
|
||||
caps.open("did:ng:o:prot2", "protected", "alice");
|
||||
caps.open("did:ng:o:pub", "public", "alice"); // not protected → excluded
|
||||
caps.open("did:ng:o:priv", "private", "alice"); // not protected → excluded
|
||||
caps.open("did:ng:o:bob", "protected", "bob"); // other owner → excluded
|
||||
expect(caps.protectedDocsOf("alice").sort()).toEqual([
|
||||
"did:ng:o:prot1",
|
||||
"did:ng:o:prot2",
|
||||
]);
|
||||
expect(caps.protectedDocsOf("bob")).toEqual(["did:ng:o:bob"]);
|
||||
expect(caps.protectedDocsOf("carol")).toEqual([]);
|
||||
// A directed grant on one of them makes the reader read that doc only.
|
||||
caps.grantRead("did:ng:o:prot1", "carol");
|
||||
expect(caps.canRead("did:ng:o:prot1", "carol")).toBe(true);
|
||||
expect(caps.canRead("did:ng:o:prot2", "carol")).toBe(false);
|
||||
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("write is restricted to write-cap holders; the creator always holds it", () => {
|
||||
const caps = new CapRegistry();
|
||||
caps.open("did:ng:o:pub", "public", "alice");
|
||||
expect(caps.canWrite("did:ng:o:pub", "alice")).toBe(true);
|
||||
expect(caps.canWrite("did:ng:o:pub", "bob")).toBe(false);
|
||||
expect(caps.canWrite("did:ng:o:pub", null)).toBe(false);
|
||||
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("holding a document's cap does NOT grant another document (no inheritance)", () => {
|
||||
const caps = new CapRegistry();
|
||||
caps.grantRead("did:ng:o:doc1", "alice");
|
||||
expect(caps.canRead("did:ng:o:doc1", "alice")).toBe(true);
|
||||
expect(caps.canRead("did:ng:o:doc2", "alice")).toBe(false); // separate repo, separate cap
|
||||
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);
|
||||
});
|
||||
|
||||
test("governsRead / hasReadPolicy distinguish governed from ungoverned documents", () => {
|
||||
const caps = new CapRegistry();
|
||||
expect(caps.hasReadPolicy()).toBe(false);
|
||||
caps.grantRead("did:ng:o:doc1", "alice");
|
||||
expect(caps.hasReadPolicy()).toBe(true);
|
||||
expect(caps.governsRead("did:ng:o:doc1")).toBe(true);
|
||||
expect(caps.governsRead("did:ng:o:unknown")).toBe(false); // not declared → not enforced
|
||||
test("publishRepoLink returns a cap-bearing link; reading it still means HOLDING it", () => {
|
||||
const { caps, become } = registry("alice");
|
||||
const doc = "did:ng:o:public-doc";
|
||||
const link = caps.publishRepoLink(doc);
|
||||
|
||||
expect(hasReadCap(link)).toBe(true);
|
||||
expect(targetOf(link)).toBe(doc);
|
||||
expect(caps.isPublished(doc)).toBe(true);
|
||||
expect(caps.isPublished("did:ng:o:other")).toBe(false);
|
||||
|
||||
// Publication is not a world-wide read grant: whoever HAS the URL reads it.
|
||||
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("governsWrite / hasWritePolicy distinguish governed from ungoverned documents", () => {
|
||||
const caps = new CapRegistry();
|
||||
test("open(): a public document is published as a link, 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.isPublished("did:ng:o:pub")).toBe(true);
|
||||
expect(caps.isPublished("did:ng:o:prot")).toBe(false);
|
||||
expect(caps.isPublished("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.open("did:ng:o:doc1", "private", "alice"); // owner gets the write cap
|
||||
caps.grantWrite("did:ng:o:doc", "alice");
|
||||
expect(caps.hasWritePolicy()).toBe(true);
|
||||
expect(caps.governsWrite("did:ng:o:doc1")).toBe(true);
|
||||
expect(caps.governsWrite("did:ng:o:doc")).toBe(true);
|
||||
expect(caps.governsWrite("did:ng:o:unknown")).toBe(false); // not declared → not enforced
|
||||
// A public doc grants read to all but its write cap is still owner-only.
|
||||
const pub = new CapRegistry();
|
||||
pub.open("did:ng:o:pub", "public", "alice");
|
||||
expect(pub.hasWritePolicy()).toBe(true);
|
||||
expect(pub.governsWrite("did:ng:o:pub")).toBe(true);
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user