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:
@@ -28,6 +28,8 @@ import {
|
||||
resetConfig,
|
||||
setCurrentUser,
|
||||
getCurrentUser,
|
||||
resetCaps,
|
||||
connectedUser,
|
||||
} from "../src/polyfill";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -97,6 +99,17 @@ afterAll(() => {
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Two process-wide things bite this suite, which only wants to watch the log:
|
||||
// - the cap registry: once ANY cap exists the reach guard applies to every reader;
|
||||
// - connecting a user does WORK (restore + drain its inbox, see connect.ts), which
|
||||
// both logs and files caps, asynchronously.
|
||||
// So: let any in-flight connection finish, THEN clear. Awaiting rather than hoping
|
||||
// is what makes this deterministic — `setCurrentUser` is fire-and-forget by design.
|
||||
beforeEach(async () => {
|
||||
await connectedUser();
|
||||
resetCaps();
|
||||
});
|
||||
|
||||
describe("access-log: OFF by default", () => {
|
||||
beforeEach(() => {
|
||||
// Force the env var OFF for these tests, regardless of the shell environment.
|
||||
@@ -147,8 +160,10 @@ describe("access-log: OFF by default", () => {
|
||||
});
|
||||
|
||||
describe("access-log: ON via configure({ debugAccessLog: true })", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
setCurrentUser("alice");
|
||||
await connectedUser(); // drain the connection work before counting log lines
|
||||
resetCaps();
|
||||
});
|
||||
|
||||
it("sparqlQuery emits a READ line with identity, nuri, label, and row-count", async () => {
|
||||
@@ -262,9 +277,11 @@ describe("access-log: identity follows setCurrentUser", () => {
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
expect(lines.length).toBe(2);
|
||||
expect(lines[0]).toMatch(/^\[first-user\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
||||
expect(lines[1]).toMatch(/^\[second-user\]\[polyfill\] /);
|
||||
// Only this test's own lines: connecting a user legitimately logs its own reads.
|
||||
const mine = lines.filter((l) => l.includes("step1") || l.includes("step2"));
|
||||
expect(mine.length).toBe(2);
|
||||
expect(mine[0]).toMatch(/^\[first-user\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
||||
expect(mine[1]).toMatch(/^\[second-user\]\[polyfill\] /);
|
||||
});
|
||||
|
||||
it("prefix is (none) when no identity is set", async () => {
|
||||
|
||||
@@ -23,7 +23,6 @@ import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
|
||||
import {
|
||||
ensureAccount,
|
||||
resolveAccount,
|
||||
loadShim,
|
||||
resetRegistryCache,
|
||||
} from "../src/store-registry";
|
||||
import type { RegistrySession } from "../src/store-registry";
|
||||
@@ -225,7 +224,8 @@ describe("deterministic resolution over a doc-shim corrupted by fork residue", (
|
||||
const r1 = await resolveAccount("dupuser");
|
||||
resetRegistryCache();
|
||||
const r2 = await resolveAccount("dupuser");
|
||||
const viaShim = (await loadShim()).get("dupuser");
|
||||
resetRegistryCache();
|
||||
const viaShim = await resolveAccount("dupuser");
|
||||
|
||||
// Canonical = lexicographically smallest → "did:ng:o:pub-a".
|
||||
expect(r1?.docPublic).toBe("did:ng:o:pub-a");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*
|
||||
* The heal: `resolveAccount`/`loadShim`/`ensureAccount` call `ensureRepoOpen(anchor)`
|
||||
* (open-repo.ts, via `doc_subscribe` + first-`State` barrier) before touching the
|
||||
* shim — the same open-before-read guard `readScopeIndex` already applies to its
|
||||
* shim — the same open-before-read guard `readUserStore` already applies to its
|
||||
* index doc. This suite models a fake `ng` where the anchor throws `RepoNotFound`
|
||||
* UNTIL it has been `doc_subscribe`-d, and asserts the registry provisions cleanly.
|
||||
*
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
configureStoreRegistry,
|
||||
resetStoreRegistry,
|
||||
resetConfig,
|
||||
resetCaps,
|
||||
setCurrentUser,
|
||||
} from "../src/polyfill";
|
||||
import { resetInfrastructure } from "../src/reach";
|
||||
|
||||
const SESSION = { sessionId: "sid-cold", privateStoreId: "PRIV-COLD" };
|
||||
const ANCHOR = `did:ng:${SESSION.privateStoreId}`;
|
||||
@@ -38,11 +41,20 @@ afterAll(() => {
|
||||
resetStoreRegistry();
|
||||
resetRegistryCache();
|
||||
resetOpenedRepos();
|
||||
resetCaps();
|
||||
resetInfrastructure();
|
||||
setCurrentUser(null);
|
||||
});
|
||||
|
||||
// The reach guard is process-wide and so is the cap registry: once ANY cap exists
|
||||
// the boundary applies to every reader. A suite that declares none must therefore
|
||||
// start from an empty one, or it inherits another suite's enforcement.
|
||||
beforeEach(() => {
|
||||
resetRegistryCache();
|
||||
resetOpenedRepos();
|
||||
resetCaps();
|
||||
resetInfrastructure();
|
||||
setCurrentUser(null);
|
||||
});
|
||||
|
||||
interface Quad { g: string; s: string; p: string; o: string }
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Cross-user access — the scenario that proves the model end to end.
|
||||
*
|
||||
* Alice owns a PROTECTED document and a PUBLIC one, and the public one carries a
|
||||
* REFERENCE to the protected one. Then:
|
||||
*
|
||||
* - **Bob** has the public document's link. He reads it, sees the reference, and
|
||||
* cannot read what it points at. Naming is not reading, and publication is
|
||||
* **not recursive**: a public object may point at private content without
|
||||
* disclosing it.
|
||||
* - **Charlie** has the public document's link AND was given the protected
|
||||
* document's cap. Same reference, same path — he reads through it.
|
||||
* - **Bob, dynamically**: Alice delivers the cap to Bob's inbox. Processing the
|
||||
* inbox files it, which fires the held-caps signal, which re-runs the read — the
|
||||
* protected document appears with nothing else happening.
|
||||
*
|
||||
* The difference between Bob and Charlie is ONLY what what they hold holds. There is
|
||||
* no authorization list anywhere, and nobody was named to the registry.
|
||||
*/
|
||||
import { test, expect, mock, afterAll } from "bun:test";
|
||||
import { createEntityDoc, documentInbox, resetRegistryCache, walletInbox } from "../src/store-registry";
|
||||
import type { RegistrySession } from "../src/store-registry";
|
||||
import {
|
||||
configure,
|
||||
configureStoreRegistry,
|
||||
resetStoreRegistry,
|
||||
resetConfig,
|
||||
capFor,
|
||||
getCaps,
|
||||
resetCaps,
|
||||
setCurrentUser,
|
||||
shareCap,
|
||||
connectedUser,
|
||||
} from "../src/polyfill";
|
||||
import { post, read as readInbox } from "../src/inbox";
|
||||
import { readUnion } from "../src/read-model";
|
||||
import { sparqlUpdate } from "../src/docs";
|
||||
import type { Nuri } from "../src/types";
|
||||
|
||||
afterAll(() => {
|
||||
resetConfig();
|
||||
resetStoreRegistry();
|
||||
resetCaps();
|
||||
setCurrentUser(null);
|
||||
});
|
||||
|
||||
const SESSION: RegistrySession = { sessionId: "sid-x", privateStoreId: "PRIV-X" };
|
||||
const SHIM = "urn:ng-eventually:shim";
|
||||
const INBOX = "urn:ng-eventually:inbox";
|
||||
/** The predicate Alice uses to point from her public doc at her protected one. */
|
||||
const REFERS_TO = "urn:e2e:refersTo";
|
||||
const SECRET = "urn:e2e:secret";
|
||||
|
||||
interface Quad { g: string; s: string; p: string; o: string }
|
||||
|
||||
function unescapeLiteral(s: string): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (s[i] === "\\" && i + 1 < s.length) {
|
||||
const next = s[++i];
|
||||
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
|
||||
} else out += s[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A stateful fake `ng`: the shim SPARQL, the inbox SPARQL, and the anchored
|
||||
* per-doc `?s ?p ?o` read the read-model uses. */
|
||||
function makeFakeNg() {
|
||||
const quads: Quad[] = [];
|
||||
let docCounter = 0;
|
||||
|
||||
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
|
||||
|
||||
const sparql_update = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[2] as string | undefined;
|
||||
if (!anchor) return undefined;
|
||||
const body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
||||
const sm = body.match(/<([^>]+)>/);
|
||||
if (!sm) return undefined;
|
||||
const s = sm[1]!;
|
||||
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
||||
while ((m = pairRe.exec(after)) !== null) {
|
||||
const p = m[1] ?? (query.includes(`${INBOX}:Deposit`) ? `${INBOX}:Deposit` : `${SHIM}:Account`);
|
||||
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
||||
quads.push({ g: anchor, s, p, o });
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const sparql_query = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[3] as string | undefined;
|
||||
if (query.includes(`<${SHIM}:shimDoc>`)) {
|
||||
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`).map((q) => ({ shimDoc: { value: q.o } })) } };
|
||||
}
|
||||
if (query.includes(`<${SHIM}:id>`)) {
|
||||
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
||||
const only = subjM ? subjM[1]! : null;
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
if (q.g !== anchor) continue;
|
||||
if (only !== null && q.s !== only) continue;
|
||||
const rec = bySubject.get(q.s) ?? {};
|
||||
if (q.p === `${SHIM}:id`) rec.id = q.o;
|
||||
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
|
||||
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
|
||||
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
|
||||
bySubject.set(q.s, rec);
|
||||
}
|
||||
return {
|
||||
results: {
|
||||
bindings: [...bySubject.values()].filter((r) => r.id).map((r) => ({
|
||||
id: { value: r.id! },
|
||||
docPublic: { value: r.docPublic ?? "" },
|
||||
docProtected: { value: r.docProtected ?? "" },
|
||||
docPrivate: { value: r.docPrivate ?? "" },
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (query.includes(`<${INBOX}:payload>`)) {
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
if (q.g !== anchor) continue;
|
||||
const rec = bySubject.get(q.s) ?? {};
|
||||
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
|
||||
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
|
||||
if (q.p === `${INBOX}:from`) rec.from = q.o;
|
||||
bySubject.set(q.s, rec);
|
||||
}
|
||||
return {
|
||||
results: {
|
||||
bindings: [...bySubject.values()]
|
||||
.filter((r) => r.payload !== undefined && r.ts !== undefined)
|
||||
.map((r) => {
|
||||
const row: Record<string, { value: string }> = { payload: { value: r.payload! }, ts: { value: r.ts! } };
|
||||
if (r.from !== undefined) row.from = { value: r.from };
|
||||
return row;
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
// User-branch `link` SELECT (the emulated AddLink records).
|
||||
// User-branch `inboxCap` SELECT (the emulated AddInboxCap records).
|
||||
if (query.includes(`<${SHIM}:inboxCap>`)) {
|
||||
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxCap`).map((q) => ({ c: { value: q.o } })) } };
|
||||
}
|
||||
// Store-branch `readCap` SELECT (the emulated AddRepo records).
|
||||
if (query.includes(`<${SHIM}:readCap>`)) {
|
||||
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:readCap`).map((q) => ({ c: { value: q.o } })) } };
|
||||
}
|
||||
if (query.includes(`<${SHIM}:link>`)) {
|
||||
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:link`).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 } })) } };
|
||||
}
|
||||
// Anchored per-doc read (readUnion `SELECT ?s ?p ?o`) — the document's content.
|
||||
return {
|
||||
results: {
|
||||
bindings: quads
|
||||
.filter((q) => q.g === anchor)
|
||||
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { doc_create, sparql_update, sparql_query, _quads: quads };
|
||||
}
|
||||
|
||||
function inject() {
|
||||
const ng = makeFakeNg();
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim().toLowerCase() });
|
||||
resetRegistryCache();
|
||||
resetCaps();
|
||||
setCurrentUser(null);
|
||||
return ng;
|
||||
}
|
||||
|
||||
/** Write one triple into `doc`, as the consumer's write path would. */
|
||||
async function write(doc: Nuri, p: string, o: string): Promise<void> {
|
||||
await sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${doc}> <${p}> "${o}" }`, doc, "test");
|
||||
}
|
||||
|
||||
/** The values `p` carries in the documents `docs`, as the current holder reads them. */
|
||||
async function readValues(docs: Nuri[], p: string): Promise<string[]> {
|
||||
const subjects = await readUnion(docs);
|
||||
return subjects.flatMap((s) => s.props[p] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function aliceSetsUpHerDocuments() {
|
||||
setCurrentUser("alice");
|
||||
const protDoc = await createEntityDoc("alice", "protected");
|
||||
await write(protDoc, SECRET, "the-protected-content");
|
||||
|
||||
const pubDoc = await createEntityDoc("alice", "public");
|
||||
// The reference is the BARE NURI of the protected document: it names it, and
|
||||
// grants nothing. This is the whole point of the scenario.
|
||||
await write(pubDoc, REFERS_TO, protDoc);
|
||||
|
||||
const pubLink = capFor(pubDoc)!; // the shareable repo link of the public doc
|
||||
const protCap = capFor(protDoc)!; // the cap Alice may hand to whoever she chooses
|
||||
return { protDoc, pubDoc, pubLink, protCap };
|
||||
}
|
||||
|
||||
/** Follow the reference found in the public document — what a reader actually does. */
|
||||
function referenceFoundIn(values: string[]): Nuri {
|
||||
const ref = values[0];
|
||||
expect(ref).toBeDefined();
|
||||
return ref as Nuri;
|
||||
}
|
||||
|
||||
test("Bob: reads the public document, sees the reference, and cannot read through it", async () => {
|
||||
inject();
|
||||
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments();
|
||||
|
||||
setCurrentUser("bob");
|
||||
// Bob was given the public document's link — "whoever has the URL reads it".
|
||||
getCaps().learn(pubLink);
|
||||
|
||||
// He reads the public document and finds the reference.
|
||||
const refs = await readValues([pubDoc], REFERS_TO);
|
||||
const ref = referenceFoundIn(refs);
|
||||
expect(ref).toBe(protDoc); // he can NAME Alice's protected document
|
||||
|
||||
// …and that is all it gets him: no cap, no read. Publication is NOT recursive.
|
||||
expect(capFor(ref)).toBeUndefined();
|
||||
expect(await readValues([ref], SECRET)).toEqual([]);
|
||||
});
|
||||
|
||||
test("Charlie: same public document, same reference — and he reads through it", async () => {
|
||||
inject();
|
||||
const { protDoc, pubDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
|
||||
const CHARLIE_INBOX = await walletInbox("charlie");
|
||||
|
||||
// Alice decides Charlie may read that ONE document, and delivers its cap to his
|
||||
// inbox. She names no principal to the registry; she addresses an inbox.
|
||||
setCurrentUser("alice");
|
||||
await shareCap(protCap, CHARLIE_INBOX);
|
||||
|
||||
setCurrentUser("charlie");
|
||||
getCaps().learn(pubLink);
|
||||
await readInbox(CHARLIE_INBOX); // processing the inbox files the cap
|
||||
|
||||
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
|
||||
expect(ref).toBe(protDoc);
|
||||
expect(capFor(ref)).toBe(protCap);
|
||||
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
|
||||
});
|
||||
|
||||
test("the ONLY difference between Bob and Charlie is what what they hold holds", async () => {
|
||||
inject();
|
||||
const { protDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
|
||||
const CHARLIE_INBOX = await walletInbox("charlie");
|
||||
|
||||
setCurrentUser("alice");
|
||||
await shareCap(protCap, CHARLIE_INBOX);
|
||||
|
||||
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);
|
||||
|
||||
expect(bobSees).toEqual([]);
|
||||
expect(charlieSees).toEqual(["the-protected-content"]);
|
||||
});
|
||||
|
||||
// The dynamic version: Bob is refused, then the cap lands in his inbox and the read
|
||||
// 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 { pubDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
|
||||
const BOB_INBOX = await walletInbox("bob");
|
||||
|
||||
setCurrentUser("bob");
|
||||
getCaps().learn(pubLink);
|
||||
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
|
||||
|
||||
// Before: named, unreadable.
|
||||
expect(await readValues([ref], SECRET)).toEqual([]);
|
||||
|
||||
// A reader that re-reads whenever what it holds changes — this is exactly what
|
||||
// `watchShape` wires internally, played here on an ad-hoc read.
|
||||
let reread = 0;
|
||||
let latest: string[] = [];
|
||||
const unsub = getCaps().onChange(() => {
|
||||
reread += 1;
|
||||
void readValues([ref], SECRET).then((v) => (latest = v));
|
||||
});
|
||||
|
||||
// Alice delivers the cap. Bob's client processes his inbox — the only thing that
|
||||
// happens; no "receive" call exists.
|
||||
setCurrentUser("alice");
|
||||
await shareCap(protCap, BOB_INBOX);
|
||||
setCurrentUser("bob");
|
||||
await readInbox(BOB_INBOX);
|
||||
|
||||
// Filing the cap fired the signal…
|
||||
expect(reread).toBeGreaterThan(0);
|
||||
await Promise.resolve();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
// …and the read that was empty now yields the content.
|
||||
expect(capFor(ref)).toBe(protCap);
|
||||
expect(latest).toEqual(["the-protected-content"]);
|
||||
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
test("a bare reference to the PUBLIC document is not enough either — the link is", async () => {
|
||||
inject();
|
||||
const { pubDoc, pubLink } = 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);
|
||||
expect((await readValues([pubDoc], REFERS_TO)).length).toBe(1);
|
||||
});
|
||||
|
||||
// THE POINT OF THE LINK: a cap survives because it was APPLIED, not because the
|
||||
// inbox is re-read. Upstream, processing an inbox message files it — `AddLink
|
||||
// { read_cap }` on the User branch of the private store — and the queue is consumed.
|
||||
// Re-reading a queue to recover state is using it as a database.
|
||||
test("a Link is APPLIED durably: the cap survives with the inbox emptied", async () => {
|
||||
const ng = inject();
|
||||
const { protDoc, protCap } = await aliceSetsUpHerDocuments();
|
||||
const bobInbox = await walletInbox("bob");
|
||||
|
||||
setCurrentUser("alice");
|
||||
await shareCap(protCap, bobInbox);
|
||||
|
||||
// Bob connects: the library restores + drains, with nothing asked of the app.
|
||||
setCurrentUser("bob");
|
||||
await connectedUser();
|
||||
expect(await readValues([protDoc], SECRET)).toEqual(["the-protected-content"]);
|
||||
|
||||
// Now EMPTY the inbox — as a consumed queue would be — and drop every in-memory
|
||||
// cap, then re-arm the emulation so the boundary is actually in force again.
|
||||
for (let k = ng._quads.length - 1; k >= 0; k--) {
|
||||
if (ng._quads[k]!.g === bobInbox) ng._quads.splice(k, 1);
|
||||
}
|
||||
resetCaps();
|
||||
setCurrentUser("alice");
|
||||
await createEntityDoc("alice", "private"); // re-arms: a cap exists again
|
||||
setCurrentUser("bob");
|
||||
expect(await readValues([protDoc], SECRET)).toEqual([]); // bob holds nothing yet
|
||||
|
||||
// Connecting restores it — from the User branch, since the inbox has nothing left.
|
||||
await connectedUser();
|
||||
expect(capFor(protDoc)).toBe(protCap);
|
||||
expect(await readValues([protDoc], SECRET)).toEqual(["the-protected-content"]);
|
||||
});
|
||||
|
||||
test("connecting a user that does not exist provisions nothing", async () => {
|
||||
inject();
|
||||
setCurrentUser("nobody");
|
||||
await connectedUser();
|
||||
// No account, no stores, no caps — connecting must not create a user as a side
|
||||
// effect, or the emulation would arm itself in the background.
|
||||
expect(getCaps().isEnforcing()).toBe(false);
|
||||
});
|
||||
|
||||
// PER-DOCUMENT INBOXES. Upstream a repo carries `inbox: Option<PrivKey>` and its
|
||||
// owner records the private half with `AddInboxCap` on the User branch — the same
|
||||
// branch as `AddLink`. So "which inboxes may I read" has one answer, and connecting
|
||||
// drains them all: the user's own, and one per document it opened an inbox on.
|
||||
test("a document has its own inbox: anyone deposits, only the owner reads", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "public");
|
||||
const docInbox = await documentInbox(doc);
|
||||
expect(docInbox).not.toBe(await walletInbox("alice"));
|
||||
|
||||
// Bob deposits into the document's inbox — the cross-user act, open to all.
|
||||
setCurrentUser("bob");
|
||||
await post(docInbox, { payload: { joining: true }, ts: 1 });
|
||||
|
||||
// …and cannot read it back: depositing grants nothing.
|
||||
await expect(readInbox(docInbox)).rejects.toThrow(/does not belong to the connected wallet/i);
|
||||
|
||||
// Alice reads her document's inbox, because she opened it.
|
||||
setCurrentUser("alice");
|
||||
const deposits = await readInbox(docInbox);
|
||||
expect(deposits.map((d) => d.payload)).toEqual([{ joining: true }]);
|
||||
});
|
||||
|
||||
test("connecting drains BOTH levels: the user's inbox and its documents'", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const protDoc = await createEntityDoc("alice", "protected");
|
||||
const pubDoc = await createEntityDoc("alice", "public");
|
||||
const docInbox = await documentInbox(pubDoc);
|
||||
const aliceInbox = await walletInbox("alice");
|
||||
|
||||
// Two deposits, one at each level, both made by someone else.
|
||||
setCurrentUser("carol");
|
||||
const carolDoc = await createEntityDoc("carol", "protected");
|
||||
await shareCap(capFor(carolDoc)!, aliceInbox); // a Link, to alice herself
|
||||
await post(docInbox, { payload: { onTheDocument: true }, ts: 2 });
|
||||
|
||||
// Alice connects: one call, both queues.
|
||||
setCurrentUser("alice");
|
||||
await connectedUser();
|
||||
|
||||
expect(capFor(carolDoc)).toBeDefined(); // the Link was applied
|
||||
expect(await readValues([protDoc], SECRET)).toEqual([]); // (protDoc holds no secret here)
|
||||
const left = await readInbox(docInbox);
|
||||
expect(left.map((d) => d.payload)).toEqual([{ onTheDocument: true }]); // consumer data stays
|
||||
});
|
||||
@@ -1,333 +0,0 @@
|
||||
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
|
||||
import { submitToIndex, readIndex, watchIndex, INDEX_ACCOUNT } from "../src/discovery";
|
||||
import type { IndexEntry } from "../src/discovery";
|
||||
import {
|
||||
configure,
|
||||
configureStoreRegistry,
|
||||
resetStoreRegistry,
|
||||
resetConfig,
|
||||
setCurrentUser,
|
||||
getCaps,
|
||||
resetCaps,
|
||||
} from "../src/polyfill";
|
||||
import { resetRegistryCache, ensureAccount } from "../src/store-registry";
|
||||
import type { RegistrySession } from "../src/store-registry";
|
||||
|
||||
// discovery.ts submits to / reads from a global index owned by a RESERVED
|
||||
// SPECIAL ACCOUNT (@index) in the shim. This suite injects one fake `ng` that
|
||||
// emulates BOTH the shim SPARQL (ensureAccount('@index') → doc_create ×3 +
|
||||
// shim INSERT/SELECT) AND the inbox SPARQL (deposit INSERT + read SELECT), over
|
||||
// a single in-memory quad store. Restore un-configured state at the end.
|
||||
afterAll(() => {
|
||||
resetConfig();
|
||||
resetStoreRegistry();
|
||||
setCurrentUser(null);
|
||||
resetCaps();
|
||||
});
|
||||
|
||||
test("throws a clear error when configureStoreRegistry() was not called", async () => {
|
||||
resetStoreRegistry();
|
||||
resetRegistryCache();
|
||||
await expect(submitToIndex({ ref: 1 })).rejects.toThrow(
|
||||
/configureStoreRegistry\(\) must be called before use/,
|
||||
);
|
||||
});
|
||||
|
||||
interface Quad { g: string; s: string; p: string; o: string }
|
||||
|
||||
const SHIM = "urn:ng-eventually:shim";
|
||||
const INBOX = "urn:ng-eventually:inbox";
|
||||
|
||||
/** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */
|
||||
function unescapeLiteral(s: string): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (s[i] === "\\" && i + 1 < s.length) {
|
||||
const next = s[++i];
|
||||
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next;
|
||||
} else {
|
||||
out += s[i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A stateful fake `ng` serving BOTH the shim and the inbox SPARQL.
|
||||
function makeFakeNg() {
|
||||
const quads: Quad[] = [];
|
||||
let docCounter = 0;
|
||||
|
||||
// Reactive subscriptions (see inbox.test.ts): doc_subscribe registers a
|
||||
// callback per anchor + fires an initial push; sparql_update pushes a Patch to
|
||||
// that anchor's subscribers, so discovery.watchIndex (now event-driven) works
|
||||
// without a timer.
|
||||
const subs = new Map<string, Set<(r: unknown) => void>>();
|
||||
const doc_subscribe = mock(async (nuri: string, _sid: unknown, cb: (r: unknown) => void) => {
|
||||
let set = subs.get(nuri);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
subs.set(nuri, set);
|
||||
}
|
||||
set.add(cb);
|
||||
queueMicrotask(() => cb({ V0: { State: { doc: nuri } } }));
|
||||
return () => set!.delete(cb);
|
||||
});
|
||||
const pushTo = (anchor: string): void => {
|
||||
for (const cb of subs.get(anchor) ?? []) cb({ V0: { Patch: { doc: anchor } } });
|
||||
};
|
||||
|
||||
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
|
||||
|
||||
const sparql_update = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[2] as string | undefined;
|
||||
// TWO shapes coexist: the shim account write STILL uses `GRAPH <${priv}>`
|
||||
// (the private-store repo's graph name equals the plain store NURI → it
|
||||
// round-trips; key by that GRAPH IRI). The inbox deposit write has NO
|
||||
// explicit GRAPH — the real broker keys it by the ANCHORED repo's default
|
||||
// graph (repo_graph_name(id, overlay)); key it by the ANCHOR arg (a[2]).
|
||||
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
||||
let g: string;
|
||||
let body: string;
|
||||
if (gm) {
|
||||
g = gm[1]!;
|
||||
body = gm[2]!;
|
||||
} else {
|
||||
if (!anchor) return undefined;
|
||||
g = anchor;
|
||||
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
||||
}
|
||||
const sm = body.match(/<([^>]+)>/);
|
||||
if (!sm) return undefined;
|
||||
const s = sm[1]!;
|
||||
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
||||
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = pairRe.exec(after)) !== null) {
|
||||
// `a` → an rdf:type marker; the two type IRIs the modules use differ, so
|
||||
// pick by which body we're in (deposit vs account) — harmless if wrong,
|
||||
// the SELECT filters by the real predicates below.
|
||||
const isDeposit = query.includes(`${INBOX}:Deposit`);
|
||||
const p = m[1] ?? (isDeposit ? `${INBOX}:Deposit` : `${SHIM}:Account`);
|
||||
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
||||
quads.push({ g, s, p, o });
|
||||
}
|
||||
pushTo(g); // local-push to the written graph's subscribers
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const sparql_query = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[3] as string | undefined;
|
||||
// Pointer SELECT: `<shim:root> <shim:shimDoc> ?shimDoc` in the store-root graph.
|
||||
if (query.includes(`<${SHIM}:shimDoc>`)) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`)
|
||||
.map((q) => ({ shimDoc: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
// Shim account SELECT (anchored to the doc-shim, no GRAPH wrapper). Two shapes:
|
||||
// the full scan (`?acc a <Account>`) and the TARGETED bounded resolve (`<subj> a
|
||||
// <Account>`) — honour that subject filter so the bounded query is O(1)/exact.
|
||||
if (query.includes(`<${SHIM}:id>`)) {
|
||||
const subjM = query.match(new RegExp(`<([^>]+)>\\s+a\\s+<${SHIM}:Account>`));
|
||||
const onlySubject = subjM ? subjM[1]! : null;
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
if (q.g !== anchor) continue;
|
||||
if (onlySubject !== null && q.s !== onlySubject) continue;
|
||||
const rec = bySubject.get(q.s) ?? {};
|
||||
if (q.p === `${SHIM}:id`) rec.id = q.o;
|
||||
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
|
||||
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
|
||||
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
|
||||
bySubject.set(q.s, rec);
|
||||
}
|
||||
const bindings = [...bySubject.values()]
|
||||
.filter((r) => r.id)
|
||||
.map((r) => ({
|
||||
id: { value: r.id! },
|
||||
docPublic: { value: r.docPublic ?? "" },
|
||||
docProtected: { value: r.docProtected ?? "" },
|
||||
docPrivate: { value: r.docPrivate ?? "" },
|
||||
}));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
// Inbox deposit SELECT (?payload ?ts ?from).
|
||||
if (query.includes(`<${INBOX}:payload>`)) {
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
if (q.g !== anchor) continue;
|
||||
if (q.p === `${INBOX}:Deposit`) {
|
||||
if (!bySubject.has(q.s)) bySubject.set(q.s, {});
|
||||
continue;
|
||||
}
|
||||
const rec = bySubject.get(q.s) ?? {};
|
||||
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
|
||||
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
|
||||
if (q.p === `${INBOX}:from`) rec.from = q.o;
|
||||
bySubject.set(q.s, rec);
|
||||
}
|
||||
const bindings = [...bySubject.values()]
|
||||
.filter((r) => r.payload !== undefined && r.ts !== undefined)
|
||||
.map((r) => {
|
||||
const row: Record<string, { value: string }> = {
|
||||
payload: { value: r.payload! },
|
||||
ts: { value: r.ts! },
|
||||
};
|
||||
if (r.from !== undefined) row.from = { value: r.from };
|
||||
return row;
|
||||
});
|
||||
return { results: { bindings } };
|
||||
}
|
||||
// Entity-index SELECT (shim contains) — unused here.
|
||||
return { results: { bindings: [] } };
|
||||
});
|
||||
|
||||
return { doc_create, doc_subscribe, sparql_update, sparql_query, _quads: quads };
|
||||
}
|
||||
|
||||
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
|
||||
|
||||
function inject() {
|
||||
const ng = makeFakeNg();
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
configureStoreRegistry({
|
||||
getSession: async () => SESSION,
|
||||
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
|
||||
});
|
||||
resetRegistryCache();
|
||||
setCurrentUser(null);
|
||||
return ng;
|
||||
}
|
||||
|
||||
let fake: ReturnType<typeof makeFakeNg>;
|
||||
beforeEach(() => {
|
||||
fake = inject();
|
||||
});
|
||||
|
||||
test("submitToIndex creates the @index special account on first sight (3 docs)", async () => {
|
||||
await submitToIndex({ nuri: "did:ng:o:event1", title: "Concert" });
|
||||
// ensureAccount('@index') created its 3 scope docs + 1 doc-shim (first login).
|
||||
expect(fake.doc_create).toHaveBeenCalledTimes(4);
|
||||
// The deposit landed in the @index public document (its inbox).
|
||||
const depositCall = fake.sparql_update.mock.calls.find((c) =>
|
||||
(c[1] as string).includes(`${INBOX}:Deposit`),
|
||||
)!;
|
||||
expect(depositCall, "a deposit INSERT was issued").not.toBeUndefined();
|
||||
expect(depositCall[2]).toMatch(/^did:ng:o:doc/); // the index document NURI
|
||||
});
|
||||
|
||||
test("submit → read round-trips the reference as an index entry", async () => {
|
||||
setCurrentUser("alice"); // `from` is bound to the current identity
|
||||
const ref = { nuri: "did:ng:o:event1", title: "Concert au parc" };
|
||||
await submitToIndex(ref, { from: "alice", ts: 100 });
|
||||
const entries = await readIndex();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toEqual({ ref, from: "alice", ts: 100 } as IndexEntry);
|
||||
});
|
||||
|
||||
test("a reference submitted by A is discovered by a NON-connected reader via the index", async () => {
|
||||
// A submits (identified). No connection is ever declared. A separate reader
|
||||
// materializes the SAME index (same special account → same document) and sees
|
||||
// the reference — discovery is via the index, not any direct fan-out/link.
|
||||
setCurrentUser("alice");
|
||||
const ref = { nuri: "did:ng:o:evA", title: "Public event by A" };
|
||||
await submitToIndex(ref, { ts: 100 });
|
||||
|
||||
// Reader B: a fresh cache, never connected to A, reads the index.
|
||||
resetRegistryCache();
|
||||
setCurrentUser("bob");
|
||||
const entries = await readIndex();
|
||||
const refs = entries.map((e) => e.ref);
|
||||
expect(refs).toContainEqual(ref);
|
||||
expect(entries.find((e) => JSON.stringify(e.ref) === JSON.stringify(ref))!.from).toBe("alice");
|
||||
});
|
||||
|
||||
test("readIndex deduplicates identical references (materialization moderation point)", async () => {
|
||||
const ref = { nuri: "did:ng:o:dup", title: "Twice" };
|
||||
// Anonymous submissions (dedup keys on the ref, not the submitter).
|
||||
await submitToIndex(ref, { from: null, ts: 100 });
|
||||
await submitToIndex(ref, { from: null, ts: 200 }); // duplicate reference
|
||||
const entries = await readIndex();
|
||||
expect(entries).toHaveLength(1); // surfaced once
|
||||
});
|
||||
|
||||
test("from: null makes an anonymous submission", async () => {
|
||||
await submitToIndex({ nuri: "did:ng:o:anon" }, { from: null, ts: 100 });
|
||||
const entries = await readIndex();
|
||||
expect(entries[0]!.from).toBeNull();
|
||||
});
|
||||
|
||||
// (d) PUBLIC-ONLY: a protected/private document must NOT be submittable to the
|
||||
// world-readable discovery index; a public (or ungoverned) document is fine.
|
||||
test("(d) submitToIndex refuses a PROTECTED/PRIVATE document (public-only)", async () => {
|
||||
resetCaps();
|
||||
// A PROTECTED and a PRIVATE governed document, and a PUBLIC one.
|
||||
getCaps().open("did:ng:o:prot", "protected", "alice");
|
||||
getCaps().open("did:ng:o:priv", "private", "alice");
|
||||
getCaps().open("did:ng:o:pub", "public", "alice");
|
||||
|
||||
// Submitting the protected doc's NURI is REJECTED.
|
||||
await expect(
|
||||
submitToIndex({ nuri: "did:ng:o:prot" }, { from: null, doc: "did:ng:o:prot" }),
|
||||
).rejects.toThrow(/PUBLIC|public-only|protected\/private/i);
|
||||
// Private too.
|
||||
await expect(
|
||||
submitToIndex({ nuri: "did:ng:o:priv" }, { from: null, doc: "did:ng:o:priv" }),
|
||||
).rejects.toThrow(/PUBLIC|public-only|protected\/private/i);
|
||||
// The PUBLIC document passes.
|
||||
await submitToIndex({ nuri: "did:ng:o:pub" }, { from: null, doc: "did:ng:o:pub", ts: 1 });
|
||||
const entries = await readIndex();
|
||||
expect(entries.map((e) => (e.ref as { nuri: string }).nuri)).toEqual(["did:ng:o:pub"]);
|
||||
resetCaps();
|
||||
});
|
||||
|
||||
test("INDEX_ACCOUNT lives in the reserved namespace (no typed id can equal it)", () => {
|
||||
// The index account occupies a key no consumer input can produce: it is prefixed
|
||||
// with a NUL control char, which a user cannot type into an id field and
|
||||
// which no `normalizeId` output (a typeable value) contains. So it is
|
||||
// disjoint from the keys "index" / "@index" a hostile user would submit.
|
||||
expect(INDEX_ACCOUNT.startsWith("\u0000")).toBe(true); // unreachable-by-typing sentinel
|
||||
expect(INDEX_ACCOUNT).not.toBe("index");
|
||||
expect(INDEX_ACCOUNT).not.toBe("@index");
|
||||
});
|
||||
|
||||
test("a user named 'index'/'@index' does NOT resolve to the index account's document", async () => {
|
||||
// The discovery index lives on INDEX_ACCOUNT. A hostile (or unlucky) user who
|
||||
// registers as "index" or "@index" normalizes to key "index" — which must be
|
||||
// a DISJOINT key from the reserved index account, so they get their own
|
||||
// documents and cannot hijack / read-write the global index document.
|
||||
const indexRecord = await ensureAccount(INDEX_ACCOUNT);
|
||||
|
||||
// A real user "index" — same normalized form as "@index".
|
||||
const userIndex = await ensureAccount("index");
|
||||
expect(userIndex.docPublic).not.toBe(indexRecord.docPublic);
|
||||
expect(userIndex.docProtected).not.toBe(indexRecord.docProtected);
|
||||
expect(userIndex.docPrivate).not.toBe(indexRecord.docPrivate);
|
||||
|
||||
// "@index" must land on the SAME account as "index" (both normalize to
|
||||
// "index") — and still NOT on the reserved index account.
|
||||
const userAtIndex = await ensureAccount("@index");
|
||||
expect(userAtIndex.docPublic).toBe(userIndex.docPublic);
|
||||
expect(userAtIndex.docPublic).not.toBe(indexRecord.docPublic);
|
||||
});
|
||||
|
||||
test("watchIndex fires immediately then when a submission arrives", async () => {
|
||||
const seen: IndexEntry[][] = [];
|
||||
const stop = watchIndex((e) => seen.push(e), { intervalMs: 5 });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(seen.length).toBeGreaterThanOrEqual(1);
|
||||
expect(seen[seen.length - 1]).toEqual([]);
|
||||
|
||||
await submitToIndex({ nuri: "did:ng:o:watched" }, { from: null, ts: 1 });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const last = seen[seen.length - 1]!;
|
||||
expect(last.map((e) => (e.ref as any).nuri)).toContain("did:ng:o:watched");
|
||||
|
||||
stop();
|
||||
const countAfterStop = seen.length;
|
||||
await submitToIndex({ nuri: "did:ng:o:after" }, { from: null, ts: 2 });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(seen.length).toBe(countAfterStop);
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import { test, expect, mock } from "bun:test";
|
||||
import { test, expect, mock, beforeEach } from "bun:test";
|
||||
import { docCreate, sparqlUpdate, sparqlQuery } from "../src/docs";
|
||||
|
||||
// The reach guard is process-wide: once ANY cap exists it applies to every reader.
|
||||
// This suite declares none, so it must not inherit another suite's enforcement.
|
||||
beforeEach(() => {
|
||||
resetCaps();
|
||||
setCurrentUser(null);
|
||||
});
|
||||
import * as ngProxy from "../src/ng-proxy";
|
||||
|
||||
// NOTE ORDER: the "not configured → throw" case MUST run before any configure()
|
||||
@@ -18,7 +25,7 @@ test("throws a clear error when configure() was not called", async () => {
|
||||
});
|
||||
|
||||
// From here on, a fake real `ng` is injected via configure().
|
||||
import { configure } from "../src/polyfill";
|
||||
import { configure, resetCaps, setCurrentUser } from "../src/polyfill";
|
||||
|
||||
function fakeNg() {
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
|
||||
import { post, read, materialize, watch } from "../src/inbox";
|
||||
import { walletInbox, resetRegistryCache } from "../src/store-registry";
|
||||
import type { Deposit } from "../src/inbox";
|
||||
import {
|
||||
configure,
|
||||
@@ -147,7 +148,8 @@ function makeFakeNg() {
|
||||
}
|
||||
|
||||
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
|
||||
const TARGET = "did:ng:o:host-inbox";
|
||||
/** Resolved per test: an inbox BELONGS to a wallet, and only its owner may read it. */
|
||||
let TARGET: `did:ng:${string}`;
|
||||
|
||||
function inject() {
|
||||
const ng = makeFakeNg();
|
||||
@@ -159,15 +161,20 @@ function inject() {
|
||||
}
|
||||
|
||||
let fake: ReturnType<typeof makeFakeNg>;
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
fake = inject();
|
||||
resetRegistryCache();
|
||||
setCurrentUser("alice");
|
||||
TARGET = await walletInbox("alice");
|
||||
});
|
||||
|
||||
test("post writes via the real injected ng.sparql_update (not makeNg), scoped to the inbox", async () => {
|
||||
setCurrentUser("alice"); // `from` is bound to the current identity
|
||||
// Count from HERE: resolving this wallet's own inbox already wrote to the shim.
|
||||
const before = fake.sparql_update.mock.calls.length;
|
||||
await post(TARGET, { from: "alice", payload: { kind: "join" }, ts: 100 });
|
||||
expect(fake.sparql_update).toHaveBeenCalledTimes(1);
|
||||
const call = fake.sparql_update.mock.calls[0]!;
|
||||
expect(fake.sparql_update.mock.calls.length).toBe(before + 1);
|
||||
const call = fake.sparql_update.mock.calls[before]!;
|
||||
expect(call[0]).toBe("sid-1"); // sessionId from the injected session
|
||||
expect(call[2]).toBe(TARGET); // anchored to the target inbox
|
||||
// The write targets the anchored DEFAULT graph — NO explicit `GRAPH <…>`
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
/**
|
||||
* ReadCap ACTIVE — end-to-end proof that the emulated SDK enforces per-DOCUMENT
|
||||
* isolation, driven by per-entity documents + DIRECTED read grants.
|
||||
* isolation, driven by per-entity documents + KEY POSSESSION.
|
||||
*
|
||||
* Mirrors what the app does: create an entity document through the REAL registry
|
||||
* (`createEntityDoc`), declare its cap policy via `getCaps().open(doc, scope,
|
||||
* owner)`, set the current identity, and — when the app decides two identities
|
||||
* are related — issue a DIRECTED read grant on each of the owner's protected
|
||||
* documents (`getCaps().grantRead(doc, granteeId)`). Whether identities are
|
||||
* "connected" is the application's own concept: this test plays that role
|
||||
* directly. The read filter then discriminates:
|
||||
* (a) an ungranted principal is denied a PROTECTED doc; granted once the owner
|
||||
* issues a directed grant; PUBLIC readable throughout — via the ACTIVE
|
||||
* ReadCap.
|
||||
* (b) no grant → no protected read (a reader cannot grant itself).
|
||||
* (`createEntityDoc`) — which files its cap in the creator's held caps, the emulated
|
||||
* `AddRepo { read_cap }` — and, when the app decides two identities are related,
|
||||
* SHARE that one document's cap to the other's inbox (`shareCap`). The recipient
|
||||
* needs no dedicated operation: processing their inbox absorbs it.
|
||||
*
|
||||
* 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;
|
||||
* (c) switching identity SWITCHES heldByHolder — it never wipes one.
|
||||
*/
|
||||
import { test, expect, mock, afterAll } from "bun:test";
|
||||
import { createEntityDoc, resetRegistryCache } from "../src/store-registry";
|
||||
import { createEntityDoc, resetRegistryCache, walletInbox, listMyEntityDocs } from "../src/store-registry";
|
||||
import type { RegistrySession } from "../src/store-registry";
|
||||
import type { ReadCap } from "../src/types";
|
||||
import {
|
||||
configure,
|
||||
configureStoreRegistry,
|
||||
resetStoreRegistry,
|
||||
resetConfig,
|
||||
capFor,
|
||||
getCaps,
|
||||
resetCaps,
|
||||
setCurrentUser,
|
||||
shareCap,
|
||||
} from "../src/polyfill";
|
||||
import { read as readInbox } from "../src/inbox";
|
||||
import { filterReadable } from "../src/read-filter";
|
||||
|
||||
afterAll(() => {
|
||||
@@ -36,93 +41,371 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
const SESSION: RegistrySession = { sessionId: "sid", privateStoreId: "PRIV" };
|
||||
const SHIM = "urn:ng-eventually:shim";
|
||||
const INBOX = "urn:ng-eventually:inbox";
|
||||
|
||||
function inject() {
|
||||
let n = 0;
|
||||
const ng = {
|
||||
doc_create: mock(async () => `did:ng:o:doc${++n}`),
|
||||
sparql_update: mock(async () => undefined),
|
||||
sparql_query: mock(async () => ({ results: { bindings: [] } })),
|
||||
};
|
||||
interface Quad { g: string; s: string; p: string; o: string }
|
||||
|
||||
/** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */
|
||||
function unescapeLiteral(s: string): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (s[i] === "\\" && i + 1 < s.length) {
|
||||
const next = s[++i];
|
||||
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
|
||||
} else out += s[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A stateful fake `ng` serving BOTH the shim SPARQL and the inbox SPARQL. */
|
||||
function makeFakeNg() {
|
||||
const quads: Quad[] = [];
|
||||
let docCounter = 0;
|
||||
|
||||
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
|
||||
|
||||
const sparql_update = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[2] as string | undefined;
|
||||
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
||||
let g: string;
|
||||
let body: string;
|
||||
if (gm) {
|
||||
g = gm[1]!;
|
||||
body = gm[2]!;
|
||||
} else {
|
||||
if (!anchor) return undefined;
|
||||
g = anchor;
|
||||
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
||||
}
|
||||
const sm = body.match(/<([^>]+)>/);
|
||||
if (!sm) return undefined;
|
||||
const s = sm[1]!;
|
||||
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
||||
while ((m = pairRe.exec(after)) !== null) {
|
||||
const p = m[1] ?? (query.includes(`${INBOX}:Deposit`) ? `${INBOX}:Deposit` : `${SHIM}:Account`);
|
||||
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
||||
quads.push({ g, s, p, o });
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const sparql_query = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[3] as string | undefined;
|
||||
// Pointer SELECT (store-root → doc-shim).
|
||||
if (query.includes(`<${SHIM}:shimDoc>`)) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`)
|
||||
.map((q) => ({ shimDoc: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
// Account SELECT.
|
||||
if (query.includes(`<${SHIM}:id>`)) {
|
||||
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
||||
const onlySubject = subjM ? subjM[1]! : null;
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
if (q.g !== anchor) continue;
|
||||
if (onlySubject !== null && q.s !== onlySubject) continue;
|
||||
const rec = bySubject.get(q.s) ?? {};
|
||||
if (q.p === `${SHIM}:id`) rec.id = q.o;
|
||||
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
|
||||
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
|
||||
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
|
||||
bySubject.set(q.s, rec);
|
||||
}
|
||||
const bindings = [...bySubject.values()]
|
||||
.filter((r) => r.id)
|
||||
.map((r) => ({
|
||||
id: { value: r.id! },
|
||||
docPublic: { value: r.docPublic ?? "" },
|
||||
docProtected: { value: r.docProtected ?? "" },
|
||||
docPrivate: { value: r.docPrivate ?? "" },
|
||||
}));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
// Inbox deposit SELECT.
|
||||
if (query.includes(`<${INBOX}:payload>`)) {
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
if (q.g !== anchor) continue;
|
||||
const rec = bySubject.get(q.s) ?? {};
|
||||
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
|
||||
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
|
||||
if (q.p === `${INBOX}:from`) rec.from = q.o;
|
||||
bySubject.set(q.s, rec);
|
||||
}
|
||||
const bindings = [...bySubject.values()]
|
||||
.filter((r) => r.payload !== undefined && r.ts !== undefined)
|
||||
.map((r) => {
|
||||
const row: Record<string, { value: string }> = {
|
||||
payload: { value: r.payload! },
|
||||
ts: { value: r.ts! },
|
||||
};
|
||||
if (r.from !== undefined) row.from = { value: r.from };
|
||||
return row;
|
||||
});
|
||||
return { results: { bindings } };
|
||||
}
|
||||
// User-branch `link` SELECT (the emulated AddLink records).
|
||||
// User-branch `inboxCap` SELECT (the emulated AddInboxCap records).
|
||||
if (query.includes(`<${SHIM}:inboxCap>`)) {
|
||||
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxCap`).map((q) => ({ c: { value: q.o } })) } };
|
||||
}
|
||||
// Store-branch `readCap` SELECT (the emulated AddRepo records).
|
||||
if (query.includes(`<${SHIM}:readCap>`)) {
|
||||
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:readCap`).map((q) => ({ c: { value: q.o } })) } };
|
||||
}
|
||||
if (query.includes(`<${SHIM}:link>`)) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === `${SHIM}:link`)
|
||||
.map((q) => ({ c: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
// Scope-index `contains` SELECT.
|
||||
if (query.includes(`<${SHIM}:contains>`)) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`)
|
||||
.map((q) => ({ e: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
return { results: { bindings: [] } };
|
||||
});
|
||||
|
||||
return { doc_create, sparql_update, sparql_query, _quads: quads };
|
||||
}
|
||||
|
||||
function inject(normalizeId: (id: string) => string = (id) => id.trim()) {
|
||||
const ng = makeFakeNg();
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
|
||||
configureStoreRegistry({ getSession: async () => SESSION, normalizeId });
|
||||
resetRegistryCache();
|
||||
resetCaps();
|
||||
setCurrentUser(null);
|
||||
return ng;
|
||||
}
|
||||
|
||||
/** The app's relationship concept, played inline: grant `reader` the read cap of
|
||||
* every protected document owned by `owner`. */
|
||||
function grantOwnerProtectedTo(owner: string, reader: string) {
|
||||
for (const doc of getCaps().protectedDocsOf(owner)) getCaps().grantRead(doc, reader);
|
||||
}
|
||||
/** The items an ORM set would carry, one per document. */
|
||||
const item = (doc: string, id: string) => ({ "@graph": doc, "@id": id });
|
||||
/** What the current holder reads out of `items`. */
|
||||
const view = (items: Array<{ "@graph": string; "@id": string }>) =>
|
||||
filterReadable(items, getCaps()).map((i) => i["@id"]).sort();
|
||||
|
||||
test("ReadCap active: a private entity doc created via the real registry is hidden from another principal", async () => {
|
||||
|
||||
test("a created document is readable by its creator and by nobody else", async () => {
|
||||
inject();
|
||||
|
||||
setCurrentUser("alice");
|
||||
const aliceDoc = await createEntityDoc("alice", "private");
|
||||
getCaps().open(aliceDoc, "private", "alice");
|
||||
setCurrentUser("bob");
|
||||
const bobDoc = await createEntityDoc("bob", "private");
|
||||
|
||||
const bobDoc = await createEntityDoc("bob", "public");
|
||||
getCaps().open(bobDoc, "public", "bob");
|
||||
const items = [item(aliceDoc, "a1"), item(bobDoc, "b1")];
|
||||
|
||||
const items = [
|
||||
{ "@graph": aliceDoc, "@id": "a1", label: "alice-private" },
|
||||
{ "@graph": bobDoc, "@id": "b1", label: "bob-public" },
|
||||
];
|
||||
|
||||
expect(filterReadable(items, getCaps(), "bob").map((i) => i["@id"])).toEqual(["b1"]);
|
||||
expect(filterReadable(items, getCaps(), "alice").map((i) => i["@id"]).sort()).toEqual(["a1", "b1"]);
|
||||
expect(filterReadable(items, getCaps(), null).map((i) => i["@id"])).toEqual(["b1"]);
|
||||
expect(getCaps().hasReadPolicy()).toBe(true);
|
||||
setCurrentUser("alice");
|
||||
expect(view(items)).toEqual(["a1"]);
|
||||
setCurrentUser("bob");
|
||||
expect(view(items)).toEqual(["b1"]);
|
||||
setCurrentUser(null);
|
||||
expect(view(items)).toEqual([]); // anonymous holds nothing
|
||||
expect(getCaps().isEnforcing()).toBe(true);
|
||||
});
|
||||
|
||||
// (a) protected hidden while ungranted → revealed after a DIRECTED grant; public
|
||||
// readable regardless — all through the ACTIVE ReadCap.
|
||||
test("(a) PROTECTED doc: hidden ungranted, revealed after a DIRECTED grant, PUBLIC always readable", async () => {
|
||||
// (a) Sharing is per-document AND per-recipient: a share to bob leaves carol out.
|
||||
test("(a) sharing one document's cap to ONE inbox reveals it there, and only there", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const shared = await createEntityDoc("alice", "protected");
|
||||
const kept = await createEntityDoc("alice", "protected");
|
||||
const items = [item(shared, "s1"), item(kept, "k1")];
|
||||
|
||||
const aliceProtected = await createEntityDoc("alice", "protected");
|
||||
getCaps().open(aliceProtected, "protected", "alice");
|
||||
const alicePublic = await createEntityDoc("alice", "public");
|
||||
getCaps().open(alicePublic, "public", "alice");
|
||||
// BEFORE the share: bob reads nothing of alice's.
|
||||
setCurrentUser("bob");
|
||||
expect(view(items)).toEqual([]);
|
||||
|
||||
const items = [
|
||||
{ "@graph": aliceProtected, "@id": "p1" },
|
||||
{ "@graph": alicePublic, "@id": "u1" },
|
||||
];
|
||||
const view = (u: string) => filterReadable(items, getCaps(), u).map((i) => i["@id"]).sort();
|
||||
// The app decides alice↔bob are related: alice shares ONE document's cap into
|
||||
// bob's OWN inbox — the only cross-wallet act there is.
|
||||
const bobInbox = await walletInbox("bob");
|
||||
setCurrentUser("alice");
|
||||
await shareCap(capFor(shared)!, bobInbox);
|
||||
|
||||
// BEFORE any grant: bob sees only the public item.
|
||||
expect(view("bob")).toEqual(["u1"]);
|
||||
expect(view("alice")).toEqual(["p1", "u1"]);
|
||||
// bob processes his inbox — no dedicated "receive" operation exists.
|
||||
setCurrentUser("bob");
|
||||
await readInbox(bobInbox);
|
||||
expect(view(items)).toEqual(["s1"]); // the shared one only — not `kept`
|
||||
|
||||
// The app decides alice↔bob are related and grants bob the read cap of alice's
|
||||
// protected documents.
|
||||
grantOwnerProtectedTo("alice", "bob");
|
||||
|
||||
expect(view("bob")).toEqual(["p1", "u1"]);
|
||||
// A third, ungranted principal still sees only the public one.
|
||||
expect(view("carol")).toEqual(["u1"]);
|
||||
// carol, who was not shared with, still reads nothing.
|
||||
setCurrentUser("carol");
|
||||
await readInbox(await walletInbox("carol"));
|
||||
expect(view(items)).toEqual([]);
|
||||
});
|
||||
|
||||
// (b) An identity gets no protected read until the OWNER issues the grant — a
|
||||
// reader cannot grant itself.
|
||||
test("(b) no directed grant → no protected read", async () => {
|
||||
test("a cap deposit is absorbed, not surfaced as a consumer deposit", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "protected");
|
||||
const bobInbox = await walletInbox("bob");
|
||||
await shareCap(capFor(doc)!, bobInbox);
|
||||
|
||||
const aliceProtected = await createEntityDoc("alice", "protected");
|
||||
getCaps().open(aliceProtected, "protected", "alice");
|
||||
const items = [{ "@graph": aliceProtected, "@id": "p1" }];
|
||||
const view = (u: string) => filterReadable(items, getCaps(), u).map((i) => i["@id"]);
|
||||
|
||||
// mallory holds no grant on alice's protected doc → denied.
|
||||
expect(view("mallory")).toEqual([]);
|
||||
|
||||
// Granting bob (a different, legitimate reader) leaves mallory denied.
|
||||
grantOwnerProtectedTo("alice", "bob");
|
||||
expect(view("mallory")).toEqual([]);
|
||||
expect(view("bob")).toEqual(["p1"]);
|
||||
setCurrentUser("bob");
|
||||
const deposits = await readInbox(bobInbox);
|
||||
expect(deposits).toEqual([]); // infrastructure, not consumer data
|
||||
expect(capFor(doc)).toBeDefined(); // …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 () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const pub = await createEntityDoc("alice", "public");
|
||||
const items = [item(pub, "u1")];
|
||||
expect(getCaps().isPublished(pub)).toBe(true);
|
||||
const link = capFor(pub)!;
|
||||
|
||||
// bob HAS the document's bare NURI (it is right there in `items`) and reads nothing.
|
||||
setCurrentUser("bob");
|
||||
expect(view(items)).toEqual([]);
|
||||
|
||||
// Receiving the repo link — what a discovery entry actually carries — opens it.
|
||||
getCaps().learn(link);
|
||||
expect(view(items)).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
// (c) Identity change switches heldByHolder; it does not wipe them.
|
||||
test("(c) switching identity switches heldByHolder — a returning identity keeps its caps", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "protected");
|
||||
const cap = capFor(doc);
|
||||
expect(cap).toBeDefined();
|
||||
|
||||
setCurrentUser("bob");
|
||||
expect(capFor(doc)).toBeUndefined();
|
||||
|
||||
setCurrentUser("alice");
|
||||
expect(capFor(doc)).toBe(cap!); // durable across the switch — nothing re-declared
|
||||
});
|
||||
|
||||
// A virtual user IS a shim account, and the shim keys accounts through the
|
||||
// consumer's `normalizeId`. The held caps must key the SAME way: otherwise an app
|
||||
// that spells its own identity differently between two calls ("@Alice" at login,
|
||||
// "alice" later) gets a second held caps and stops reading its own documents.
|
||||
test("one held caps per virtual WALLET, not per spelling of its id", async () => {
|
||||
inject((id) => id.trim().replace(/^@+/, "").toLowerCase());
|
||||
|
||||
setCurrentUser("@Alice");
|
||||
const doc = await createEntityDoc("@Alice", "protected");
|
||||
const cap = capFor(doc);
|
||||
expect(cap).toBeDefined();
|
||||
|
||||
// Same account, spelled differently — same shim account, so the same held caps.
|
||||
setCurrentUser("alice");
|
||||
expect(capFor(doc)).toBe(cap!);
|
||||
setCurrentUser(" ALICE ");
|
||||
expect(capFor(doc)).toBe(cap!);
|
||||
|
||||
// A genuinely different account still holds nothing.
|
||||
setCurrentUser("bob");
|
||||
expect(capFor(doc)).toBeUndefined();
|
||||
});
|
||||
|
||||
// THE BREACH P1a OPENED. Caps travel as inbox deposits, so an unguarded inbox read
|
||||
// let anyone who knew an inbox NURI collect the caps addressed to its owner —
|
||||
// defeating directed sharing entirely. Depositing stays open (it is the only way a
|
||||
// link crosses between wallets at all); reading does not.
|
||||
test("an inbox may be DEPOSITED into by anyone, and READ only by its owner", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const secret = await createEntityDoc("alice", "protected");
|
||||
const bobInbox = await walletInbox("bob");
|
||||
|
||||
// Alice deposits into bob's inbox — allowed, and it grants her nothing back.
|
||||
await shareCap(capFor(secret)!, bobInbox);
|
||||
await expect(readInbox(bobInbox)).rejects.toThrow(/does not belong to the connected wallet/i);
|
||||
expect(capFor(secret)).toBeDefined(); // still hers, obviously
|
||||
|
||||
// Mallory knows the NURI of bob's inbox and tries to pocket what is in it.
|
||||
setCurrentUser("mallory");
|
||||
await expect(readInbox(bobInbox)).rejects.toThrow(/does not belong to the connected wallet/i);
|
||||
expect(capFor(secret)).toBeUndefined(); // nothing was absorbed
|
||||
|
||||
// Anonymous owns no inbox at all.
|
||||
setCurrentUser(null);
|
||||
await expect(readInbox(bobInbox)).rejects.toThrow(/no identity is set/i);
|
||||
|
||||
// Bob reads his own, and only then does the cap land.
|
||||
setCurrentUser("bob");
|
||||
await readInbox(bobInbox);
|
||||
expect(capFor(secret)).toBeDefined();
|
||||
});
|
||||
|
||||
test("a fresh session rebuilds the held caps from the scope index (the emulated AddRepo)", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "protected");
|
||||
const items = [item(doc, "p1")];
|
||||
|
||||
// Simulate a new session over the same wallet: caps are in memory, so they go —
|
||||
// the registry cache too. Only the persisted documents remain.
|
||||
resetCaps();
|
||||
resetRegistryCache();
|
||||
expect(view(items)).toEqual([]);
|
||||
|
||||
// Listing my own documents refiles their caps: this is the store branch that
|
||||
// carries `AddRepo { read_cap }` upstream.
|
||||
const { listMyEntityDocs } = await import("../src/store-registry");
|
||||
expect(await listMyEntityDocs("alice", "protected")).toEqual([doc]);
|
||||
expect(view(items)).toEqual(["p1"]);
|
||||
});
|
||||
|
||||
// The Store branch exists so a cap is READ back, not recomputed. Without this test
|
||||
// the two are indistinguishable: with a stand-in value, re-minting happens to give
|
||||
// the same string. So corrupt the stored cap and check the corruption wins — proof
|
||||
// the value comes from the store, and proof that P1b's real key will too.
|
||||
test("a document's cap is READ from the Store branch, never recomputed", async () => {
|
||||
const ng = inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "protected");
|
||||
|
||||
// The store recorded `AddRepo { read_cap }` beside the `contains` listing.
|
||||
const stored = ng._quads.filter((q) => q.p === "urn:ng-eventually:shim:readCap");
|
||||
expect(stored.length).toBe(1);
|
||||
expect(stored[0]!.o).toBe(`${doc}:r:OK`);
|
||||
|
||||
// Rewrite it to a DIFFERENT value, then start a fresh session.
|
||||
stored[0]!.o = `${doc}:r:FROM-THE-STORE`;
|
||||
resetCaps();
|
||||
resetRegistryCache();
|
||||
|
||||
expect(await listMyEntityDocs("alice", "protected")).toEqual([doc]);
|
||||
// Recomputing would have produced `:r:OK`; this is what was stored.
|
||||
expect(capFor(doc)).toBe(`${doc}:r:FROM-THE-STORE` as ReadCap);
|
||||
});
|
||||
|
||||
// The listing and the keys are separate upstream (Main vs Store branch), and the
|
||||
// separation has to survive here or a document could be listed without its cap.
|
||||
test("the listing and the caps are two separate records", async () => {
|
||||
const ng = inject();
|
||||
setCurrentUser("alice");
|
||||
await createEntityDoc("alice", "private");
|
||||
|
||||
const subjects = new Set(ng._quads.filter((q) => q.p.startsWith("urn:ng-eventually:shim:")).map((q) => q.s));
|
||||
expect(subjects.has("urn:ng-eventually:shim:index")).toBe(true); // Main branch: contains
|
||||
expect(subjects.has("urn:ng-eventually:shim:storeBranch")).toBe(true); // Store branch: readCap
|
||||
});
|
||||
|
||||
// P1b will make the stand-in value a real, non-derivable key. The moment it does,
|
||||
// any path that mints a SECOND cap instead of using the stored one breaks: the
|
||||
// creator would hold a key that does not open its own document. This pins that the
|
||||
// creation path mints exactly once.
|
||||
test("creation mints the cap ONCE — the stored value is the one held", async () => {
|
||||
const ng = inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "protected");
|
||||
|
||||
const stored = ng._quads.find((q) => q.p === "urn:ng-eventually:shim:readCap")!;
|
||||
expect(capFor(doc)).toBe(stored.o as ReadCap); // same value, not two mints that agree by luck
|
||||
});
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
setCurrentUser,
|
||||
} from "../src/polyfill";
|
||||
|
||||
// This suite injects a fake `ng` via configure() and declares write caps. Reset
|
||||
// both after each test so the docs.test.ts "not configured" guard still holds
|
||||
// and no cap policy leaks into another suite.
|
||||
// This suite injects a fake `ng` via configure() and declares WRITE caps —
|
||||
// which stay an authorization list on purpose: only READING is key possession
|
||||
// (P1a). The write axis is decorative until P1b (every internal writer bypasses
|
||||
// this proxy). Reset after each test so the docs.test.ts "not configured" guard
|
||||
// still holds and no cap leaks into another suite.
|
||||
afterEach(() => {
|
||||
resetConfig();
|
||||
resetCaps();
|
||||
@@ -40,7 +42,7 @@ test("write guard: passthrough when NO write policy is declared (no regression)"
|
||||
|
||||
test("write guard: passthrough for an UNGOVERNED doc even when a policy exists elsewhere", async () => {
|
||||
const ng = inject();
|
||||
getCaps().open("did:ng:o:other", "private", "alice"); // policy on another doc
|
||||
getCaps().grantWrite("did:ng:o:other", "alice"); // policy on another doc
|
||||
setCurrentUser("bob");
|
||||
const proxy = makeNg();
|
||||
await proxy.sparql_update("sid", UPDATE, DOC); // DOC itself is ungoverned
|
||||
@@ -49,7 +51,7 @@ test("write guard: passthrough for an UNGOVERNED doc even when a policy exists e
|
||||
|
||||
test("write guard: REJECTS when the doc is governed and the user lacks the write cap", async () => {
|
||||
const ng = inject();
|
||||
getCaps().open(DOC, "private", "alice"); // alice holds write cap
|
||||
getCaps().grantWrite(DOC, "alice"); // alice holds the write cap
|
||||
setCurrentUser("bob"); // bob does not
|
||||
const proxy = makeNg();
|
||||
await expect(proxy.sparql_update("sid", UPDATE, DOC)).rejects.toThrow(
|
||||
@@ -60,7 +62,7 @@ test("write guard: REJECTS when the doc is governed and the user lacks the write
|
||||
|
||||
test("write guard: REJECTS an anonymous (null) user on a governed doc", async () => {
|
||||
const ng = inject();
|
||||
getCaps().open(DOC, "public", "alice");
|
||||
getCaps().grantWrite(DOC, "alice");
|
||||
setCurrentUser(null);
|
||||
const proxy = makeNg();
|
||||
await expect(proxy.sparql_update("sid", UPDATE, DOC)).rejects.toThrow(
|
||||
@@ -71,7 +73,7 @@ test("write guard: REJECTS an anonymous (null) user on a governed doc", async ()
|
||||
|
||||
test("write guard: ALLOWS the write-cap holder", async () => {
|
||||
const ng = inject();
|
||||
getCaps().open(DOC, "private", "alice");
|
||||
getCaps().grantWrite(DOC, "alice");
|
||||
setCurrentUser("alice"); // owner always holds the write cap
|
||||
const proxy = makeNg();
|
||||
await proxy.sparql_update("sid", UPDATE, DOC);
|
||||
@@ -80,7 +82,7 @@ test("write guard: ALLOWS the write-cap holder", async () => {
|
||||
|
||||
test("write guard: passthrough when anchor is omitted (cannot scope the guard)", async () => {
|
||||
const ng = inject();
|
||||
getCaps().open(DOC, "private", "alice");
|
||||
getCaps().grantWrite(DOC, "alice");
|
||||
setCurrentUser("bob");
|
||||
const proxy = makeNg();
|
||||
await proxy.sparql_update("sid", "INSERT DATA {}"); // no anchor → passthrough
|
||||
|
||||
@@ -20,7 +20,15 @@
|
||||
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
|
||||
import { ensureRepoOpen, ensureReposOpen, resetOpenedRepos } from "../src/open-repo";
|
||||
import { readUnion } from "../src/read-model";
|
||||
import { configure, configureStoreRegistry, resetStoreRegistry, resetConfig } from "../src/polyfill";
|
||||
import {
|
||||
configure,
|
||||
configureStoreRegistry,
|
||||
resetStoreRegistry,
|
||||
resetConfig,
|
||||
resetCaps,
|
||||
setCurrentUser,
|
||||
} from "../src/polyfill";
|
||||
import { resetInfrastructure } from "../src/reach";
|
||||
import { resetRegistryCache } from "../src/store-registry";
|
||||
|
||||
afterAll(() => {
|
||||
@@ -30,9 +38,15 @@ afterAll(() => {
|
||||
resetOpenedRepos();
|
||||
});
|
||||
|
||||
// The reach guard and the cap registry are process-wide: once ANY cap exists the
|
||||
// boundary applies to every reader. A suite that declares none must start from an
|
||||
// empty one, or it inherits another suite's enforcement.
|
||||
beforeEach(() => {
|
||||
resetOpenedRepos();
|
||||
resetRegistryCache();
|
||||
resetCaps();
|
||||
resetInfrastructure();
|
||||
setCurrentUser(null);
|
||||
});
|
||||
|
||||
const SESSION = { sessionId: "sid-or", privateStoreId: "PRIV-OR" };
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* reach.test.ts — the virtual user boundary, at the passage points.
|
||||
*
|
||||
* A virtual user must simulate the boundary of the future single-user wallet: the
|
||||
* access functions are confined to the user currently connected, and no cross-user
|
||||
* access is permitted. Before this, `docs.sparqlQuery`/`sparqlUpdate` — both
|
||||
* exported from the SDK entry — reached ANY document of ANY identity given a
|
||||
* session id and a NURI.
|
||||
*
|
||||
* The one act that legitimately crosses: DEPOSITING into someone's inbox. It is
|
||||
* how a link travels between users at all, and it gives the depositor nothing back.
|
||||
*/
|
||||
import { test, expect, mock, afterAll } from "bun:test";
|
||||
import { sparqlQuery, sparqlUpdate, depositInto } from "../src/docs";
|
||||
import { createEntityDoc, resetRegistryCache, walletInbox } from "../src/store-registry";
|
||||
import type { RegistrySession } from "../src/store-registry";
|
||||
import {
|
||||
configure,
|
||||
configureStoreRegistry,
|
||||
resetStoreRegistry,
|
||||
resetConfig,
|
||||
resetCaps,
|
||||
setCurrentUser,
|
||||
} from "../src/polyfill";
|
||||
import { mayReach, mustNotAttempt } from "../src/reach";
|
||||
import { hasReadCap } from "../src/nuri";
|
||||
|
||||
afterAll(() => {
|
||||
resetConfig();
|
||||
resetStoreRegistry();
|
||||
resetCaps();
|
||||
setCurrentUser(null);
|
||||
});
|
||||
|
||||
const SESSION: RegistrySession = { sessionId: "sid-reach", privateStoreId: "PRIV-REACH" };
|
||||
|
||||
function inject() {
|
||||
let n = 0;
|
||||
const quads: Array<{ g: string; s: string; p: string; o: string }> = [];
|
||||
const ng = {
|
||||
doc_create: mock(async () => `did:ng:o:reach${++n}`),
|
||||
sparql_update: mock(async (...a: unknown[]) => {
|
||||
quads.push({ g: String(a[2]), s: "", p: "", o: String(a[1]) });
|
||||
return undefined;
|
||||
}),
|
||||
sparql_query: mock(async () => ({ results: { bindings: [] } })),
|
||||
};
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
|
||||
resetRegistryCache();
|
||||
resetCaps();
|
||||
setCurrentUser(null);
|
||||
return { ng, quads };
|
||||
}
|
||||
|
||||
const READ = "SELECT ?s ?p ?o WHERE { ?s ?p ?o }";
|
||||
|
||||
test("the guard is inert until the first cap exists (no regression for a cap-free consumer)", async () => {
|
||||
const { ng } = inject();
|
||||
// Nothing has been created, so no cap has been issued: everything flows.
|
||||
expect(mayReach("did:ng:o:anything")).toBe(true);
|
||||
await sparqlQuery(SESSION.sessionId, READ, undefined, "did:ng:o:anything");
|
||||
expect(ng.sparql_query).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("once caps exist, a document outside the connected user's reach is refused — read AND write", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const mine = await createEntityDoc("alice", "private");
|
||||
|
||||
// Mine: reachable.
|
||||
expect(mayReach(mine)).toBe(true);
|
||||
await sparqlQuery(SESSION.sessionId, READ, undefined, mine);
|
||||
|
||||
// A well-formed NURI I hold nothing for: named, unreachable. Both directions.
|
||||
const theirs = "did:ng:o:someone-elses-doc" as const;
|
||||
expect(mayReach(theirs)).toBe(false);
|
||||
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, theirs)).rejects.toThrow(
|
||||
/does not hold this document.s cap/i,
|
||||
);
|
||||
await expect(
|
||||
sparqlUpdate(SESSION.sessionId, "INSERT DATA { <a> <b> \"c\" }", theirs),
|
||||
).rejects.toThrow(/does not hold this document.s cap/i);
|
||||
});
|
||||
|
||||
test("the boundary follows the connected user — one user's document is another's forbidden NURI", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const aliceDoc = await createEntityDoc("alice", "private");
|
||||
setCurrentUser("bob");
|
||||
const bobDoc = await createEntityDoc("bob", "private");
|
||||
|
||||
expect(mayReach(bobDoc)).toBe(true);
|
||||
expect(mayReach(aliceDoc)).toBe(false); // bob is connected
|
||||
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, aliceDoc)).rejects.toThrow();
|
||||
|
||||
setCurrentUser("alice");
|
||||
expect(mayReach(aliceDoc)).toBe(true);
|
||||
expect(mayReach(bobDoc)).toBe(false);
|
||||
});
|
||||
|
||||
test("a user reaches its OWN stores and inbox — the boundary must not lock it out of itself", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
await createEntityDoc("alice", "protected"); // provisions alice's account
|
||||
const inbox = await walletInbox("alice");
|
||||
|
||||
expect(mayReach(inbox)).toBe(true);
|
||||
await sparqlQuery(SESSION.sessionId, READ, undefined, inbox);
|
||||
|
||||
// …and not another user's inbox.
|
||||
setCurrentUser("bob");
|
||||
expect(mayReach(inbox)).toBe(false);
|
||||
});
|
||||
|
||||
test("DEPOSITING into another user's inbox crosses the boundary, and gives nothing back", async () => {
|
||||
const { ng } = inject();
|
||||
setCurrentUser("bob");
|
||||
const bobInbox = await walletInbox("bob");
|
||||
|
||||
setCurrentUser("alice");
|
||||
await createEntityDoc("alice", "private"); // alice now holds caps → guard is armed
|
||||
expect(mayReach(bobInbox)).toBe(false); // she holds no cap for it
|
||||
|
||||
// The deposit goes through anyway — it is the one legitimate cross-user act.
|
||||
const before = ng.sparql_update.mock.calls.length;
|
||||
await depositInto(SESSION.sessionId, 'INSERT DATA { <a> <b> "c" }', bobInbox);
|
||||
expect(ng.sparql_update.mock.calls.length).toBe(before + 1);
|
||||
|
||||
// …and it grants her nothing: she still cannot read that inbox.
|
||||
expect(mayReach(bobInbox)).toBe(false);
|
||||
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, bobInbox)).rejects.toThrow(
|
||||
/does not hold this document.s cap/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("the shim is reached by the MACHINERY, not by an exemption in the boundary", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
await createEntityDoc("alice", "private"); // arms the emulation, resolves the shim
|
||||
|
||||
// The store-root and the doc-shim are NOT reachable through the virtual-user
|
||||
// surface — there is no exemption list any more. The machinery reaches them
|
||||
// through its own primitives (`physical.ts`), which the boundary never sees and
|
||||
// which are never exported from the package.
|
||||
expect(mayReach(`did:ng:${SESSION.privateStoreId}`)).toBe(false);
|
||||
await expect(
|
||||
sparqlQuery(SESSION.sessionId, READ, undefined, `did:ng:${SESSION.privateStoreId}`),
|
||||
).rejects.toThrow(/does not hold this document's cap/i);
|
||||
|
||||
// …yet the registry works, because it never asked through that door.
|
||||
const doc = await createEntityDoc("alice", "protected");
|
||||
expect(mayReach(doc)).toBe(true);
|
||||
});
|
||||
|
||||
// The two rules are deliberately redundant, and this is what that buys.
|
||||
test("rule 1 and rule 2 are independent — the guard still holds if a caller forgets to check", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
await createEntityDoc("alice", "private"); // arms the emulation
|
||||
const theirs = "did:ng:o:not-mine" as const;
|
||||
|
||||
// RULE 2 — a caller that checks first simply does not issue the operation.
|
||||
expect(mustNotAttempt(theirs)).toBe(true);
|
||||
|
||||
// RULE 1 — and a caller that does NOT check is refused anyway. This is the whole
|
||||
// point of implementing the same criterion in two places: rule 2 is where the
|
||||
// model lives (you cannot address what you hold no cap for), rule 1 is what makes
|
||||
// a lapse in rule 2 fail loudly instead of quietly succeeding.
|
||||
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, theirs)).rejects.toThrow(
|
||||
/does not hold this document's cap/i,
|
||||
);
|
||||
});
|
||||
|
||||
// Possession decides, not the shape of the reference the caller happens to hold.
|
||||
test("a BARE reference is reachable when the cap is possessed elsewhere", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "private");
|
||||
|
||||
// `doc` is the bare form — it carries no cap — yet alice possesses that cap, so
|
||||
// reaching it is legitimate. Manipulating a bare NURI is normal: references travel
|
||||
// bare through content and indexes while the cap sits in what the user holds.
|
||||
expect(hasReadCap(doc)).toBe(false);
|
||||
expect(mayReach(doc)).toBe(true);
|
||||
await sparqlQuery(SESSION.sessionId, READ, undefined, doc);
|
||||
|
||||
// The cap-bearing form of the same document answers alike.
|
||||
expect(mayReach(`${doc}:r:OK`)).toBe(true);
|
||||
|
||||
// And bob, holding neither, cannot reach it in either form.
|
||||
setCurrentUser("bob");
|
||||
expect(mayReach(doc)).toBe(false);
|
||||
expect(mayReach(`${doc}:r:OK`)).toBe(false);
|
||||
});
|
||||
|
||||
// The whole point of splitting the machinery out: one API is the app's, the other
|
||||
// must never be. A regression here is silent and total — an app holding the
|
||||
// machinery reaches every virtual user's documents.
|
||||
test("the machinery is NOT part of the package's public surface", async () => {
|
||||
const entry: Record<string, unknown> = await import("../src/index");
|
||||
const polyfill: Record<string, unknown> = await import("../src/polyfill");
|
||||
|
||||
for (const surface of [entry, polyfill]) {
|
||||
for (const name of Object.keys(surface)) {
|
||||
expect(name).not.toMatch(/^physical/);
|
||||
}
|
||||
}
|
||||
// Named explicitly, so adding one and forgetting the rule fails here.
|
||||
for (const forbidden of ["physicalQuery", "physicalUpdate", "physicalCreate", "subscribePhysicalDoc"]) {
|
||||
expect(entry[forbidden]).toBeUndefined();
|
||||
expect(polyfill[forbidden]).toBeUndefined();
|
||||
}
|
||||
// The cross-account fan-out is gone from the registry entirely.
|
||||
const registry = entry.storeRegistry as Record<string, unknown>;
|
||||
for (const gone of ["listEntityDocs", "resolveReadGraphs", "allAccounts", "loadShim"]) {
|
||||
expect(registry[gone]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
@@ -3,57 +3,82 @@ import { filterReadable, makeReadFilteredView } from "../src/read-filter";
|
||||
import { CapRegistry } from "../src/caps";
|
||||
|
||||
// The access unit is the DOCUMENT (an item's `@graph` = the repo it lives in),
|
||||
// not the item. Items here carry `@graph`; caps are granted per document.
|
||||
// not the item. Items here carry `@graph`; each holder holds caps per document.
|
||||
interface Item { id: string; "@graph"?: string }
|
||||
|
||||
const PRIV: Item = { id: "a", "@graph": "did:ng:o:alice" }; // alice's doc
|
||||
const PUB: Item = { id: "p", "@graph": "did:ng:o:public" }; // public doc
|
||||
const UNGOV: Item = { id: "n", "@graph": "did:ng:o:other" }; // doc under no policy
|
||||
const NOGRAPH: Item = { id: "x" }; // no document → kept
|
||||
const MINE: Item = { id: "a", "@graph": "did:ng:o:alice" }; // alice's doc
|
||||
const LINKED: Item = { id: "p", "@graph": "did:ng:o:public" }; // a published doc
|
||||
const FOREIGN: Item = { id: "n", "@graph": "did:ng:o:other" }; // no cap held
|
||||
const NOGRAPH: Item = { id: "x" }; // names no document
|
||||
|
||||
function caps(): CapRegistry {
|
||||
const c = new CapRegistry();
|
||||
c.grantRead("did:ng:o:alice", "alice");
|
||||
c.makePublic("did:ng:o:public");
|
||||
return c;
|
||||
/** A registry whose holder the test drives; alice created one doc and published one. */
|
||||
function setup(initial: string | null = "alice") {
|
||||
let holder = initial;
|
||||
const caps = new CapRegistry(() => holder);
|
||||
const before = holder;
|
||||
holder = "alice";
|
||||
caps.mint("did:ng:o:alice");
|
||||
const link = caps.publishRepoLink("did:ng:o:public");
|
||||
holder = before;
|
||||
return { caps, link, become: (id: string | null) => (holder = id) };
|
||||
}
|
||||
|
||||
test("filterReadable keeps public, cap-held, ungoverned and graphless items", () => {
|
||||
const items = [PRIV, PUB, UNGOV, NOGRAPH];
|
||||
expect(filterReadable(items, caps(), "alice").map(i => (i as Item).id)).toEqual(["a", "p", "n", "x"]);
|
||||
expect(filterReadable(items, caps(), "bob").map(i => (i as Item).id)).toEqual(["p", "n", "x"]);
|
||||
expect(filterReadable(items, caps(), null).map(i => (i as Item).id)).toEqual(["p", "n", "x"]);
|
||||
test("filterReadable keeps only documents whose cap is held; a graphless item names none", () => {
|
||||
const items = [MINE, LINKED, FOREIGN, NOGRAPH];
|
||||
const { caps, become } = setup("alice");
|
||||
expect(filterReadable(items, caps).map((i) => i.id)).toEqual(["a", "p", "x"]);
|
||||
|
||||
// bob holds nothing — including the published doc, until he receives its link.
|
||||
become("bob");
|
||||
expect(filterReadable(items, caps).map((i) => i.id)).toEqual(["x"]);
|
||||
});
|
||||
|
||||
test("makeReadFilteredView filters iteration/size, reflects the current user", () => {
|
||||
const set = new Set<Item>([PRIV, PUB, UNGOV, NOGRAPH]);
|
||||
let user: string | null = "bob";
|
||||
const view = makeReadFilteredView(set, caps(), () => user);
|
||||
test("a bare reference yields nothing — naming is not reading", () => {
|
||||
const { caps } = setup("alice");
|
||||
// `did:ng:o:other` is perfectly well-formed and perfectly unreadable.
|
||||
expect(filterReadable([FOREIGN], caps)).toEqual([]);
|
||||
});
|
||||
|
||||
expect([...view].map(i => i.id)).toEqual(["p", "n", "x"]);
|
||||
test("receiving the repo link is what opens a published document", () => {
|
||||
const { caps, link, become } = setup("alice");
|
||||
become("bob");
|
||||
expect(filterReadable([LINKED], caps)).toEqual([]);
|
||||
caps.learn(link);
|
||||
expect(filterReadable([LINKED], caps).map((i) => i.id)).toEqual(["p"]);
|
||||
});
|
||||
|
||||
test("makeReadFilteredView filters iteration/size, and follows the holder in effect", () => {
|
||||
const set = new Set<Item>([MINE, LINKED, FOREIGN, NOGRAPH]);
|
||||
const { caps, become } = setup("bob");
|
||||
const view = makeReadFilteredView(set, caps);
|
||||
|
||||
expect([...view].map((i) => i.id)).toEqual(["x"]);
|
||||
expect(view.size).toBe(1);
|
||||
|
||||
become("alice"); // the held caps are read lazily → the view updates without rewrapping
|
||||
expect([...view].map((i) => i.id)).toEqual(["a", "p", "x"]);
|
||||
expect(view.size).toBe(3);
|
||||
|
||||
user = "alice"; // read lazily → view updates without rewrapping
|
||||
expect([...view].map(i => i.id)).toEqual(["a", "p", "n", "x"]);
|
||||
expect(view.size).toBe(4);
|
||||
});
|
||||
|
||||
test("makeReadFilteredView forwards mutations and membership to the target", () => {
|
||||
const set = new Set<Item>([PUB]);
|
||||
const view = makeReadFilteredView(set, caps(), () => "bob");
|
||||
const set = new Set<Item>([LINKED]);
|
||||
const { caps } = setup("alice");
|
||||
const view = makeReadFilteredView(set, caps);
|
||||
const C: Item = { id: "c", "@graph": "did:ng:o:public" };
|
||||
|
||||
view.add(C);
|
||||
expect(set.has(C)).toBe(true); // mutation reached the real set
|
||||
expect([...view].map(i => i.id)).toEqual(["p", "c"]);
|
||||
expect([...view].map((i) => i.id)).toEqual(["p", "c"]);
|
||||
|
||||
view.delete(C);
|
||||
expect(set.has(C)).toBe(false);
|
||||
});
|
||||
|
||||
test("forEach is filtered too", () => {
|
||||
const set = new Set<Item>([PRIV, PUB]);
|
||||
const set = new Set<Item>([MINE, LINKED]);
|
||||
const seen: string[] = [];
|
||||
makeReadFilteredView(set, caps(), () => "bob").forEach((i) => seen.push((i as Item).id));
|
||||
expect(seen).toEqual(["p"]);
|
||||
const { caps, become } = setup("alice");
|
||||
become("bob");
|
||||
makeReadFilteredView(set, caps).forEach((i) => seen.push((i as Item).id));
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { test, expect, mock } from "bun:test";
|
||||
import { test, expect, mock, afterAll } from "bun:test";
|
||||
import { readUnion } from "../src/read-model";
|
||||
import { configure, configureStoreRegistry } from "../src/polyfill";
|
||||
import type { Nuri } from "../src/types";
|
||||
import {
|
||||
configure,
|
||||
configureStoreRegistry,
|
||||
getCaps,
|
||||
resetCaps,
|
||||
setCurrentUser,
|
||||
} from "../src/polyfill";
|
||||
|
||||
// The cap registry is process-wide, so each inject() starts from an empty one:
|
||||
// once ANY cap exists the possession gate is in force for every reader, and a
|
||||
// suite that never declares caps must not inherit another suite's.
|
||||
afterAll(() => {
|
||||
resetCaps();
|
||||
setCurrentUser(null);
|
||||
});
|
||||
|
||||
// A fake `ng` whose sparql_query answers the ANCHORED per-doc query (SELECT ?s ?p ?o
|
||||
// WHERE { ?s ?p ?o }, anchor = the doc NURI) with ONLY that doc's triples. There is
|
||||
@@ -31,6 +46,8 @@ function fakeNgWith(triplesByDoc: Record<string, Array<[string, string]>>) {
|
||||
|
||||
function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
|
||||
const ng = fakeNgWith(triplesByDoc);
|
||||
resetCaps();
|
||||
setCurrentUser(null);
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
configureStoreRegistry({
|
||||
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
|
||||
@@ -103,3 +120,26 @@ test("a doc that fails to read is skipped, not aborting the batch", async () =>
|
||||
// The bad doc failed its read but the good one still lists.
|
||||
expect(subjects.map((s) => s.subject)).toEqual(["did:ng:o:ok"]);
|
||||
});
|
||||
|
||||
// The possession gate, at the read-model's own level: once ANY cap exists, a doc
|
||||
// whose cap is not in what the current holder holds is dropped — however well its
|
||||
// NURI resolves. Before the first cap the gate is inert (no regression).
|
||||
test("readUnion drops a doc whose cap the holder does not hold", async () => {
|
||||
inject({
|
||||
"did:ng:o:mine": [[TYPE, `${FP}Event`], [`${FP}title`, "mine"]],
|
||||
"did:ng:o:theirs": [[TYPE, `${FP}Event`], [`${FP}title`, "theirs"]],
|
||||
});
|
||||
const both: Nuri[] = ["did:ng:o:mine", "did:ng:o:theirs"];
|
||||
|
||||
// Inert: no cap issued yet → everything flows through.
|
||||
expect((await readUnion(both)).map((s) => s.subject).sort()).toEqual(both);
|
||||
|
||||
// One cap issued → possession is now the rule for every document.
|
||||
setCurrentUser("alice");
|
||||
getCaps().mint("did:ng:o:mine");
|
||||
expect((await readUnion(both)).map((s) => s.subject)).toEqual(["did:ng:o:mine"]);
|
||||
|
||||
// …and for every holder: bob holds nothing, so bob reads nothing.
|
||||
setCurrentUser("bob");
|
||||
expect(await readUnion(both)).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
|
||||
import {
|
||||
ensureAccount,
|
||||
allAccounts,
|
||||
loadShim,
|
||||
resolveWriteGraph,
|
||||
resolveReadGraphs,
|
||||
resolveAccount,
|
||||
listMyEntityDocs,
|
||||
resolveScopeGraph,
|
||||
resolveInboxAnchor,
|
||||
walletInbox,
|
||||
createEntityDoc,
|
||||
listEntityDocs,
|
||||
resetRegistryCache,
|
||||
} from "../src/store-registry";
|
||||
import type { RegistrySession } from "../src/store-registry";
|
||||
@@ -223,19 +221,10 @@ test("ensureAccount de-dupes CONCURRENT provisions (anti-fork): one account, 3 d
|
||||
for (const r of results) expect(r).toEqual(results[0]!);
|
||||
});
|
||||
|
||||
test("loadShim round-trips a persisted account across a cache reset", async () => {
|
||||
await ensureAccount("Bob");
|
||||
resetRegistryCache(); // force a re-read from the fake store
|
||||
const map = await loadShim();
|
||||
const rec = map.get("bob");
|
||||
expect(rec?.id).toBe("Bob");
|
||||
expect(rec?.docPublic).toMatch(/^did:ng:o:doc/);
|
||||
});
|
||||
|
||||
test("resolveWriteGraph returns the per-scope index doc; resolveReadGraphs fans out", async () => {
|
||||
test("resolveWriteGraph returns the per-scope index doc", async () => {
|
||||
const rec = await ensureAccount("Carol");
|
||||
expect(await resolveWriteGraph("carol", "protected")).toBe(rec.docProtected);
|
||||
expect(await resolveReadGraphs("public")).toEqual([rec.docPublic]);
|
||||
});
|
||||
|
||||
test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to the caller)", async () => {
|
||||
@@ -255,14 +244,15 @@ test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to
|
||||
expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV");
|
||||
expect(await resolveScopeGraph("protected")).toBe("did:ng:PROT");
|
||||
expect(await resolveScopeGraph("public")).toBe("did:ng:PROT"); // co-located
|
||||
// The inbox anchor is now a DEDICATED inbox DOCUMENT (a reserved account's
|
||||
// public scope doc, from docCreate) — NOT the private-store root — so inbox
|
||||
// deposits don't bloat the shim graph. It is a real repo NURI and STABLE
|
||||
// across calls (same reserved account → same document).
|
||||
const anchor = await resolveInboxAnchor();
|
||||
expect(anchor).toMatch(/^did:ng:o:doc/);
|
||||
expect(anchor).not.toBe("did:ng:PRIV");
|
||||
expect(await resolveInboxAnchor()).toBe(anchor); // stable
|
||||
// An inbox belongs to ONE virtual user — it is a dedicated document (from
|
||||
// docCreate), not the private-store root, so deposits never bloat the shim graph.
|
||||
// Stable per wallet, and DISJOINT between wallets: reading someone else's inbox
|
||||
// would collect the caps addressed to them (see inbox.ts's read guard).
|
||||
const mine = await walletInbox("@alice");
|
||||
expect(mine).toMatch(/^did:ng:o:doc/);
|
||||
expect(mine).not.toBe("did:ng:PRIV");
|
||||
expect(await walletInbox("@alice")).toBe(mine); // stable
|
||||
expect(await walletInbox("@bob")).not.toBe(mine); // another wallet, another inbox
|
||||
});
|
||||
|
||||
test("resolveScopeGraph falls back to the private store when no protected id is injected", async () => {
|
||||
@@ -272,34 +262,21 @@ test("resolveScopeGraph falls back to the private store when no protected id is
|
||||
expect(await resolveScopeGraph("public")).toBe("did:ng:PRIV");
|
||||
});
|
||||
|
||||
test("createEntityDoc + listEntityDocs round-trip via the per-scope index", async () => {
|
||||
test("createEntityDoc + listMyEntityDocs round-trip via the per-scope index", async () => {
|
||||
const rec = await ensureAccount("Dave");
|
||||
const e1 = await createEntityDoc("dave", "public");
|
||||
const e2 = await createEntityDoc("dave", "public");
|
||||
const other = await createEntityDoc("dave", "protected");
|
||||
// Public listing unions dave's public entities only.
|
||||
const pub = await listEntityDocs("public");
|
||||
const pub = await listMyEntityDocs("dave", "public");
|
||||
expect(pub.sort()).toEqual([e1, e2].sort());
|
||||
const prot = await listEntityDocs("protected");
|
||||
const prot = await listMyEntityDocs("dave", "protected");
|
||||
expect(prot).toEqual([other]);
|
||||
// The index append targets the account's public index doc.
|
||||
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
||||
});
|
||||
|
||||
test("listEntityDocs fans out across multiple accounts", async () => {
|
||||
await ensureAccount("Eve");
|
||||
await ensureAccount("Frank");
|
||||
const e = await createEntityDoc("eve", "public");
|
||||
const f = await createEntityDoc("frank", "public");
|
||||
expect((await listEntityDocs("public")).sort()).toEqual([e, f].sort());
|
||||
});
|
||||
|
||||
test("allAccounts reflects every ensured account", async () => {
|
||||
await ensureAccount("Gina");
|
||||
await ensureAccount("Hank");
|
||||
const names = (await allAccounts()).map((a) => a.id).sort();
|
||||
expect(names).toEqual(["Gina", "Hank"]);
|
||||
});
|
||||
|
||||
// --- SPARQL injection hardening (F1) --------------------------------------
|
||||
//
|
||||
@@ -384,12 +361,11 @@ test("injection: a malicious id still round-trips through the shim", async () =>
|
||||
const rec = await ensureAccount(evil);
|
||||
expect(rec.id).toBe(evil);
|
||||
resetRegistryCache();
|
||||
const map = await loadShim();
|
||||
// The stored id came back verbatim (escaping is lossless) under its
|
||||
// normalized key, and exactly ONE account exists (no injected extra subject).
|
||||
const key = evil.trim().replace(/^@+/, "").toLowerCase();
|
||||
expect(map.get(key)?.id).toBe(evil);
|
||||
expect(map.size).toBe(1);
|
||||
// The stored id comes back verbatim (escaping is lossless) when resolved by its
|
||||
// own key — and no injected extra subject answers in its place.
|
||||
const back = await resolveAccount(evil);
|
||||
expect(back?.id).toBe(evil);
|
||||
expect(back?.docPublic).toBe(rec.docPublic);
|
||||
});
|
||||
|
||||
test("normalizeId defaults to trim when not provided", async () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
configureStoreRegistry,
|
||||
resetStoreRegistry,
|
||||
resetConfig,
|
||||
resetCaps,
|
||||
setCurrentUser,
|
||||
} from "../src/polyfill";
|
||||
import { resetRegistryCache, createEntityDoc } from "../src/store-registry";
|
||||
@@ -156,6 +157,18 @@ function makeFake(opts?: { holdState?: boolean }) {
|
||||
}));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
if (query.includes("<urn:ng-eventually:shim:inboxCap>")) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:inboxCap")
|
||||
.map((q) => ({ c: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
if (query.includes("<urn:ng-eventually:shim:readCap>")) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:readCap")
|
||||
.map((q) => ({ c: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
if (query.includes("<urn:ng-eventually:shim:contains>")) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
|
||||
@@ -214,6 +227,7 @@ function inject(ng: ReturnType<typeof makeFake>) {
|
||||
});
|
||||
resetRegistryCache();
|
||||
resetOpenedRepos();
|
||||
resetCaps();
|
||||
}
|
||||
|
||||
// Insert a triple straight into a doc's graph in the fake store (no push).
|
||||
@@ -242,6 +256,9 @@ afterAll(() => {
|
||||
resetStoreRegistry();
|
||||
resetRegistryCache();
|
||||
resetOpenedRepos();
|
||||
// The cap registry is process-wide: leaving caps behind would put the possession
|
||||
// gate in force for a suite that never declares any.
|
||||
resetCaps();
|
||||
});
|
||||
|
||||
describe("watchShape", () => {
|
||||
@@ -369,4 +386,5 @@ describe("watchShape", () => {
|
||||
expect(snap.data.length).toBe(1);
|
||||
unsub();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user