refactor: le paquet s'appelle polyfill, « SDK » désigne celui de NextGraph

Le nom @ng-eventually/sdk entrait en collision avec le SDK de NextGraph, dont
ce paquet est justement un polyfill. Impossible d'écrire « le SDK » sans lever
l'ambiguïté à chaque phrase — et le contrat publié, lu par une application,
était le pire endroit pour laisser traîner ça.

packages/sdk → packages/polyfill, @ng-eventually/sdk → @ng-eventually/polyfill,
contract_sdk-surface → contract_polyfill-surface, e2e/sdk-entry.ts →
e2e/polyfill-entry.ts, docs/sdk-reference.md → docs/polyfill-reference.md.

Les occurrences de « SDK » qui désignent celui de NextGraph restent intactes,
y compris les chemins dans nextgraph-rs (sdk/js/orm, sdk/js/web). Le tri s'est
fait occurrence par occurrence, pas par substitution.

Le contrat énonce désormais son identité en une phrase : « This package is a
polyfill of NextGraph's SDK. »
This commit is contained in:
Sylvain Duchesne
2026-08-10 17:14:25 +02:00
parent 49b046268e
commit 737729c9ce
88 changed files with 122 additions and 106 deletions
+120
View File
@@ -0,0 +1,120 @@
/**
* The access gate's identity resolution.
*
* This is the piece whose failure is SILENT: get the order wrong and the broker iframe
* reads an empty identity, provisions a second virtual user, and the returning user
* lands in an empty space with no error anywhere. So the order is pinned, not trusted.
*/
import { getCurrentUser } from "../src/shared-wallet/bootstrap";
import { test, expect, afterEach } from "bun:test";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { ensureIdentity } from "../src/shared-wallet/access-gate";
const KEY = "ng-eventually:identity";
/** A localStorage double — the real one is absent in `bun test`. */
function fakeStorage(initial: Record<string, string> = {}) {
const map = new Map(Object.entries(initial));
return {
getItem: (k: string) => map.get(k) ?? null,
setItem: (k: string, v: string) => void map.set(k, v),
removeItem: (k: string) => void map.delete(k),
get size() { return map.size; },
};
}
/** Put the page in a given URL + storage state, as the browser would. */
function inPage(search: string, storage: ReturnType<typeof fakeStorage>) {
(globalThis as any).location = { search, href: "https://app.example" + search };
(globalThis as any).localStorage = storage;
(globalThis as any).history = { replaceState: () => {} };
}
afterEach(() => {
setCurrentUser(null);
resetConfig();
resetStoreRegistry();
delete (globalThis as any).location;
delete (globalThis as any).localStorage;
delete (globalThis as any).history;
});
function configured() {
configureStoreRegistry({
getSession: async () => ({ sessionId: "s", privateStoreId: "did:ng:o:p" }),
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
});
configure({
ng: {} as never,
useShape: (() => {}) as never,
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
});
}
test("an identity already set is left alone — the gate never re-asks", async () => {
configured();
inPage("", fakeStorage());
setCurrentUser("alice");
await ensureIdentity();
expect(getCurrentUser()).toBe("alice");
});
test("the URL parameter WINS over storage — it is the only thing that crosses the frontier", async () => {
// The top-level page and the broker iframe have separate localStorage partitions, so a
// value written on one side is not the value the other reads. The URL survives the
// round-trip; storage does not. If storage won here, a user entering a second
// identifier would keep being sent back to the first one's space.
configured();
inPage("?ng-id=fromurl", fakeStorage({ [KEY]: "fromstorage" }));
await ensureIdentity();
expect(getCurrentUser()).toBe("fromurl");
});
test("the URL parameter is copied into THIS partition, so a plain reload still knows", async () => {
configured();
const storage = fakeStorage();
inPage("?ng-id=carol", storage);
await ensureIdentity();
expect(storage.getItem(KEY)).toBe("carol");
});
test("with no parameter, storage answers — a reload does not re-ask", async () => {
configured();
inPage("", fakeStorage({ [KEY]: "dana" }));
await ensureIdentity();
expect(getCurrentUser()).toBe("dana");
});
test("nothing known and no DOM to ask on → it refuses loudly", async () => {
// Continuing silently would provision an anonymous virtual space, which is the failure
// this module exists to prevent. The error names what the caller must do.
configured();
inPage("", fakeStorage());
await expect(ensureIdentity()).rejects.toThrow(/no DOM to ask on/i);
});
test("no shared wallet configured → it refuses, rather than inventing a space", async () => {
configure({ ng: {} as never, useShape: (() => {}) as never });
inPage("", fakeStorage());
await expect(ensureIdentity()).rejects.toThrow(/no shared wallet configured/i);
});
test("the URL value is NORMALIZED on the way in — `@Erin` and `erin` are one space", async () => {
// Ported from the consumer's `identifiant-resolution` feature, and it caught a real
// defect here: the gate normalized what a user TYPED but not what the URL carried, so
// a link with `?ng-id=@Erin` keyed onto a different virtual user than the same person
// typing `erin`. One normalizer — the injected one — for all three entry paths.
configured();
inPage("?ng-id=@Erin", fakeStorage());
await ensureIdentity();
expect(getCurrentUser()).toBe("erin");
});
test("a stored value is normalized too — an old entry cannot key onto a second space", async () => {
configured();
inPage("", fakeStorage({ [KEY]: "@Frank" }));
await ensureIdentity();
expect(getCurrentUser()).toBe("frank");
});
+308
View File
@@ -0,0 +1,308 @@
/**
* access-log.test.ts — behavioral tests for logAccess / setAccessLog / enabled
* (src/access-log.ts), as wired through the docs primitives (src/docs.ts).
*
* Tests:
* (a) OFF by default: reads + writes via sparqlQuery / sparqlUpdate / docCreate
* emit nothing to console.log.
* (b) ON via configure({ debugAccessLog: true }): each read/write emits a line
* matching `[<identity>][polyfill] READ/WRITE <shortNuri> (<label>)` (identity
* FIRST, `[polyfill]` glued right after) plus row-count suffix on READs. The
* NURI is shortened by shortNuri (did:ng:o: prefix + :v: suffix stripped,
* RepoID truncated to 8 chars + ellipsis).
* (c) ON via env var NG_EVENTUALLY_ACCESS_LOG=1: same behavior without changing
* calling code.
* (d) Identity follows setCurrentUser: after setCurrentUser the prefix changes.
*
* Spy approach: replace console.log with a mock, restore it after each test.
* Env var tests set/delete process.env.NG_EVENTUALLY_ACCESS_LOG and restore it.
*/
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test";
import { setAccessLog, enabled, shortNuri } from "../src/shared-wallet/access-log";
import { docCreate, sparqlUpdate, sparqlQuery } from "../src/surface/docs";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { connectedUser } from "../src/emulated-verifier/connect";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function fakeNg() {
return {
doc_create: mock(async (..._a: unknown[]) => "did:ng:o:log-doc"),
sparql_update: mock(async (..._a: unknown[]) => undefined),
sparql_query: mock(async (..._a: unknown[]) => ({
results: { bindings: [{ x: { value: "v" } }] }, // 1 row so row-count is visible
})),
};
}
function injectFake(debugAccessLog = false) {
const ng = fakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any, debugAccessLog });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-log", privateStoreId: "P" }),
});
return ng;
}
// Capture console.log lines for the duration of a test.
// Returns the captured lines array and a restore function.
function spyConsoleLog(): { lines: string[]; restore: () => void } {
const lines: string[] = [];
const orig = console.log;
console.log = (...args: unknown[]) => {
lines.push(args.map(String).join(" "));
};
return { lines, restore: () => { console.log = orig; } };
}
// ---------------------------------------------------------------------------
// Lifecycle: restore config state after each test to avoid cross-test bleed.
// ---------------------------------------------------------------------------
// Save the env var value that was present BEFORE any test ran, so tests
// that run in an environment where NG_EVENTUALLY_ACCESS_LOG is already set
// don't permanently destroy that value.
const _originalEnvVar = process.env?.NG_EVENTUALLY_ACCESS_LOG;
afterEach(() => {
setAccessLog(false); // always reset the config toggle
setCurrentUser(null); // clear active identity
// Restore the original env var value (don't just delete — it may have existed before)
if ((globalThis as any)?.process?.env) {
if (_originalEnvVar === undefined) {
delete process.env.NG_EVENTUALLY_ACCESS_LOG;
} else {
process.env.NG_EVENTUALLY_ACCESS_LOG = _originalEnvVar;
}
}
});
afterAll(() => {
resetConfig();
resetStoreRegistry();
setAccessLog(false);
setCurrentUser(null);
});
// ---------------------------------------------------------------------------
// 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.
if ((globalThis as any)?.process?.env) {
delete process.env.NG_EVENTUALLY_ACCESS_LOG;
}
setAccessLog(false);
});
it("no console.log output for sparqlQuery when disabled", async () => {
injectFake(false);
const { lines, restore } = spyConsoleLog();
try {
await sparqlQuery("sid-log", "SELECT * {}");
} finally {
restore();
}
expect(lines.length).toBe(0);
});
it("no console.log output for sparqlUpdate when disabled", async () => {
injectFake(false);
const { lines, restore } = spyConsoleLog();
try {
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:x");
} finally {
restore();
}
expect(lines.length).toBe(0);
});
it("no console.log output for docCreate when disabled", async () => {
injectFake(false);
const { lines, restore } = spyConsoleLog();
try {
await docCreate("sid-log", "Graph", "data:graph", "store");
} finally {
restore();
}
expect(lines.length).toBe(0);
});
it("enabled() returns false when disabled", () => {
setAccessLog(false);
if (process.env) delete process.env.NG_EVENTUALLY_ACCESS_LOG;
expect(enabled()).toBe(false);
});
});
describe("access-log: ON via configure({ debugAccessLog: true })", () => {
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 () => {
injectFake(true);
setCurrentUser("alice");
// `setCurrentUser` FIRES the connection work; draining it here (and only then
// dropping the caps it filed) is what keeps this test about the log and not about
// whether a background connect happened to win the race.
await connectedUser();
resetCaps();
const { lines, restore } = spyConsoleLog();
try {
await sparqlQuery("sid-log", "SELECT * {}", undefined, "did:ng:o:q", "myLabel");
} finally {
restore();
}
expect(lines.length).toBe(1);
expect(lines[0]).toMatch(/^\[alice\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
expect(lines[0]).toMatch(/READ/);
expect(lines[0]).toContain(shortNuri("did:ng:o:q")); // NURI shortened
expect(lines[0]).not.toContain("did:ng:o:"); // full prefix stripped
expect(lines[0]).toMatch(/myLabel/);
expect(lines[0]).toMatch(/→ 1 triple-rows/); // triple-count from the 1-row fake result
});
it("sparqlUpdate emits a WRITE line with identity, anchor nuri, and label", async () => {
injectFake(true);
setCurrentUser("alice");
// `setCurrentUser` FIRES the connection work; draining it here (and only then
// dropping the caps it filed) is what keeps this test about the log and not about
// whether a background connect happened to win the race.
await connectedUser();
resetCaps();
const { lines, restore } = spyConsoleLog();
try {
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:w", "writeLabel");
} finally {
restore();
}
expect(lines.length).toBe(1);
expect(lines[0]).toMatch(/^\[alice\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
expect(lines[0]).toMatch(/WRITE/);
expect(lines[0]).toContain(shortNuri("did:ng:o:w"));
expect(lines[0]).toMatch(/writeLabel/);
});
it("docCreate emits a WRITE line with identity and the returned nuri", async () => {
injectFake(true);
setCurrentUser("alice");
// `setCurrentUser` FIRES the connection work; draining it here (and only then
// dropping the caps it filed) is what keeps this test about the log and not about
// whether a background connect happened to win the race.
await connectedUser();
resetCaps();
const { lines, restore } = spyConsoleLog();
try {
await docCreate("sid-log", "Graph", "data:graph", "store");
} finally {
restore();
}
expect(lines.length).toBe(1);
expect(lines[0]).toMatch(/^\[alice\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
expect(lines[0]).toMatch(/WRITE/);
// The nuri is the value returned by ng.doc_create, shortened by shortNuri.
expect(lines[0]).toContain(shortNuri("did:ng:o:log-doc"));
});
it("enabled() returns true when set via setAccessLog", () => {
setAccessLog(true);
expect(enabled()).toBe(true);
});
});
describe("access-log: ON via env var NG_EVENTUALLY_ACCESS_LOG=1", () => {
it("emits READ line when env var is set, even without configure() setting", async () => {
// Set env var but do NOT pass debugAccessLog=true to configure
if (!(globalThis as any)?.process?.env) return; // skip in env-less runtimes
process.env.NG_EVENTUALLY_ACCESS_LOG = "1";
injectFake(false); // debugAccessLog = false explicitly
setCurrentUser("bob");
const { lines, restore } = spyConsoleLog();
try {
await sparqlQuery("sid-log", "SELECT * {}", undefined, "did:ng:o:env-q");
} finally {
restore();
}
expect(lines.length).toBe(1);
expect(lines[0]).toMatch(/^\[bob\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
expect(lines[0]).toMatch(/READ/);
expect(lines[0]).toContain(shortNuri("did:ng:o:env-q"));
});
it("env var NG_EVENTUALLY_ACCESS_LOG=true also enables the log", async () => {
if (!(globalThis as any)?.process?.env) return;
process.env.NG_EVENTUALLY_ACCESS_LOG = "true";
injectFake(false);
setCurrentUser("charlie");
const { lines, restore } = spyConsoleLog();
try {
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:env-w");
} finally {
restore();
}
expect(lines.length).toBe(1);
expect(lines[0]).toMatch(/^\[charlie\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
expect(lines[0]).toMatch(/WRITE/);
});
it("enabled() returns true when env var is set", () => {
if (!(globalThis as any)?.process?.env) return;
process.env.NG_EVENTUALLY_ACCESS_LOG = "1";
setAccessLog(false); // config toggle is off
expect(enabled()).toBe(true);
});
});
describe("access-log: identity follows setCurrentUser", () => {
it("prefix changes after setCurrentUser", async () => {
injectFake(true);
setCurrentUser("first-user");
const { lines, restore } = spyConsoleLog();
try {
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:id1", "step1");
setCurrentUser("second-user");
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:id2", "step2");
} finally {
restore();
}
// 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 () => {
injectFake(true);
setCurrentUser(null); // no active identity
const { lines, restore } = spyConsoleLog();
try {
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:anon");
} finally {
restore();
}
expect(lines.length).toBe(1);
expect(lines[0]).toMatch(/^\[\(none\)\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
});
});
+75
View File
@@ -0,0 +1,75 @@
import { test, expect } from "bun:test";
import {
IdentityStore,
browserIdentityStore,
ACCOUNT_STORAGE_KEY,
type VirtualUserStorage,
} from "../src/shared-wallet/virtual-users";
// In-memory fake of the Storage subset — keeps this framework/DOM-agnostic.
function fakeStorage(): VirtualUserStorage & { map: Map<string, string> } {
const map = new Map<string, string>();
return {
map,
getItem: (k) => (map.has(k) ? (map.get(k) as string) : null),
setItem: (k, v) => void map.set(k, v),
removeItem: (k) => void map.delete(k),
};
}
test("IdentityStore: set persists a trimmed id, get reads it back", () => {
const s = fakeStorage();
const store = new IdentityStore(s);
expect(store.get()).toBeNull();
expect(store.set(" marie ")).toBe("marie"); // trimmed
expect(store.get()).toBe("marie");
expect(s.map.get(ACCOUNT_STORAGE_KEY)).toBe("marie");
});
test("IdentityStore: a blank id is ignored, keeps the previous value", () => {
const store = new IdentityStore(fakeStorage());
store.set("bob");
expect(store.set(" ")).toBe("bob");
expect(store.get()).toBe("bob");
});
test("IdentityStore: clear removes the id (no throw)", () => {
const store = new IdentityStore(fakeStorage());
store.set("bob");
store.clear();
expect(store.get()).toBeNull();
});
test("IdentityStore: null storage degrades to non-persisting (SSR-safe)", () => {
const store = new IdentityStore(null);
expect(store.get()).toBeNull();
expect(store.set("bob")).toBe("bob"); // returns the value, just doesn't persist
expect(store.get()).toBeNull();
store.clear(); // no throw
});
test("IdentityStore: swallows storage errors on read and write", () => {
const throwing: VirtualUserStorage = {
getItem: () => {
throw new Error("boom");
},
setItem: () => {
throw new Error("boom");
},
removeItem: () => {
throw new Error("boom");
},
};
const store = new IdentityStore(throwing);
expect(store.get()).toBeNull(); // read error swallowed → null
expect(() => store.set("bob")).not.toThrow();
expect(() => store.clear()).not.toThrow();
});
test("browserIdentityStore returns a working store (uses global localStorage if present)", () => {
const store = browserIdentityStore("ng-eventually.test.account");
expect(store).toBeInstanceOf(IdentityStore);
// Behaves regardless of environment: set returns the value.
expect(store.set("zoe")).toBe("zoe");
});
+330
View File
@@ -0,0 +1,330 @@
/**
* anti-fork.test.ts — behavioral tests for the reconnection fix in the
* polyfill-era shim (src/store-registry.ts), redesigned around the
* pointer → doc-shim indirection. Two groups, one file:
*
* (1) DETERMINISTIC RESOLUTION — a doc-shim whose account subject carries
* DUPLICATE scope-doc values (fork residue: several `shim:docPublic`) must
* resolve to the SAME canonical doc every time (lexicographically-smallest
* NURI), so the session that WROTE an entity and a fresh page that RESOLVES
* the doc never disagree. Robustness against PAST fork residue.
*
* (2) BARRIER-AUTHORITATIVE RECONNECT (the core fix) — a fresh page over a
* persistent wallet resolves the SAME account through the doc-shim's
* first-`State` BARRIER, with NO account-level retry. The account records
* live in a subscribable doc-shim (`did:ng:o:...`) reached via a write-once
* POINTER triple in the store-root; opening the doc-shim makes a cold read
* authoritative, so a genuinely-present account is found on the first read
* and never re-provisioned (no fork). This replaced the deleted
* `resolveAccountReliably` / `provisionRetry` account retry.
*/
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
import {
ensureAccount,
resolveAccount,
resetRegistryCache,
} from "../src/shared-wallet/account-registry";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import { configure } from "../src/index";
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
});
const SESSION: RegistrySession = { sessionId: "sid-af", privateStoreId: "PRIV-AF" };
const ROOT = "did:ng:PRIV-AF";
// ---------------------------------------------------------------------------
// Fake ng — in-memory quad store modelling the pointer → doc-shim indirection.
//
// - The POINTER (`<shim:root> <shim:shimDoc> <docShim>`) lives in the store-root
// graph (keyed by GRAPH <ROOT>).
// - AccountRecords live in the doc-shim (anchored default graph, keyed by the
// anchor arg = the doc-shim NURI).
// - doc_subscribe pushes an initial `State` so ensureRepoOpen resolves at once
// (the barrier).
// ---------------------------------------------------------------------------
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;
}
function makeSparqlUpdate(quads: Quad[]) {
return 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] ?? "urn:ng-eventually:shim:Account";
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g, s, p, o });
}
return undefined;
});
}
/** Build the account-SELECT bindings from the quads in ONE graph (the doc-shim),
* grouped per subject. A subject with DUPLICATE scope docs yields a cross-product
* of bindings — a corrupted shim. */
function accountBindings(quads: Quad[], anchor: string | undefined, onlySubject: string | null) {
const bySubject = new Map<string, { id: string; pub: string[]; prot: string[]; priv: string[] }>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) continue;
const rec = bySubject.get(q.s) ?? { id: "", pub: [], prot: [], priv: [] };
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
if (q.p === "urn:ng-eventually:shim:docPublic") rec.pub.push(q.o);
if (q.p === "urn:ng-eventually:shim:docProtected") rec.prot.push(q.o);
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.priv.push(q.o);
bySubject.set(q.s, rec);
}
const bindings: Array<Record<string, { value: string }>> = [];
for (const rec of bySubject.values()) {
if (!rec.id) continue;
const pubs = rec.pub.length ? rec.pub : [""];
const prots = rec.prot.length ? rec.prot : [""];
const privs = rec.priv.length ? rec.priv : [""];
for (const pub of pubs)
for (const prot of prots)
for (const priv of privs)
bindings.push({
id: { value: rec.id },
docPublic: { value: pub },
docProtected: { value: prot },
docPrivate: { value: priv },
});
}
return bindings;
}
/** Reactive fake ng modelling the pointer → doc-shim indirection. `doc_subscribe`
* pushes a first `State` so the doc-shim barrier resolves synchronously. */
function makeFakeNg() {
const quads: Quad[] = [];
let docCounter = 0;
// Count account SELECTs per anchor graph. The AUTHORITATIVE account read is anchored
// to the doc-shim (a did:ng:o: NURI). Counting per-anchor lets a test assert "exactly
// one doc-shim account read" (no account RETRY).
const accountReadsByAnchor = new Map<string, number>();
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
const sparql_update = makeSparqlUpdate(quads);
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
// Pointer SELECT (store-root).
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
.map((q) => ({ shimDoc: { value: q.o } }));
return { results: { bindings } };
}
// Account SELECT — anchored to the doc-shim (the authoritative read).
if (query.includes("<urn:ng-eventually:shim:id>")) {
accountReadsByAnchor.set(anchor ?? "", (accountReadsByAnchor.get(anchor ?? "") ?? 0) + 1);
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
return { results: { bindings: accountBindings(quads, anchor, subjM ? subjM[1]! : null) } };
}
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
.map((q) => ({ e: { value: q.o } }));
return { results: { bindings } };
});
// Push a `State` on subscribe (the sync barrier) so ensureRepoOpen resolves at once.
const doc_subscribe = mock(async (_repo: unknown, _sid: unknown, cb: Function) => {
if (typeof cb === "function") cb({ V0: { State: {} } });
return () => {};
});
return {
doc_create, sparql_update, sparql_query, doc_subscribe,
_quads: quads,
// Total account reads across all anchors.
getAccountQueryCount: () => [...accountReadsByAnchor.values()].reduce((a, b) => a + b, 0),
// Account reads anchored to a did:ng:o: doc-shim.
getDocShimAccountReads: () =>
[...accountReadsByAnchor.entries()]
.filter(([g]) => g.startsWith("did:ng:o:"))
.reduce((a, [, n]) => a + n, 0),
};
}
function inject(
fakeNg: unknown,
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number },
) {
configure({ ng: fakeNg as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u) => u.trim().toLowerCase(),
pointerGuard,
});
resetRegistryCache();
resetOpenedRepos();
}
// ---------------------------------------------------------------------------
// (1) Deterministic resolution over fork residue (in the doc-shim)
// ---------------------------------------------------------------------------
describe("deterministic resolution over a doc-shim corrupted by fork residue", () => {
beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); });
it("(1a) a subject with MULTIPLE docPublic values always resolves the SAME canonical (lexicographically-smallest)", async () => {
const fakeNg = makeFakeNg();
inject(fakeNg);
const docShim = "did:ng:o:shimdoc";
// Seed the pointer (store-root → doc-shim) and the corrupted record IN the doc-shim.
fakeNg._quads.push({ g: ROOT, s: "urn:ng-eventually:shim:root", p: "urn:ng-eventually:shim:shimDoc", o: docShim });
const subj = "urn:ng-eventually:shim:account:dupuser";
// The minimum must sit NEITHER first NOR last, or the test cannot tell a canonical
// pick from a positional one. It used to end on `pub-a`, so `rows[rows.length - 1]`
// — an order-dependent pick, precisely the fault this test exists to catch — passed
// it. Only `rows[0]` failed. Found by mutation, 2026-08-10.
const dupPublics = [
"did:ng:o:pub-m", "did:ng:o:pub-a", "did:ng:o:pub-z", "did:ng:o:pub-c", "did:ng:o:pub-z",
];
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:id", o: "dupuser" });
for (const p of dupPublics)
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docPublic", o: p });
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docProtected", o: "did:ng:o:prot-1" });
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docPrivate", o: "did:ng:o:priv-1" });
const r1 = await resolveAccount("dupuser");
resetRegistryCache();
const r2 = await resolveAccount("dupuser");
resetRegistryCache();
const viaShim = await resolveAccount("dupuser");
// Canonical = lexicographically smallest → "did:ng:o:pub-a".
expect(r1?.docPublic).toBe("did:ng:o:pub-a");
expect(r2?.docPublic).toBe(r1?.docPublic);
expect(viaShim?.docPublic).toBe(r1?.docPublic);
expect(viaShim?.docProtected).toBe("did:ng:o:prot-1");
expect(viaShim?.docPrivate).toBe("did:ng:o:priv-1");
});
});
// ---------------------------------------------------------------------------
// (2) Barrier-authoritative reconnect — the core fix (no account retry)
// ---------------------------------------------------------------------------
describe("reconnect resolves the SAME account through the doc-shim barrier (no fork, no account retry)", () => {
beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); });
it("(2a) NO-FORK: account already in the doc-shim → reused, 0 new scope docs", async () => {
const fakeNg = makeFakeNg();
inject(fakeNg);
// First login: provisions the account (1 doc-shim + 3 scope docs).
const first = await ensureAccount("LauraBarrier");
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
// Fresh page over the SAME persistent quads: reset all in-memory caches, keep the
// quads. Reconnect must find the SAME account via the doc-shim barrier, NO new docs.
resetRegistryCache();
resetOpenedRepos();
const second = await ensureAccount("LauraBarrier");
expect(second.docPublic).toBe(first.docPublic);
expect(second.docProtected).toBe(first.docProtected);
expect(second.docPrivate).toBe(first.docPrivate);
// Still 4 — no doc-shim re-created (pointer reused), no scope docs re-created.
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
});
it("(2b) barrier-authoritative: a fresh session finds the persisted account on the FIRST read — single doc-shim read, NO retry", async () => {
// Seed session 1; capture the account NURIs + the persistent quads.
const seed = makeFakeNg();
inject(seed);
const orig = await ensureAccount("BarrierUser");
expect(seed.doc_create).toHaveBeenCalledTimes(4);
// Fresh reactive session over the SAME persistent quads. The doc-shim pushes a
// `State` on subscribe → resolveShimDoc opens the barrier → the account read is
// authoritative on the FIRST attempt. Give a MULTI-attempt pointer guard to prove
// it is NOT used for the account.
const reconnect = makeFakeNg();
reconnect._quads.push(...seed._quads);
inject(reconnect, { attempts: 8, baseMs: 1, maxStepMs: 2 });
const resolved = await ensureAccount("BarrierUser");
expect(resolved.docPublic).toBe(orig.docPublic);
expect(resolved.docProtected).toBe(orig.docProtected);
expect(resolved.docPrivate).toBe(orig.docPrivate);
expect(reconnect.doc_create).toHaveBeenCalledTimes(0); // no new docs → no fork
// Barrier-authoritative: found on the FIRST doc-shim read.
expect(reconnect.getDocShimAccountReads()).toBe(1);
});
it("(2c) GENUINELY NEW: a cold doc-shim reads 0 → provisioned exactly once, no retry", async () => {
// Pointer + doc-shim exist but the doc-shim holds NO record for this account.
const fakeNg = makeFakeNg();
inject(fakeNg, { attempts: 5, baseMs: 1, maxStepMs: 2 });
const docShim = "did:ng:o:preexisting-shim";
fakeNg._quads.push({ g: ROOT, s: "urn:ng-eventually:shim:root", p: "urn:ng-eventually:shim:shimDoc", o: docShim });
const rec = await ensureAccount("BrandNewUser");
// Provisioned exactly ONE set of 3 scope docs (the doc-shim already existed).
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
expect(rec.docProtected).not.toBe(rec.docPublic);
// Barrier-authoritative: exactly ONE doc-shim account read (the 0 is definitive).
expect(fakeNg.getDocShimAccountReads()).toBe(1);
});
it("(2d) default budget (unset pointer guard): genuinely-new account → single doc-shim account read", async () => {
const fakeNg = makeFakeNg();
inject(fakeNg); // pointerGuard unset → attempts:1 (synchronous default)
const rec = await ensureAccount("SyncUser");
// 1 doc-shim + 3 scope docs.
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
expect(fakeNg.getDocShimAccountReads()).toBe(1);
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
});
it("(2e) idempotence within a session: ensureAccount twice never creates 2 sets", async () => {
const fakeNg = makeFakeNg();
inject(fakeNg);
const a = await ensureAccount("SameUser");
const b = await ensureAccount("SameUser");
expect(b).toEqual(a);
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs
});
});
+195
View File
@@ -0,0 +1,195 @@
/**
* The app-facing surface, pinned where it changed shape on 2026-08-10.
*
* Every test here exists because behaviour shipped without one and an adversarial pass
* had to find it: an application that cannot learn its own identity, a share that invents
* its recipient, a creation that reports success on a half-written document. They are
* about what a CALLER sees, not about the emulation's internals.
*/
import { test, expect, mock, afterEach } from "bun:test";
import { configure, ensureIdentity, storeRegistry } from "../src/index";
import {
resetCaps,
resetConfig,
resetStoreRegistry,
setCurrentUser,
} from "../src/shared-wallet/bootstrap";
import { createEntityDoc as registryCreateEntityDoc, resetRegistryCache } from "../src/shared-wallet/account-registry";
import { share } from "../src/surface/inbox";
const SHIM = "urn:ng-eventually:shim";
const SESSION = { sessionId: "sid-app", privateStoreId: "PRIV-APP" };
interface Quad { g: string; s: string; p: string; o: string }
/** A stateful fake `ng`, with a switch that makes a chosen register write fail. */
function inject(failWriteMatching?: RegExp) {
const quads: Quad[] = [];
let created = 0;
const doc_create = mock(async () => `did:ng:o:doc${++created}`);
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
if (failWriteMatching && failWriteMatching.test(query)) throw new Error("broker refused");
if (!anchor) return undefined;
const gm = query.match(/GRAPH\s+<([^>]+)>\s*\{([\s\S]*)\}/);
const body = gm ? gm[2]! : query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const subj = 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) {
quads.push({ g: anchor, s: subj, p: m[1] ?? `${SHIM}:Account`, o: m[2] ?? m[3] ?? "" });
}
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
const byPred = (pred: string, v: string) => ({
results: { bindings: quads.filter((q) => q.g === anchor && q.p === pred).map((q) => ({ [v]: { value: q.o } })) },
});
if (query.includes(`${SHIM}:shimDoc`)) return byPred(`${SHIM}:shimDoc`, "shimDoc");
if (query.includes(`${SHIM}:readCap`)) return byPred(`${SHIM}:readCap`, "c");
if (query.includes(`${SHIM}:contains`)) return byPred(`${SHIM}:contains`, "e");
if (query.includes(`${SHIM}:id`)) {
// Filter by SUBJECT when the query names one — the account read asks about ONE
// account. A fake that ignores it hands back somebody else's record, and then
// "bob does not see alice's document" and "share refuses an unknown name" both
// fail for a reason that has nothing to do with the code. (Made that mistake here
// first; it is the same one this review found in the other fakes.)
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 ?? "" },
})),
},
};
}
return { results: { bindings: [] } };
});
configure({
ng: { doc_create, sparql_update, sparql_query } as never,
useShape: (() => {}) as never,
getSession: async () => SESSION,
});
resetRegistryCache();
resetCaps();
setCurrentUser(null);
return { quads };
}
afterEach(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
setCurrentUser(null);
});
// ── identity ───────────────────────────────────────────────────────────────
// An application has to know which user it is — to display it, at least. Upstream it
// does: it passes `user_id` to `session_start`, having got it from the wallet it opened.
// Here the gate chooses, so the gate returns. Without this the example application read
// the gate's own private storage key, which is a boundary no consumer should see.
test("ensureIdentity returns the identity it settled", async () => {
inject();
setCurrentUser("alice");
expect(await ensureIdentity()).toBe("alice");
});
// The other half of the same decision: no placement call TAKES an identity, because a
// session belongs to one user and the target's `doc_create` carries none. Calling one
// before signing in is a caller error worth naming, not an empty result.
test("a placement call before signing in names the mistake", async () => {
inject();
setCurrentUser(null);
await expect(storeRegistry.createEntityDoc("protected")).rejects.toThrow(/ensureIdentity/i);
await expect(storeRegistry.listMyEntityDocs("protected")).rejects.toThrow(/no identity/i);
});
test("placement acts as the connected user, with nothing passed", async () => {
inject();
setCurrentUser("alice");
const doc = await storeRegistry.createEntityDoc("protected");
expect(await storeRegistry.listMyEntityDocs("protected")).toContain(doc);
setCurrentUser("bob");
expect(await storeRegistry.listMyEntityDocs("protected")).not.toContain(doc);
});
// ── sharing names someone who exists ───────────────────────────────────────
test("share refuses a recipient nobody has signed in as, instead of creating them", async () => {
inject();
setCurrentUser("alice");
const doc = await storeRegistry.createEntityDoc("protected");
// "bpb" is a typo for "bob". It used to mint that name's three stores and an inbox,
// and the cap landed where nobody will ever look — with no error at all.
await expect(share(doc, "bpb")).rejects.toThrow(/no such recipient/i);
});
// …and the refusal must rest on ABSENCE, never on ignorance: `resolveAccount` answers
// `null` for a failed read as well as for a missing one, so a refusal built on it would
// tell a user "nobody has signed in as bob" because a query timed out.
test("a failed lookup surfaces as a failure, not as 'no such recipient'", async () => {
const { quads } = inject();
setCurrentUser("alice");
const doc = await storeRegistry.createEntityDoc("protected");
setCurrentUser("bob");
await registryCreateEntityDoc("bob", "private"); // bob genuinely exists
setCurrentUser("alice");
resetRegistryCache(); // force a read rather than the cache
const { ng } = (await import("../src/shared-wallet/bootstrap")).getConfig();
const realQuery = ng.sparql_query;
(ng as { sparql_query: unknown }).sparql_query = async () => {
throw new Error("broker unreachable");
};
await expect(share(doc, "bob")).rejects.toThrow(/broker unreachable/i);
(ng as { sparql_query: unknown }).sparql_query = realQuery;
void quads;
});
// ── a creation that half-worked is a failure, and says which half ──────────
test("createEntityDoc reports a half-written document instead of returning its reference", async () => {
inject(/shim:contains/); // the LISTING write fails
setCurrentUser("alice");
await expect(storeRegistry.createEntityDoc("protected")).rejects.toThrow(/not listed in its store/i);
});
// Both writes are attempted before the failure is raised — the cap must land even when
// the listing did not, because either is worth having without the other. Throwing on the
// first one (2026-08-07) skipped the second and orphaned the document entirely.
test("a failed listing does not cost the document its key", async () => {
const { quads } = inject(/shim:contains/);
setCurrentUser("alice");
await storeRegistry.createEntityDoc("protected").catch(() => {});
expect(quads.some((q) => q.p === `${SHIM}:readCap`)).toBe(true);
});
test("a failed key write is reported too, and names that half", async () => {
inject(/shim:readCap/);
setCurrentUser("alice");
await expect(storeRegistry.createEntityDoc("protected")).rejects.toThrow(/key is not recorded/i);
});
+183
View File
@@ -0,0 +1,183 @@
/**
* caps.test.ts — the cap surface as KEY POSSESSION.
*
* What these prove is a SHAPE, not a protection (the library is deliberately
* insecure until P1b): the only question the registry can answer is "do I hold
* this document's cap?", there is no principal to look up in a list, and no
* function turns a bare reference into a cap.
*/
import { test, expect } from "bun:test";
import { CapRegistry, mintCap } from "../src/emulated-verifier/caps";
import { hasReadCap, targetOf } from "../src/model/nuri";
import type { ReadCap } from "../src/model/types";
/** A registry whose holder the test drives. */
function registry(initial: string | null = "alice") {
let holder = initial;
const caps = new CapRegistry(() => holder);
return { caps, become: (id: string | null) => (holder = id) };
}
test("a cap NAMES and READS; the bare reference only names", () => {
const { caps } = registry();
const doc = "did:ng:o:doc1:v:overlay";
// Before anything: naming a document tells you nothing about reading it.
expect(caps.capFor(doc)).toBeUndefined();
const cap = caps.mint(doc);
expect(hasReadCap(cap)).toBe(true); // carries `:r:`
expect(hasReadCap(doc)).toBe(false);
expect(targetOf(cap)).toBe(doc); // same object, key inside
expect(caps.capFor(doc)).toBe(cap);
// Looking the cap up by the cap-bearing form resolves the same document.
expect(caps.capFor(cap)).toBe(cap);
});
test("no cap is derivable from a bare reference — you look it up or you were given it", () => {
const { caps } = registry();
caps.mint("did:ng:o:mine");
// A document that never entered the held caps stays unreadable, however well-formed
// its reference is. There is no `grantRead`, and no principal to name.
expect(caps.capFor("did:ng:o:someone-else")).toBeUndefined();
});
// Passing the naming form where the reading form is meant is now a COMPILE error
// (`ReadCap` is a template literal type). The runtime refusal still has to hold,
// because a JavaScript consumer — or a cap read back from storage, a URL or JSON
// and cast rather than narrowed — never meets the compiler. The `as` below is
// exactly that consumer: it is how the mistake reaches the library at all.
// Unchecked, it would file a bare reference as its own cap and make the document
// read — the exact inversion this batch removes.
test("learn REFUSES a bare reference, even when the compiler was bypassed", () => {
const { caps } = registry();
const bare = "did:ng:o:someone-elses-doc" as ReadCap; // a JS consumer / an unchecked cast
expect(() => caps.learn(bare)).toThrow(/naming is not reading|bare reference/i);
expect(caps.capFor("did:ng:o:someone-elses-doc")).toBeUndefined(); // nothing was filed
expect(caps.isEnforcing()).toBe(false); // and nothing was issued
});
test("holding one document's cap grants nothing on another (no inheritance)", () => {
const { caps } = registry();
caps.mint("did:ng:o:doc1");
expect(caps.capFor("did:ng:o:doc1")).toBeDefined();
expect(caps.capFor("did:ng:o:doc2")).toBeUndefined(); // separate repo, separate cap
});
test("one set of held caps PER holder: switching identity switches heldByHolder, it does not wipe", () => {
const { caps, become } = registry("alice");
const doc = "did:ng:o:alice-doc";
const cap = caps.mint(doc);
become("bob");
expect(caps.capFor(doc)).toBeUndefined(); // bob holds nothing of alice's
become("alice");
expect(caps.capFor(doc)).toBe(cap); // …and alice did not lose hers
});
test("a cap received (learn) reads, exactly like one minted", () => {
const alice = registry("alice");
const doc = "did:ng:o:shared";
const cap = alice.caps.mint(doc);
const bob = registry("bob");
expect(bob.caps.capFor(doc)).toBeUndefined();
bob.caps.learn(cap); // delivered to bob's inbox, absorbed
expect(bob.caps.capFor(doc)).toBe(cap);
});
// A public store SERVES its documents' caps (`emulated-verifier/public-store.ts`).
// This registry is one level below that: it records WHERE a document sits, and it
// files a served cap apart from one that was minted or deposited — because the two
// grant different things.
test("markInPublicStore records where a document sits, and mints nothing", () => {
const { caps, become } = registry("alice");
const doc = "did:ng:o:public-doc";
caps.markInPublicStore(doc);
expect(caps.isInPublicStore(doc)).toBe(true);
expect(caps.isInPublicStore("did:ng:o:other")).toBe(false);
// Marking is not holding: the fact is about the document, the cap is about a holder.
expect(caps.capFor(doc)).toBeUndefined();
become("bob");
expect(caps.capFor(doc)).toBeUndefined();
});
test("a cap SERVED by a public store is held like any other — possession is the read criterion", () => {
const { caps, become } = registry("alice");
const doc = "did:ng:o:public-doc";
const served = mintCap(doc);
become("bob");
caps.learnFromPublicStore(served);
expect(caps.capFor(doc)).toBe(served);
// No read-only mark, and that absence is the point. It existed until 2026-08-07 and
// fed the write guard, which was the wrong predicate in both directions — writing is
// OWNERSHIP, and how a read key arrived says nothing about it (see `reach.ts`).
// Carol, in the same registry, holds nothing until she asks in her turn: what a public
// store serves is per-asker, not once-for-everyone.
become("carol");
expect(caps.capFor(doc)).toBeUndefined();
});
test("open(): a public document is marked as sitting in a public store, a private one is not", () => {
const { caps } = registry();
const pub = caps.open("did:ng:o:pub", "public");
const prot = caps.open("did:ng:o:prot", "protected");
const priv = caps.open("did:ng:o:priv", "private");
expect(caps.isInPublicStore("did:ng:o:pub")).toBe(true);
expect(caps.isInPublicStore("did:ng:o:prot")).toBe(false);
expect(caps.isInPublicStore("did:ng:o:priv")).toBe(false);
// All three are readable BY THEIR OWNER — a creator is never locked out.
for (const [doc, cap] of [["did:ng:o:pub", pub], ["did:ng:o:prot", prot], ["did:ng:o:priv", priv]] as const) {
expect(caps.capFor(doc)).toBe(cap);
}
});
test("open() is idempotent — re-listing my own documents refiles the same caps", () => {
const { caps } = registry();
const first = caps.open("did:ng:o:doc", "protected");
let fired = 0;
caps.onChange(() => (fired += 1));
expect(caps.open("did:ng:o:doc", "protected")).toBe(first);
expect(fired).toBe(0); // nothing changed → no spurious re-read
});
test("isEnforcing is false until the first cap exists, then holds for every holder", () => {
const { caps, become } = registry("alice");
expect(caps.isEnforcing()).toBe(false);
caps.mint("did:ng:o:doc1");
expect(caps.isEnforcing()).toBe(true);
// …including for a holder whose own holds nothing: that IS the isolation.
become("bob");
expect(caps.isEnforcing()).toBe(true);
expect(caps.capFor("did:ng:o:doc1")).toBeUndefined();
});
test("a cap arriving fires the change signal — an asynchronous delivery must re-trigger reads", () => {
const { caps } = registry();
let fired = 0;
const unsub = caps.onChange(() => (fired += 1));
caps.learn(caps.mint("did:ng:o:doc1")); // mint fires once; the learn is a no-op
expect(fired).toBe(1);
unsub();
caps.mint("did:ng:o:doc2");
expect(fired).toBe(1); // unsubscribed
});
test("write is restricted to write-cap holders (decorative until P1b)", () => {
const { caps } = registry();
expect(caps.hasWritePolicy()).toBe(false);
caps.grantWrite("did:ng:o:doc", "alice");
expect(caps.hasWritePolicy()).toBe(true);
expect(caps.governsWrite("did:ng:o:doc")).toBe(true);
expect(caps.governsWrite("did:ng:o:unknown")).toBe(false); // not declared → not enforced
expect(caps.canWrite("did:ng:o:doc", "alice")).toBe(true);
expect(caps.canWrite("did:ng:o:doc", "bob")).toBe(false);
expect(caps.canWrite("did:ng:o:doc", null)).toBe(false);
});
@@ -0,0 +1,203 @@
/**
* cold-start-anchor.test.ts — the shim ANCHOR (private-store-root) must be OPENED
* before the registry reads/writes it, or a cold anchor throws `RepoNotFound`.
*
* ── The gap this pins ──────────────────────────────────────────────────────
* The shim lives in the private-store-root graph (`did:ng:${privateStoreId}`, the
* "anchor"). Unlike a per-entity doc — whose anchored read on an unopened repo
* SILENTLY returns 0 rows — the private/store target resolves through the verifier's
* `resolve_target_for_sparql`, which HARD-errors `RepoNotFound` when the repo is not
* in `self.repos` (verified in nextgraph-rs `request_processor.rs`). On a wallet whose
* anchor repo is not yet loaded, both the shim READ (`resolveAccount`/`loadShim`) and
* the provision WRITE (`ensureAccount`) throw — so the account never provisions.
*
* 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 `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.
*
* RED without the heal (ensureAccount would throw on the cold anchor); GREEN with it.
*/
import { describe, it, expect, mock, afterAll, beforeEach } from "bun:test";
import { ensureAccount, resolveWriteGraph, resetRegistryCache } from "../src/shared-wallet/account-registry";
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
const SESSION = { sessionId: "sid-cold", privateStoreId: "PRIV-COLD" };
const ANCHOR = `did:ng:${SESSION.privateStoreId}`;
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
resetCaps();
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();
setCurrentUser(null);
});
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 fake `ng` whose ANCHOR repo behaves like the real private-store target:
* `sparql_query`/`sparql_update` anchored to it THROW `RepoNotFound` until the
* anchor has been `doc_subscribe`-d (i.e. opened into `self.repos`). Any OTHER
* anchor (per-entity docs) behaves normally. `doc_subscribe` fires the first
* `State` so `ensureRepoOpen` crosses the barrier.
*/
function makeColdAnchorNg() {
const quads: Quad[] = [];
let docCounter = 0;
const opened = new Set<string>();
let anchorSubscribes = 0;
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
if (nuri === ANCHOR) anchorSubscribes += 1;
opened.add(nuri);
setTimeout(() => cb({ V0: { State: {} } }), 0);
return () => {};
});
const sparql_update = mock(async (_sid: string, query: string, anchor?: string) => {
if (anchor === ANCHOR && !opened.has(ANCHOR)) throw new Error("RepoNotFound");
// TWO shapes: the POINTER write uses `GRAPH <root>` (keyed by IRI); the account
// record write into the doc-shim has NO explicit GRAPH (keyed by the anchor arg).
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) {
const p = m[1] ?? "urn:ng-eventually: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 (_sid: string, query: string, _base: unknown, anchor?: string) => {
if (anchor === ANCHOR && !opened.has(ANCHOR)) throw new Error("RepoNotFound");
// Pointer SELECT (store-root -> doc-shim).
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
.map((q) => ({ shimDoc: { value: q.o } }));
return { results: { bindings } };
}
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 === "urn:ng-eventually:shim:id") rec.id = q.o;
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
if (q.p === "urn:ng-eventually: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 } };
});
return {
doc_create, doc_subscribe, sparql_update, sparql_query,
_quads: quads,
anchorSubscribeCount: () => anchorSubscribes,
};
}
function inject(ng: ReturnType<typeof makeColdAnchorNg>) {
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
});
resetRegistryCache();
resetOpenedRepos();
}
describe("cold-start anchor heal", () => {
it("ensureAccount provisions over a COLD anchor (RepoNotFound-until-opened) without throwing", async () => {
const ng = makeColdAnchorNg();
inject(ng);
// Without the open-before-shim heal, the read AND the provision write would both
// throw RepoNotFound on the cold anchor and the account would never persist.
const rec = await ensureAccount("@cold-alice");
expect(rec.docPublic).toBeTruthy();
expect(rec.docProtected).toBeTruthy();
expect(rec.docPrivate).toBeTruthy();
// The anchor repo was actually opened (doc_subscribe-d) before the shim op.
expect(ng.anchorSubscribeCount()).toBeGreaterThan(0);
});
it("the provisioned account re-resolves from the shim (real persistence, no RepoNotFound)", async () => {
const ng = makeColdAnchorNg();
inject(ng);
const first = await ensureAccount("@cold-bob");
// Fresh cache → a real anchored re-read of the shim (anchor already opened → OK).
resetRegistryCache();
const again = await ensureAccount("@cold-bob");
expect(again.docPublic).toBe(first.docPublic);
expect(again.docProtected).toBe(first.docProtected);
expect(again.docPrivate).toBe(first.docPrivate);
});
it("resolveWriteGraph (scope resolver) works over a cold anchor", async () => {
const ng = makeColdAnchorNg();
inject(ng);
const g = await resolveWriteGraph("@cold-carol", "protected");
expect(g).toBeTruthy();
});
});
@@ -0,0 +1,686 @@
/**
* 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 each of them holds. There is
* no authorization list anywhere, and nobody was named to the registry.
*/
import { getCaps } from "../src/shared-wallet/bootstrap";
import { test, expect, mock, afterAll } from "bun:test";
import {
createEntityDoc,
resetRegistryCache,
resolveWriteGraph,
userInbox,
} from "../src/shared-wallet/account-registry";
import { documentInboxAddress, openDocumentInbox } from "../src/emulated-verifier/branch-registers";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { connectedUser } from "../src/emulated-verifier/connect";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { share } from "../src/surface/inbox";
import { post, postToDocument, read as readInbox } from "../src/surface/inbox";
import { readUnion } from "../src/surface/read-model";
import { sparqlUpdate } from "../src/surface/docs";
import type { Nuri } from "../src/model/types";
/**
* Do I hold this document's cap? Possession, asked of the internal registry — the
* polyfill door stopped publishing this (see `polyfill.ts`), because as an app-facing
* question it reads like "may I read this?" and a public store's document answers
* `false` until something has asked for its cap.
*/
function hasCap(nuri: Nuri): boolean {
return getCaps().capFor(nuri) !== undefined;
}
afterAll(() => {
resetConfig();
resetStoreRegistry();
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;
// `DELETE WHERE { <s> <p> ?var }` — the form the lib uses to REPLACE a value
// (see docs/decisions/sparql-delete-for-orm-objects.md). Without this arm the
// fake would treat the delete as an insert and the replacement would silently
// become an accumulation — the exact bug a replacement exists to prevent.
const del = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+<([^>]+)>\s+\?/);
if (del) {
const [s0, p0] = [del[1]!, del[2]!];
for (let i = quads.length - 1; i >= 0; i--) {
const q = quads[i]!;
if (q.g === anchor && q.s === s0 && q.p === p0) quads.splice(i, 1);
}
return undefined;
}
// `INSERT DATA { GRAPH <g> { … } }` — the shape the store-ROOT pointer write uses.
// Without this arm the first `<…>` in the body is the GRAPH NAME, so the pointer was
// stored with the graph as its subject and its predicate as its object. The pointer
// SELECT then found nothing, every cold `resolveShimDoc` forked a NEW shim, and the
// suite never noticed because the module cache carried the previous answer. Added
// 2026-08-10; the other fakes had it already.
const gm = query.match(/GRAPH\s+<([^>]+)>\s*\{([\s\S]*)\}/);
const body = gm
? gm[2]!
: 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 } })) } };
}
// Header-branch `inboxAddress` SELECT (where to deposit for this document).
if (query.includes(`<${SHIM}:inboxAddress>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxAddress`).map((q) => ({ a: { 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 } })) } };
}
// Shim `isInbox` SELECT — the emulated "the broker knows this is an inbox". Absent at
// first, so `isKnownInbox` answered from its in-memory set alone: the durable half was
// never exercised, which is the very fault this pass was fixing elsewhere.
if (query.includes(`${SHIM}:isInbox`)) {
return { results: { bindings: quads
.filter((q) => q.g === anchor && q.p === `${SHIM}:isInbox`)
.map((q) => ({ i: { value: q.o } })) } };
}
// Shim `docInbox:<scope>` SELECT — WHICH inbox a virtual user owns. Absent until
// 2026-08-10, so `userInbox` never found a persisted address and answered from the
// module cache alone: two actors in one JS realm agreed, two SESSIONS would not have.
// The suite's "a third party resolves another user's inbox" was proving the cache.
if (query.includes(`${SHIM}:docInbox`)) {
const pm = query.match(/<(urn:ng-eventually:shim:docInbox:[a-z]+)>/);
const pred = pm ? pm[1]! : "";
const sm = query.match(/<([^>]+)>\s+<urn:ng-eventually:shim:docInbox/);
const subj = sm ? sm[1]! : null;
return { results: { bindings: quads
.filter((q) => q.g === anchor && q.p === pred && (subj === null || q.s === subj))
.map((q) => ({ d: { value: q.o } })) } };
}
// Header-branch `exposedReadCap` SELECT — what a PUBLIC store serves to anyone.
if (query.includes(`<${SHIM}:exposedReadCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:exposedReadCap`).map((q) => ({ c: { value: q.o } })) } };
}
if (query.includes(`<${SHIM}:contains>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`).map((q) => ({ e: { value: q.o } })) } };
}
// 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.
*
* What crosses to the other actors is **the bare reference of the public document and
* nothing else** — no cap, no link with a key in it. That is the whole discipline of
* this file: an application circulates references, and if a test had to hand a key
* across an identity boundary through a JS variable, the feature it claims to prove
* would have no path in any real application.
*/
async function aliceSetsUpHerDocuments() {
setCurrentUser("alice");
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);
return { protDoc, pubDoc };
}
/** 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 } = await aliceSetsUpHerDocuments();
setCurrentUser("bob");
// Bob holds the BARE reference and nothing else. The document sits in a public
// store, so the store serves him its cap — he never received a key from anyone.
// He reads the public document and finds the reference.
const refs = await readValues([pubDoc], REFERS_TO);
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(hasCap(ref)).toBe(false);
expect(await readValues([ref], SECRET)).toEqual([]);
});
test("Charlie: same public document, same reference — and he reads through it", async () => {
inject();
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
const CHARLIE_INBOX = await userInbox("charlie", "protected");
// Alice decides Charlie may read that ONE document, and delivers its cap to his
// inbox. She names no principal to the registry; she addresses an inbox.
setCurrentUser("alice");
await share(protDoc, "charlie");
setCurrentUser("charlie");
await readInbox(CHARLIE_INBOX); // processing the inbox files the cap
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
expect(ref).toBe(protDoc);
expect(hasCap(ref)).toBe(true);
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
});
test("the ONLY difference between Bob and Charlie is each of them holds", async () => {
inject();
const { protDoc } = await aliceSetsUpHerDocuments();
const CHARLIE_INBOX = await userInbox("charlie", "protected");
setCurrentUser("alice");
await share(protDoc, "charlie");
setCurrentUser("bob");
const bobSees = await readValues([protDoc], SECRET);
setCurrentUser("charlie");
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 { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
const BOB_INBOX = await userInbox("bob", "protected");
setCurrentUser("bob");
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 share(protDoc, "bob");
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(hasCap(ref)).toBe(true);
expect(latest).toEqual(["the-protected-content"]);
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
unsub();
});
// The property this whole batch exists for, stated on its own: WHERE a document sits
// decides whether a bare reference is enough. Upstream a public store's repos are
// served on the outer overlay and their ReadCap is downloaded from it
// (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`) — so the same value transmitted
// (a bare reference) yields a different outcome depending on the store, and never
// because a key travelled.
test("a bare reference is enough for a PUBLIC document, and not for a protected one", async () => {
inject();
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
setCurrentUser("bob");
// Bob has been given nothing but the two NURIs.
expect((await readValues([pubDoc], REFERS_TO)).length).toBe(1);
expect(await readValues([protDoc], SECRET)).toEqual([]);
// And what he obtained for the public one is a READ grant, not a write right: a
// public store serves its read cap, no store hands out the write cap.
await expect(write(pubDoc, SECRET, "bob-was-here")).rejects.toThrow(/public store/i);
});
// THE POINT OF THE LINK: a cap survives because it was APPLIED, not because the
// 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 } = await aliceSetsUpHerDocuments();
const bobInbox = await userInbox("bob", "protected");
setCurrentUser("alice");
await share(protDoc, "bob");
// 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");
// Checked SYNCHRONOUSLY, before yielding: `setCurrentUser` fires the connection work
// itself, and that work is precisely what restores the cap. An awaited check here
// would be asserting who won a race, not what the library does.
expect(hasCap(protDoc)).toBe(false); // bob holds nothing yet
// Connecting restores it — from the User branch, since the inbox has nothing left.
await connectedUser();
expect(hasCap(protDoc)).toBe(true);
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);
});
// REGRESSION (second adversarial pass). `inbox.post` is a published door that skips both
// guards by design — the deposit is the one write that legitimately crosses. It accepted
// ANY NURI, so it wrote into a document its caller could not even read. Upstream the
// confusion cannot arise: a deposit carries an inbox key, not a document reference.
test("a deposit is addressed to an inbox, never to a document", async () => {
inject();
setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected");
await write(protDoc, SECRET, "alice's own");
setCurrentUser("bob");
await expect(post(protDoc, { payload: { x: 1 }, ts: 1 })).rejects.toThrow(/not an inbox/i);
setCurrentUser("alice");
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // untouched
});
// REGRESSION (second adversarial pass). The write guard reads ownership from the store
// index — and the holder's own store document was marked "created by me", so it was
// writable through the PUBLISHED `docs.sparqlUpdate`. One insert into it and you were
// the owner of anything you cared to name.
test("a holder cannot write into their own store index and forge ownership", async () => {
inject();
setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected");
await write(protDoc, SECRET, "alice's own");
setCurrentUser("bob");
await createEntityDoc("bob", "protected"); // bob has his own stores
const bobStore = await resolveWriteGraph("bob", "protected");
await expect(
sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${SHIM}:index> <${SHIM}:contains> "${protDoc}" }`, bobStore, "forge"),
).rejects.toThrow(/WRITE cap/i);
// …and he is still refused the write itself — here by rule 1 (he cannot even reach
// alice's protected document), which fires before the ownership guard. Both say no.
await expect(write(protDoc, SECRET, "bob was here")).rejects.toThrow(/refused/i);
});
// WRITING IS OWNERSHIP — the two regressions that replaced the old write guard.
//
// It used to ask "was this cap served to me by a public store?", which was wrong in both
// directions at once. Both are pinned here, because one predicate pushed two ways is
// exactly how a fix trades one bug for a worse one.
// Direction 1 — TOO STRICT. The owner opening her own public note from its reference,
// before her store has been listed (a deep link, a fresh session), got the "served by a
// public store" mark on her own document and was refused a write to it.
test("the owner writes to her own public note, even after opening it from its reference", async () => {
inject();
setCurrentUser("alice");
const pubDoc = await createEntityDoc("alice", "public");
await write(pubDoc, SECRET, "v1");
// She arrives at it the way a deep link would: by reference, with nothing held.
resetCaps();
setCurrentUser("bob");
await createEntityDoc("bob", "private"); // re-arms the emulation
setCurrentUser("alice");
await readValues([pubDoc], SECRET); // this is what files the served cap
await write(pubDoc, SECRET, "v2"); // must not throw
expect((await readValues([pubDoc], SECRET)).includes("v2")).toBe(true);
});
// Direction 2 — TOO LAX. A cap received in an inbox let its recipient WRITE into the
// owner's document. Upstream impossible: writing is repo membership, and a Link is
// "external repos only". An application could have shipped collaborative editing on it.
test("a cap received in an inbox reads, and does NOT write", async () => {
inject();
setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected");
await write(protDoc, SECRET, "alice's own");
const BOB_INBOX = await userInbox("bob", "protected");
await share(protDoc, "bob");
setCurrentUser("bob");
await readInbox(BOB_INBOX);
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // he reads it
await expect(write(protDoc, SECRET, "bob was here")).rejects.toThrow(/WRITE cap/i);
setCurrentUser("alice");
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // untouched
});
// 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 aliceInbox = await openDocumentInbox(doc);
expect(aliceInbox).not.toBe(await userInbox("alice", "protected"));
// Bob RESOLVES the address himself, from the BARE reference — the only thing he is
// handed, and the only thing an application circulates. The document is in a public
// store, so the store serves him its read cap; the address is not passed to him,
// because if it had to be there would be no way for an app to get it.
//
// The registry cache is dropped first: Bob is another session, and an address he can
// only find because Alice's session warmed a module map is an address no second browser
// page would find.
resetRegistryCache();
setCurrentUser("bob");
const bobTarget = await documentInboxAddress(doc);
expect(bobTarget).toBe(aliceInbox); // …and it is the SAME inbox alice reads
await post(bobTarget!, { payload: { joining: true }, ts: 1 });
// …and he cannot read it back: depositing grants nothing.
await expect(readInbox(bobTarget!)).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(aliceInbox);
expect(deposits.map((d) => d.payload)).toEqual([{ joining: true }]);
});
test("opening an inbox on someone else's document is refused, not silently forked", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const aliceInbox = await openDocumentInbox(doc);
// Bob can READ the document (it is in a public store) — and reading is not ownership.
resetRegistryCache(); // another session, not a warmed cache
setCurrentUser("bob");
await expect(openDocumentInbox(doc)).rejects.toThrow(/already has an inbox|you may only open an inbox/i);
// The address he resolves is still alice's, so his deposits reach her.
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
});
test("a fresh document has NO inbox — one belongs to one document, and only its owner opens it", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
// Not "the owner's inbox by default": upstream an inbox belongs to exactly ONE repo
// (the verifier routes by `inboxes: PubKey → RepoId`), so pointing several documents
// at one inbox is a relation the model cannot express.
resetRegistryCache(); // another session, not a warmed cache
setCurrentUser("bob");
expect(await documentInboxAddress(doc)).toBeUndefined();
// …and depositing THROWS rather than vanishing — a lost deposit is the bug this
// whole path exists to close.
await expect(postToDocument(doc, { payload: { x: 1 } })).rejects.toThrow(/has no inbox/i);
});
test("opening an inbox publishes ONE address, and re-opening does not accumulate", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const dedicated = await openDocumentInbox(doc);
expect(await openDocumentInbox(doc)).toBe(dedicated); // idempotent
resetRegistryCache(); // another session, not a warmed cache
setCurrentUser("bob");
expect(await documentInboxAddress(doc)).toBe(dedicated);
// The deposit reaches the owner, addressed by the document alone.
await postToDocument(doc, { payload: { signingUp: true } });
setCurrentUser("alice");
expect((await readInbox(dedicated)).map((d) => d.payload)).toEqual([{ signingUp: true }]);
});
test("the inbox address is machinery: it never surfaces as the document's data", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
await write(doc, SECRET, "s1");
await openDocumentInbox(doc);
// The consumer read returns the entity's properties and nothing of the compartment
// that carries the address — the Header branch is beside the content, not in it.
const subjects = await readUnion([doc]);
const props = subjects[0]?.props ?? {};
expect(Object.keys(props)).toEqual([SECRET]);
});
// CONNECTING APPLIES WHAT WAS DEPOSITED — and the honest scope of that claim.
//
// This was called "connecting drains BOTH levels" and asserted nothing about the second.
// An adversarial review replayed it against a `connectedUser` that drained ONLY the
// user's own two inboxes: all three assertions still passed. The reason is not a weak
// test, it is that the second level currently has **no producer**: the one call that
// deposits a cap is `inbox.share(doc, toUser)`, which resolves `userInbox(toUser,
// "protected")` (`surface/inbox.ts`) — a USER's inbox, never a document's. Nothing
// published can address a cap to a document's inbox, so draining one applies nothing and
// there is nothing to observe.
//
// That the drain covers document inboxes is therefore an ANTICIPATION, and a legitimate
// one: upstream `AddInboxCap` is generic over repos (no `is_store` check,
// `engine/verifier/src/verifier.rs:1916-1930`) and `InboxMsgContent::Link` exists as a
// variant. What is NOT legitimate is a test title asserting a property no code exercises.
// So this test states what it proves; the anticipation is named, not dressed up.
test("connecting applies the Links waiting for me, and leaves consumer deposits alone", async () => {
inject();
setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected");
const pubDoc = await createEntityDoc("alice", "public");
const docInbox = await openDocumentInbox(pubDoc);
// Two deposits, one at each level, both made by someone else.
resetRegistryCache();
setCurrentUser("carol");
const carolDoc = await createEntityDoc("carol", "protected");
await share(carolDoc, "alice"); // a Link, into ALICE's own inbox — the only cap path
await post(docInbox, { payload: { onTheDocument: true }, ts: 2 }); // consumer data
// Alice connects: one call, and she calls nothing to "receive".
setCurrentUser("alice");
await connectedUser();
expect(hasCap(carolDoc)).toBe(true); // 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
});
// The same resolution property one level up: a user's own inbox.
//
// REGRESSION (2026-08-10, found adversarially). This test used to pass on the module
// CACHE: `userInbox` keys by (account, scope) regardless of who is asking, so Bob hit the
// entry Alice had just warmed. Nothing about persistence was exercised — the fake did not
// even answer the shim query — so in a second SESSION (or a second browser page, which is
// what the applicative e2e runs) Bob would have got a DIFFERENT inbox, and his deposit
// would have gone where nobody reads. That is the exact failure this library already paid
// for once. Dropping the cache between the two actors is what makes it a real test.
test("a third party resolves another user's inbox, from the shim and not from a cache", async () => {
inject();
setCurrentUser("alice");
const aliceView = await userInbox("alice", "protected");
resetRegistryCache(); // Bob is another session: nothing of Alice's is in memory
setCurrentUser("bob");
const bobView = await userInbox("alice", "protected");
expect(bobView).toBe(aliceView);
});
+100
View File
@@ -0,0 +1,100 @@
import { test, expect, mock, beforeEach } from "bun:test";
import { docCreate, sparqlUpdate, sparqlQuery } from "../src/surface/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/surface/ng-proxy";
// NOTE ORDER: the "not configured → throw" case MUST run before any configure()
// call, because configure() sets a module-level singleton with no public reset.
test("throws a clear error when configure() was not called", async () => {
await expect(docCreate("sid", "Graph", "data:graph", "store")).rejects.toThrow(
/configure\(\) must be called before use/,
);
await expect(sparqlUpdate("sid", "INSERT DATA {}")).rejects.toThrow(
/configure\(\) must be called before use/,
);
await expect(sparqlQuery("sid", "SELECT * {}")).rejects.toThrow(
/configure\(\) must be called before use/,
);
});
// From here on, a fake real `ng` is injected via configure().
import { configure } from "../src/index";
import { setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps } from "../src/shared-wallet/bootstrap";
function fakeNg() {
return {
doc_create: mock(async (..._a: unknown[]) => "did:ng:o:new-doc"),
sparql_update: mock(async (..._a: unknown[]) => undefined),
sparql_query: mock(async (..._a: unknown[]) => ({ results: { bindings: [] } })),
// A sentinel: makeNg(), if ever used, would `.bind` and call THIS through
// the JS Proxy. We assert the primitives call the raw fns above directly.
};
}
function inject() {
const ng = fakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
return ng;
}
test("docCreate calls the real injected ng.doc_create with the exact args", async () => {
const ng = inject();
const nuri = await docCreate("sid-1", "Graph", "data:graph", "store", undefined);
expect(nuri).toBe("did:ng:o:new-doc");
expect(ng.doc_create).toHaveBeenCalledTimes(1);
expect(ng.doc_create.mock.calls[0]).toEqual(["sid-1", "Graph", "data:graph", "store", undefined]);
});
test("sparqlUpdate forwards (sessionId, query, anchor) to the real ng.sparql_update", async () => {
const ng = inject();
await sparqlUpdate("sid-2", "INSERT DATA { GRAPH <did:ng:o:a> { <s> <p> <o> } }", "did:ng:o:a");
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
expect(ng.sparql_update.mock.calls[0]).toEqual([
"sid-2",
"INSERT DATA { GRAPH <did:ng:o:a> { <s> <p> <o> } }",
"did:ng:o:a",
]);
});
test("sparqlUpdate passes anchor=undefined when omitted", async () => {
const ng = inject();
await sparqlUpdate("sid-3", "INSERT DATA {}");
expect(ng.sparql_update.mock.calls[0]).toEqual(["sid-3", "INSERT DATA {}", undefined]);
});
test("sparqlQuery forwards (sessionId, query, base, anchor) and returns the raw result", async () => {
const ng = inject();
const res = await sparqlQuery("sid-4", "SELECT ?e { GRAPH <g> { ?s ?p ?e } }", undefined, "did:ng:o:g");
expect(res).toEqual({ results: { bindings: [] } });
expect(ng.sparql_query).toHaveBeenCalledTimes(1);
expect(ng.sparql_query.mock.calls[0]).toEqual([
"sid-4",
"SELECT ?e { GRAPH <g> { ?s ?p ?e } }",
undefined,
"did:ng:o:g",
]);
});
test("the primitives do NOT route through the public ng proxy (makeNg)", async () => {
// makeNg builds a JS Proxy over the injected ng. If a primitive went through
// it, calls would land on the proxy's `get` trap, not on our raw mock fns.
// Spy on makeNg: it must never be invoked by the docs primitives.
const spy = mock(ngProxy.makeNg);
const ng = inject();
await docCreate("sid", "Graph", "data:graph", "store");
await sparqlUpdate("sid", "INSERT DATA {}");
await sparqlQuery("sid", "SELECT * {}");
expect(spy).toHaveBeenCalledTimes(0);
// And the raw injected fns were reached directly:
expect(ng.doc_create).toHaveBeenCalledTimes(1);
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
expect(ng.sparql_query).toHaveBeenCalledTimes(1);
});
+297
View File
@@ -0,0 +1,297 @@
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
import { post, read, materialize, watch } from "../src/surface/inbox";
import { userInbox, resetRegistryCache } from "../src/shared-wallet/account-registry";
import type { Deposit } from "../src/surface/inbox";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
// This suite injects a fake `ng` via configure() and reuses the storeRegistry's
// injected session provider (inbox docs live in the shared wallet). Restore the
// un-configured state at the end so docs.test.ts's guard still sees null config.
afterAll(() => {
resetConfig();
resetStoreRegistry();
setCurrentUser(null);
});
// NOTE ORDER: the "not configured → throw" case runs first — it exercises the
// registry-deps guard before any configureStoreRegistry() call.
test("throws a clear error when configureStoreRegistry() was not called", async () => {
resetStoreRegistry();
await expect(post("did:ng:o:inbox", { payload: { hi: 1 } })).rejects.toThrow(
/configureStoreRegistry\(\) must be called before use/,
);
await expect(read("did:ng:o:inbox")).rejects.toThrow(
/configureStoreRegistry\(\) must be called before use/,
);
});
// --- A stateful fake `ng`: parses the inbox INSERT DATA and answers the read
// SELECT over an in-memory quad store.
interface Quad { g: string; s: string; p: string; o: string }
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;
}
function makeFakeNg() {
const quads: Quad[] = [];
// Reactive subscriptions: doc_subscribe registers a callback per anchor and
// fires an initial State push; a matching sparql_update pushes a Patch to that
// anchor's subscribers. This mirrors the real broker's local-push behaviour so
// inbox.watch (now event-driven, no polling) can be tested 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 } } })); // initial push
return () => set!.delete(cb);
});
const pushTo = (anchor: string): void => {
for (const cb of subs.get(anchor) ?? []) cb({ V0: { Patch: { doc: anchor } } });
};
// Distinct NURIs, one per creation — as a real broker does. It returned the CONSTANT
// `"did:ng:o:new"` until 2026-08-07, so every document the library made was the same
// one: two users' inboxes collided, and the ownership guard could not fire because
// there was nothing to tell apart. An adversarial review measured it. A fake that
// produces a state the real system never produces makes its suite green and blind.
let created = 0;
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:new${++created}`);
// Parses one deposit: `<subj> a <Deposit> ; <payload> "..." ; <ts> "..." [; <from> "..."] .`
//
// The REAL broker keys triples by the ANCHORED repo's default graph, not by an
// explicit `GRAPH <…>` IRI (repo_graph_name(repo_id, overlay_id)). So this mock
// keys stored quads by the ANCHOR arg (a[2]) — the default graph of the anchored
// repo — and REJECTS any explicit `GRAPH <…>` wrapper, so the old wrong shape
// does NOT round-trip and can never regress silently.
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
if (/GRAPH\s*</.test(query)) return undefined; // explicit-GRAPH write → dropped
if (!anchor) return undefined;
const g = anchor;
const 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);
// predicate/object pairs: `a <type>` or `<p> "literal"`.
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? `${INBOX}:Deposit`; // `a` → rdf:type-ish
// Un-escape the SPARQL literal so payload JSON round-trips. Single pass
// over `\x` sequences (reverses the lib's escapeLiteral without the
// double-processing that chained .replace() would cause).
const rawLit = m[2];
const o = rawLit !== undefined ? unescapeLiteral(rawLit) : (m[3] ?? "");
quads.push({ g, s, p, o });
}
// A write to `g` (the anchored default graph) pushes a Patch to that doc's
// subscribers — the local-push the real broker performs on a verified commit.
pushTo(g);
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
// Shim `isInbox` SELECT — the emulated "the broker knows this is an inbox". Absent at
// first, so `isKnownInbox` answered from its in-memory set alone: the durable half was
// never exercised, which is the very fault this pass was fixing elsewhere.
if (query.includes("urn:ng-eventually:shim:isInbox")) {
return { results: { bindings: quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:isInbox")
.map((q) => ({ i: { value: q.o } })) } };
}
// Shim `docInbox:<scope>` SELECT — WHICH inbox a virtual user owns. Without it
// `userInbox` never finds a persisted address and answers from the module cache, so
// a test comparing two actors compares one cached value with itself.
if (query.includes("urn:ng-eventually:shim:docInbox")) {
const pm = query.match(/<(urn:ng-eventually:shim:docInbox:[a-z]+)>/);
const pred = pm ? pm[1]! : "";
const sm = query.match(/<([^>]+)>\s+<urn:ng-eventually:shim:docInbox/);
const subj = sm ? sm[1]! : null;
return { results: { bindings: quads
.filter((q) => q.g === anchor && q.p === pred && (subj === null || q.s === subj))
.map((q) => ({ d: { value: q.o } })) } };
}
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (q.p === `${INBOX}:Deposit`) {
// rdf:type marker — ensure the subject exists.
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 } };
});
return { doc_create, doc_subscribe, sparql_update, sparql_query, _quads: quads };
}
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
/** 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();
configure({ ng: ng as any, useShape: (() => {}) as any });
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
configureStoreRegistry({ getSession: async () => SESSION });
setCurrentUser(null);
return ng;
}
let fake: ReturnType<typeof makeFakeNg>;
beforeEach(async () => {
fake = inject();
resetRegistryCache();
setCurrentUser("alice");
TARGET = await userInbox("alice", "protected");
});
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.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 <…>`
// wrapper (which the real broker would route to a phantom graph).
expect(call[1] as string).not.toContain("GRAPH <");
});
test("post → read round-trips payload, from and ts", async () => {
setCurrentUser("alice"); // `from` is bound to the current identity
await post(TARGET, { from: "alice", payload: { kind: "join", n: 3 }, ts: 100 });
const deposits = await read(TARGET);
expect(deposits).toHaveLength(1);
expect(deposits[0]).toEqual({ from: "alice", payload: { kind: "join", n: 3 }, ts: 100 });
});
// (c) `from` is BOUND to the current identity — a spoof (naming another
// principal) is REJECTED; identifying as self or anonymous (null) is allowed.
test("(c) post rejects a spoofed `from` (naming another principal); self/null allowed", async () => {
setCurrentUser("alice");
// SPOOF: alice tries to deposit AS bob → rejected.
await expect(post(TARGET, { from: "bob", payload: { x: 1 }, ts: 1 })).rejects.toThrow(
/spoof|current identity/i,
);
// Identifying as self → allowed.
await post(TARGET, { from: "alice", payload: { x: 2 }, ts: 2 });
// Explicit anonymous → allowed.
await post(TARGET, { from: null, payload: { x: 3 }, ts: 3 });
const froms = (await read(TARGET)).map((d) => d.from);
expect(froms).toEqual(["alice", null]);
});
// Bob DEPOSITS, alice READS. The asymmetry is the model — anyone deposits, only the
// owner reads — so a test that reads back under the depositor is testing a path no
// application has. It passed until 2026-08-07 only because the fake `doc_create` handed
// out one NURI for every document, so the ownership guard had nothing to tell apart.
test("from is optional — omitting it defaults to the depositor", async () => {
setCurrentUser("bob");
await post(TARGET, { payload: { hi: 1 }, ts: 200 });
setCurrentUser("alice");
const deposits = await read(TARGET);
expect(deposits[0]!.from).toBe("bob");
});
test("from: null makes an anonymous deposit even when a current user is set", async () => {
setCurrentUser("bob");
await post(TARGET, { from: null, payload: { hi: 1 }, ts: 200 });
setCurrentUser("alice");
const deposits = await read(TARGET);
expect(deposits[0]!.from).toBeNull();
});
test("read returns deposits sorted by ts ascending and materialize is an alias", async () => {
await post(TARGET, { from: null, payload: "second", ts: 300 });
await post(TARGET, { from: null, payload: "first", ts: 100 });
await post(TARGET, { from: null, payload: "third", ts: 500 });
const deposits = await materialize(TARGET);
expect(deposits.map((d) => d.payload)).toEqual(["first", "second", "third"]);
});
test("read is scoped to one inbox — deposits in another inbox are not returned", async () => {
// The OTHER inbox is obtained from the system, not invented. A made-up NURI would be
// a target no deposit can legitimately reach (`inbox.post` refuses what is not an
// inbox), so the test would have been proving something the model does not allow.
const otherInbox = await userInbox("bob", "protected");
expect(otherInbox).not.toBe(TARGET);
await post(TARGET, { from: null, payload: "mine", ts: 1 });
await post(otherInbox, { from: null, payload: "theirs", ts: 2 });
const deposits = await read(TARGET);
expect(deposits.map((d) => d.payload)).toEqual(["mine"]);
});
test("payload with quotes/newlines/backslashes survives the round-trip", async () => {
const payload = { text: 'a "quoted"\nline\\path\ttab' };
await post(TARGET, { from: null, payload, ts: 1 });
const deposits = await read(TARGET);
expect(deposits[0]!.payload).toEqual(payload);
});
test("watch fires immediately then on each new deposit, and unsubscribe stops it", async () => {
const seen: Deposit[][] = [];
const stop = watch(TARGET, (d) => seen.push(d), { intervalMs: 5 });
// Give the immediate tick a chance to run (empty inbox → still fires once).
await new Promise((r) => setTimeout(r, 20));
expect(seen.length).toBeGreaterThanOrEqual(1);
expect(seen[seen.length - 1]).toEqual([]);
await post(TARGET, { from: null, payload: "x", ts: 1 });
await new Promise((r) => setTimeout(r, 20));
const last = seen[seen.length - 1]!;
expect(last.map((d) => d.payload)).toEqual(["x"]);
stop();
const countAfterStop = seen.length;
await post(TARGET, { from: null, payload: "y", ts: 2 });
await new Promise((r) => setTimeout(r, 20));
expect(seen.length).toBe(countAfterStop); // no more callbacks after unsubscribe
});
@@ -0,0 +1,440 @@
/**
* ReadCap ACTIVE — end-to-end proof that the emulated SDK enforces per-DOCUMENT
* isolation, driven by per-entity documents + KEY POSSESSION.
*
* Mirrors what the app does: create an entity document through the REAL registry
* (`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) the read-filtered VIEW decides on possession alone — it is synchronous, so it
* asks no store anything (a public store WOULD serve its cap; that is proven on
* the read paths, in `cross-user-access.test.ts`);
* (c) switching identity SWITCHES heldByHolder — it never wipes one.
*/
import { getCaps } from "../src/shared-wallet/bootstrap";
import { test, expect, mock, afterAll } from "bun:test";
import { createEntityDoc, resetRegistryCache, userInbox, listMyEntityDocs } from "../src/shared-wallet/account-registry";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import type { Nuri, ReadCap } from "../src/model/types";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { share } from "../src/surface/inbox";
import { read as readInbox } from "../src/surface/inbox";
import { filterReadable } from "../src/emulated-verifier/read-filter";
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
setCurrentUser(null);
});
const SESSION: RegistrySession = { sessionId: "sid", privateStoreId: "PRIV" };
const SHIM = "urn:ng-eventually:shim";
const INBOX = "urn:ng-eventually:inbox";
/** Possession, asked of the internal registry — see `polyfill.ts` on why the door
* stopped publishing it. */
function hasCap(nuri: Nuri): boolean {
return getCaps().capFor(nuri) !== undefined;
}
interface Quad { g: string; s: string; p: string; o: string }
/** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */
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 } };
}
// Shim `isInbox` SELECT — the emulated "the broker knows this is an inbox". Absent at
// first, so `isKnownInbox` answered from its in-memory set alone: the durable half was
// never exercised, which is the very fault this pass was fixing elsewhere.
if (query.includes(`${SHIM}:isInbox`)) {
return { results: { bindings: quads
.filter((q) => q.g === anchor && q.p === `${SHIM}:isInbox`)
.map((q) => ({ i: { value: q.o } })) } };
}
// Shim `docInbox:<scope>` SELECT — WHICH inbox a virtual user owns. Absent until
// 2026-08-10, so `userInbox` never found a persisted address and answered from the
// module cache alone: two actors in one JS realm agreed, two SESSIONS would not have.
// The suite's "a third party resolves another user's inbox" was proving the cache.
if (query.includes(`${SHIM}:docInbox`)) {
const pm = query.match(/<(urn:ng-eventually:shim:docInbox:[a-z]+)>/);
const pred = pm ? pm[1]! : "";
const sm = query.match(/<([^>]+)>\s+<urn:ng-eventually:shim:docInbox/);
const subj = sm ? sm[1]! : null;
return { results: { bindings: quads
.filter((q) => q.g === anchor && q.p === pred && (subj === null || q.s === subj))
.map((q) => ({ d: { value: q.o } })) } };
}
// 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 });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId });
resetRegistryCache();
resetCaps();
setCurrentUser(null);
return ng;
}
/** 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("a created document is readable by its creator and by nobody else", async () => {
inject();
setCurrentUser("alice");
const aliceDoc = await createEntityDoc("alice", "private");
setCurrentUser("bob");
const bobDoc = await createEntityDoc("bob", "private");
const items = [item(aliceDoc, "a1"), item(bobDoc, "b1")];
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) 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")];
// BEFORE the share: bob reads nothing of alice's.
setCurrentUser("bob");
expect(view(items)).toEqual([]);
// 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 userInbox("bob", "protected");
setCurrentUser("alice");
await share(shared, "bob");
// 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`
// carol, who was not shared with, still reads nothing.
setCurrentUser("carol");
await readInbox(await userInbox("carol", "protected"));
expect(view(items)).toEqual([]);
});
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 userInbox("bob", "protected");
await share(doc, "bob");
setCurrentUser("bob");
const deposits = await readInbox(bobInbox);
expect(deposits).toEqual([]); // infrastructure, not consumer data
expect(hasCap(doc)).toBe(true); // …but it landed in bob's held caps
});
// (b) The ORM read filter is PURE POSSESSION — it asks nothing of anyone.
//
// Note what this does NOT say: that a bare reference to a public document is
// unreadable. It is readable, through the read paths, because a public store serves
// its cap (`emulated-verifier/public-store.ts`, and `cross-user-access.test.ts` proves
// it). This filter sits below that: it is synchronous, it decides from what the holder
// holds AT THAT MOMENT, and a document whose cap was never obtained is filtered out
// whatever store it sits in. The library's own read paths ask first; the reactive ORM
// view has no door to ask through, and that limit is recorded in `read-filter.ts`.
test("(b) the read-filtered view decides on possession alone, with no lookup", async () => {
inject();
setCurrentUser("alice");
const pub = await createEntityDoc("alice", "public");
const items = [item(pub, "u1")];
expect(getCaps().isInPublicStore(pub)).toBe(true);
const cap = getCaps().capFor(pub)!;
// bob HAS the document's bare NURI (it is right there in `items`), holds no cap for
// it, and the view drops it — no question asked of any store.
setCurrentUser("bob");
expect(view(items)).toEqual([]);
// Once the cap IS among what he holds — however it got there — the same view yields it.
getCaps().learn(cap);
expect(view(items)).toEqual(["u1"]);
});
// (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");
expect(hasCap(doc)).toBe(true);
setCurrentUser("bob");
expect(hasCap(doc)).toBe(false);
setCurrentUser("alice");
expect(hasCap(doc)).toBe(true); // 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");
expect(hasCap(doc)).toBe(true);
// Same account, spelled differently — same shim account, so the same held caps.
setCurrentUser("alice");
expect(hasCap(doc)).toBe(true);
setCurrentUser(" ALICE ");
expect(hasCap(doc)).toBe(true);
// A genuinely different account still holds nothing.
setCurrentUser("bob");
expect(hasCap(doc)).toBe(false);
});
// 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 userInbox("bob", "protected");
// Alice deposits into bob's inbox — allowed, and it grants her nothing back.
await share(secret, "bob");
await expect(readInbox(bobInbox)).rejects.toThrow(/does not belong to the connected wallet/i);
expect(hasCap(secret)).toBe(true); // 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(hasCap(secret)).toBe(false); // 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(hasCap(secret)).toBe(true);
});
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/shared-wallet/account-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(getCaps().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(getCaps().capFor(doc)).toBe(stored.o as ReadCap); // same value, not two mints that agree by luck
});
+51
View File
@@ -0,0 +1,51 @@
/**
* The reserved-namespace predicate, in isolation.
*
* It is one `startsWith`, but it is the seam that keeps the polyfill's emulated
* branches out of the consumer's data (see `machinery.ts`), so its edges are worth
* pinning: get it wrong in one direction and machinery leaks into domain properties;
* wrong in the other and real data silently disappears from reads.
*/
import { test, expect } from "bun:test";
import { MACHINERY_NS, isMachinerySubject } from "../src/emulated-verifier/machinery";
test("the emulated branch subjects are all machinery", () => {
// The four compartments store-registry emulates, verbatim.
for (const s of [
"urn:ng-eventually:shim:index",
"urn:ng-eventually:shim:storeBranch",
"urn:ng-eventually:shim:userBranch",
"urn:ng-eventually:shim:headerBranch",
]) {
expect(isMachinerySubject(s)).toBe(true);
}
});
test("inbox deposits are machinery too — a second prefix under the same namespace", () => {
expect(isMachinerySubject("urn:ng-eventually:inbox:deposit:1700:abc")).toBe(true);
});
test("consumer subjects are not machinery — including a NURI, which is what entities use", () => {
expect(isMachinerySubject("did:ng:o:doc1")).toBe(false);
expect(isMachinerySubject("urn:e2e:secret")).toBe(false);
expect(isMachinerySubject("http://example.org/thing")).toBe(false);
});
test("a look-alike prefix is NOT machinery — the boundary is exact, not fuzzy", () => {
// Anything that merely resembles the namespace must fall on the data side, or a
// consumer's own vocabulary could vanish from its reads.
expect(isMachinerySubject("urn:ng-eventuallyX:thing")).toBe(false);
expect(isMachinerySubject("urn:ng-event:thing")).toBe(false);
expect(isMachinerySubject("x-urn:ng-eventually:shim:index")).toBe(false);
});
test("an absent subject is not machinery — read paths hand bindings straight in", () => {
expect(isMachinerySubject(undefined)).toBe(false);
expect(isMachinerySubject("")).toBe(false);
});
test("the namespace is the prefix both writers actually use", () => {
// Guards against the constant drifting away from store-registry/inbox.
expect("urn:ng-eventually:shim".startsWith(MACHINERY_NS)).toBe(true);
expect("urn:ng-eventually:inbox".startsWith(MACHINERY_NS)).toBe(true);
});
+87
View File
@@ -0,0 +1,87 @@
import { getCaps } from "../src/shared-wallet/bootstrap";
import { test, expect, mock, afterEach } from "bun:test";
import { makeNg } from "../src/surface/ng-proxy";
import { configure } from "../src/index";
import { setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig } from "../src/shared-wallet/bootstrap";
// 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();
setCurrentUser(null);
});
function fakeNg() {
return { sparql_update: mock(async (..._a: unknown[]) => undefined) };
}
function inject() {
const ng = fakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
return ng;
}
const DOC = "did:ng:o:doc";
const UPDATE = `INSERT DATA { GRAPH <${DOC}> { <s> <p> <o> } }`;
test("write guard: passthrough when NO write policy is declared (no regression)", async () => {
const ng = inject();
setCurrentUser("bob"); // not a writer, but there's no policy at all
const proxy = makeNg();
await proxy.sparql_update("sid", UPDATE, DOC);
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
});
test("write guard: passthrough for an UNGOVERNED doc even when a policy exists elsewhere", async () => {
const ng = inject();
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
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
});
test("write guard: REJECTS when the doc is governed and the user lacks the write cap", async () => {
const ng = inject();
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(
/write denied/,
);
expect(ng.sparql_update).toHaveBeenCalledTimes(0); // never reached the real ng
});
test("write guard: REJECTS an anonymous (null) user on a governed doc", async () => {
const ng = inject();
getCaps().grantWrite(DOC, "alice");
setCurrentUser(null);
const proxy = makeNg();
await expect(proxy.sparql_update("sid", UPDATE, DOC)).rejects.toThrow(
/write denied/,
);
expect(ng.sparql_update).toHaveBeenCalledTimes(0);
});
test("write guard: ALLOWS the write-cap holder", async () => {
const ng = inject();
getCaps().grantWrite(DOC, "alice");
setCurrentUser("alice"); // owner always holds the write cap
const proxy = makeNg();
await proxy.sparql_update("sid", UPDATE, DOC);
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
});
test("write guard: passthrough when anchor is omitted (cannot scope the guard)", async () => {
const ng = inject();
getCaps().grantWrite(DOC, "alice");
setCurrentUser("bob");
const proxy = makeNg();
await proxy.sparql_update("sid", "INSERT DATA {}"); // no anchor → passthrough
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
});
+219
View File
@@ -0,0 +1,219 @@
/**
* open-repo.test.ts — behavioral tests for ensureRepoOpen / ensureReposOpen
* (src/open-repo.ts).
*
* Core invariant: on a fresh session over a persistent wallet, a scope-index
* or entity repo is NOT yet in `self.repos`, so an anchored sparql_query returns
* 0 rows. `ensureRepoOpen(nuri)` calls `doc_subscribe(nuri, …)` FIRST (which
* pushes the repo into the session), then the anchored read returns data.
*
* Fake design:
* - sparql_query returns EMPTY for a nuri UNTIL doc_subscribe has been called
* for that nuri (tracked in a Set).
* - doc_subscribe is a mock that records calls, fires the callback once
* (simulating the initial State push), then returns an unsubscribe fn.
*
* We test ensureRepoOpen via readUnion (from read-model) because that is the
* production caller — it gates on ensureReposOpen internally.
*/
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
import { ensureRepoOpen, ensureReposOpen, resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { readUnion } from "../src/surface/read-model";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetRegistryCache } from "../src/shared-wallet/account-registry";
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetRegistryCache();
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();
setCurrentUser(null);
});
const SESSION = { sessionId: "sid-or", privateStoreId: "PRIV-OR" };
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
const FP = "http://festipod.org/";
// ---------------------------------------------------------------------------
// Fake ng builder: tracks which nuris have been doc_subscribe-d.
// sparql_query returns rows only AFTER the corresponding nuri is subscribed.
// ---------------------------------------------------------------------------
function makeFakeNgWithSubscribe(
triplesByDoc: Record<string, Array<[string, string]>>,
) {
const subscribed = new Set<string>();
const subscribeCallOrder: string[] = [];
// doc_subscribe: record the call, fire callback immediately (initial push), return unsub
const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
subscribed.add(nuri);
subscribeCallOrder.push(nuri);
// Simulate initial State push (synchronously deferred so the subscription
// setup promise path in ensureRepoOpen can resolve it).
setTimeout(() => cb({ V0: { State: {} } }), 0);
return () => {}; // unsubscribe fn
});
const sparql_query = mock(async (_sid: string, _query: string, _base: unknown, anchor: unknown) => {
const doc = anchor as string | undefined;
if (!doc) return { results: { bindings: [] } };
// Only return data if the repo has been subscribed (i.e. opened)
if (!subscribed.has(doc)) return { results: { bindings: [] } };
const triples = triplesByDoc[doc];
if (!triples) return { results: { bindings: [] } };
const bindings = triples.map(([p, o]) => ({
s: { value: doc },
p: { value: p },
o: { value: o },
}));
return { results: { bindings } };
});
const doc_create = mock(async () => "did:ng:o:new");
const sparql_update = mock(async () => undefined);
return { doc_subscribe, sparql_query, doc_create, sparql_update, subscribed, subscribeCallOrder };
}
function inject(ng: ReturnType<typeof makeFakeNgWithSubscribe>) {
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u: string) => u,
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("ensureRepoOpen", () => {
it("calls doc_subscribe BEFORE the anchored read returns data", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:a": [[TYPE, `${FP}Event`], [`${FP}title`, "Alpha"]],
});
inject(ng);
// Directly call ensureRepoOpen then verify read sees data
await ensureRepoOpen("did:ng:o:a");
// doc_subscribe was called for the nuri
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
expect(ng.subscribeCallOrder[0]).toBe("did:ng:o:a");
// sparql_query was called AFTER subscribe (ensureRepoOpen guarantees ordering)
const result = await readUnion(["did:ng:o:a"]);
expect(result.length).toBe(1);
expect(result[0]!.props[`${FP}title`]).toEqual(["Alpha"]);
});
it("WITHOUT doc_subscribe, sparql_query returns 0 rows (verifies fake mechanics)", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:a": [[TYPE, `${FP}Event`], [`${FP}title`, "Alpha"]],
});
inject(ng);
// Do NOT call ensureRepoOpen — subscribed Set remains empty
// Query directly (bypass readUnion which calls ensureReposOpen internally)
const result = await ng.sparql_query("sid-or", "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, "did:ng:o:a");
const bindings = (result as any).results.bindings;
expect(bindings.length).toBe(0); // not subscribed → 0 rows (confirms fake design)
});
it("idempotence: a 2nd ensureRepoOpen for the same nuri does NOT re-subscribe", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:b": [[TYPE, `${FP}Event`]],
});
inject(ng);
await ensureRepoOpen("did:ng:o:b");
await ensureRepoOpen("did:ng:o:b"); // second call
// doc_subscribe must have been called exactly ONCE
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
});
it("no-op when the fake ng has no doc_subscribe (unit fake path)", async () => {
// Fake ng WITHOUT doc_subscribe
const noSubscribeNg = {
doc_create: mock(async () => "did:ng:o:new"),
sparql_update: mock(async () => undefined),
sparql_query: mock(async () => ({ results: { bindings: [] } })),
};
configure({ ng: noSubscribeNg as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u: string) => u,
});
// Must not throw; nuri is added to opened Set (guard skips subscribe)
await expect(ensureRepoOpen("did:ng:o:c")).resolves.toBeUndefined();
// Calling again should also be a no-op (idempotent, already in opened)
await expect(ensureRepoOpen("did:ng:o:c")).resolves.toBeUndefined();
});
});
describe("ensureReposOpen", () => {
it("opens all provided nuris in parallel (one subscribe per unique nuri)", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:x": [[TYPE, `${FP}Event`]],
"did:ng:o:y": [[TYPE, `${FP}Event`]],
});
inject(ng);
await ensureReposOpen(["did:ng:o:x", "did:ng:o:y"]);
expect(ng.doc_subscribe).toHaveBeenCalledTimes(2);
expect(ng.subscribed.has("did:ng:o:x")).toBe(true);
expect(ng.subscribed.has("did:ng:o:y")).toBe(true);
});
it("deduplicates: repeated nuri in input leads to exactly one subscribe", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:dup": [[TYPE, `${FP}Event`]],
});
inject(ng);
await ensureReposOpen(["did:ng:o:dup", "did:ng:o:dup", "did:ng:o:dup"]);
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
});
it("empty or all-falsy input is a no-op (no subscribe calls)", async () => {
const ng = makeFakeNgWithSubscribe({});
inject(ng);
await ensureReposOpen([]);
await ensureReposOpen(["" as any]);
expect(ng.doc_subscribe).toHaveBeenCalledTimes(0);
});
it("readUnion triggers doc_subscribe then returns data (integration path)", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:p": [[TYPE, `${FP}Participation`], [`${FP}event`, "did:ng:o:e"]],
});
inject(ng);
const subjects = await readUnion(["did:ng:o:p"]);
// doc_subscribe was called as part of ensureReposOpen inside readUnion
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
expect(subjects.length).toBe(1);
expect(subjects[0]!.props[`${FP}event`]).toEqual(["did:ng:o:e"]);
});
});
+179
View File
@@ -0,0 +1,179 @@
/**
* public-store.test.ts — the emulated *"downloaded from the outerOverlay"*, in isolation.
*
* `cross-user-access.test.ts` proves the consequence end to end (Bob reads Alice's
* public document from a bare reference). This file pins the primitive itself: what it
* asks, what it refuses, and when it says nothing at all.
*/
import { test, expect, mock, afterEach } from "bun:test";
import { exposeReadCap, fetchReadCap, resetPublicStoreFetches } from "../src/emulated-verifier/public-store";
import { mintCap } from "../src/emulated-verifier/caps";
import { getCaps } from "../src/shared-wallet/bootstrap";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import type { Nuri } from "../src/model/types";
const SHIM = "urn:ng-eventually:shim";
const SESSION = { sessionId: "sid-ps", privateStoreId: "PRIV-PS" };
interface Quad { g: string; s: string; p: string; o: string }
/** A fake `ng` holding just enough to answer the Header-branch `exposedReadCap` query. */
function inject() {
const quads: Quad[] = [];
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string;
if (/^\s*DELETE WHERE/.test(query)) {
for (let i = quads.length - 1; i >= 0; i--) if (quads[i]!.g === anchor) quads.splice(i, 1);
return undefined;
}
const m = query.match(/<([^>]+)>\s+<([^>]+)>\s+"([^"]*)"/);
if (m) quads.push({ g: anchor, s: m[1]!, p: m[2]!, o: m[3]! });
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => ({
results: {
bindings: quads
.filter((q) => q.g === (a[3] as string) && q.p === `${SHIM}:exposedReadCap`)
.map((q) => ({ c: { value: q.o } })),
},
}));
configure({ ng: { doc_create: mock(async () => "did:ng:o:x"), sparql_update, sparql_query } as any, useShape: (() => {}) as any });
configureStoreRegistry({ getSession: async () => SESSION });
resetCaps();
resetPublicStoreFetches();
setCurrentUser(null);
return { sparql_query, quads };
}
afterEach(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
setCurrentUser(null);
});
/**
* Alice creates her note and exposes its cap — the two halves of what `createEntityDoc`
* does for a `public` scope, in that order.
*
* The `mint` is not decoration: exposing writes to the document, and writing needs to
* reach it. Without it this file only passed while the emulation happened to be
* DISARMED, which made its results depend on which test file ran first — it went red in
* `bun test <other-file> test/public-store.test.ts`. A fixture that exposes a cap for a
* document nobody holds describes a state the library never produces.
*/
async function aliceExposesHerNote(): Promise<void> {
getCaps().mint(PUB);
await exposeReadCap(PUB, mintCap(PUB));
}
/** Arm the emulation without giving the current holder anything: some OTHER document. */
function armEmulation(): void {
setCurrentUser("someone-else");
getCaps().mint("did:ng:o:unrelated");
}
const PUB = "did:ng:o:pub" as Nuri;
test("a cap exposed on a document is downloaded by a holder that has nothing", async () => {
inject();
setCurrentUser("alice");
await aliceExposesHerNote();
setCurrentUser("bob");
armEmulation();
setCurrentUser("bob");
expect(getCaps().capFor(PUB)).toBeUndefined();
expect(await fetchReadCap(PUB)).toBe(true);
expect(getCaps().capFor(PUB)).toBe(mintCap(PUB));
expect(getCaps().isInPublicStore(PUB)).toBe(true);
});
test("a document that exposes nothing yields nothing — that is the normal case, not an error", async () => {
inject();
armEmulation();
setCurrentUser("bob");
expect(await fetchReadCap("did:ng:o:protected" as Nuri)).toBe(false);
expect(getCaps().capFor("did:ng:o:protected" as Nuri)).toBeUndefined();
});
// A document speaks for itself and for nothing else. Without this, whoever can write
// into one public document could file caps for every document they care to name.
test("a cap naming ANOTHER document is refused, not filed", async () => {
const { quads } = inject();
setCurrentUser("alice");
await aliceExposesHerNote();
// Forge the exposed value so it names a different document.
quads[0]!.o = mintCap("did:ng:o:someone-elses" as Nuri);
armEmulation();
setCurrentUser("bob");
expect(await fetchReadCap(PUB)).toBe(false);
expect(getCaps().capFor(PUB)).toBeUndefined();
expect(getCaps().capFor("did:ng:o:someone-elses" as Nuri)).toBeUndefined();
});
test("inert while no cap has been issued at all — nothing to obtain, nothing asked", async () => {
const { sparql_query } = inject();
setCurrentUser("bob");
expect(await fetchReadCap(PUB)).toBe(false);
expect(sparql_query).toHaveBeenCalledTimes(0);
});
// REGRESSION (2026-08-07, found adversarially). The memo used to cache a BOOLEAN, so the
// first holder to ask triggered the download, the cap was filed for THEM, and every later
// holder got `true` while holding nothing — their next read was refused. Upstream a broker
// serving a pinned outer overlay answers EVERY asker.
test("a public store serves every asker, not only the first", async () => {
inject();
setCurrentUser("alice");
await aliceExposesHerNote();
armEmulation();
setCurrentUser("bob");
expect(await fetchReadCap(PUB)).toBe(true);
expect(getCaps().capFor(PUB)).toBe(mintCap(PUB));
setCurrentUser("carol");
expect(await fetchReadCap(PUB)).toBe(true);
expect(getCaps().capFor(PUB)).toBe(mintCap(PUB)); // …and she HOLDS it, not just "true"
});
test("asked once per document: the outcome is memoised, in both directions", async () => {
const { sparql_query } = inject();
setCurrentUser("alice");
await aliceExposesHerNote();
armEmulation();
setCurrentUser("bob");
await fetchReadCap(PUB);
const afterHit = sparql_query.mock.calls.length;
await fetchReadCap(PUB); // held now → not even the memo is consulted
expect(sparql_query.mock.calls.length).toBe(afterHit);
const absent = "did:ng:o:nothing-here" as Nuri;
await fetchReadCap(absent);
const afterMiss = sparql_query.mock.calls.length;
await fetchReadCap(absent); // a miss is remembered too
expect(sparql_query.mock.calls.length).toBe(afterMiss);
});
test("resetting the caps forgets the memo — a stale yes would hand back what is no longer held", async () => {
const { sparql_query } = inject();
setCurrentUser("alice");
await aliceExposesHerNote();
armEmulation();
setCurrentUser("bob");
await fetchReadCap(PUB);
resetCaps(); // also calls resetPublicStoreFetches
armEmulation();
setCurrentUser("bob");
const before = sparql_query.mock.calls.length;
expect(await fetchReadCap(PUB)).toBe(true);
expect(sparql_query.mock.calls.length).toBeGreaterThan(before); // asked again
});
+216
View File
@@ -0,0 +1,216 @@
/**
* 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 } from "../src/surface/docs";
import { depositInto } from "../src/emulated-verifier/register-write";
import { createEntityDoc, resetRegistryCache, userInbox } from "../src/shared-wallet/account-registry";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { mayReach, mustNotAttempt } from "../src/emulated-verifier/reach";
import { hasReadCap } from "../src/model/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 userInbox("alice", "protected");
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 userInbox("bob", "protected");
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");
for (const name of Object.keys(entry)) {
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();
}
// …and the machinery accessors the merged entry deliberately stopped publishing
// (2026-08-07): internal wiring and test resets are reached by their internal path.
for (const unpublished of ["getConfig", "getStoreRegistryDeps", "resetConfig", "resetStoreRegistry", "resetCaps", "getCaps", "getCurrentUser"]) {
expect(entry[unpublished]).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();
}
});
+144
View File
@@ -0,0 +1,144 @@
import { test, expect } from "bun:test";
import { filterReadable, makeReadFilteredView } from "../src/emulated-verifier/read-filter";
import { CapRegistry } from "../src/emulated-verifier/caps";
// The access unit is the DOCUMENT (an item's `@graph` = the repo it lives in),
// not the item. Items here carry `@graph`; each holder holds caps per document.
interface Item { id: string; "@graph"?: string }
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
/** 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.open("did:ng:o:public", "public");
holder = before;
return { caps, link, become: (id: string | null) => (holder = id) };
}
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("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([]);
});
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);
});
test("makeReadFilteredView forwards mutations and membership to the target", () => {
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"]);
view.delete(C);
expect(set.has(C)).toBe(false);
});
// REGRESSION (2026-08-07, found adversarially). The view forwarded every member it did
// not name, bound to the TARGET — so `.values()`, `.map()`, `.getById()` returned another
// identity's items. Those are the members a reactive-set API puts forward, so a consumer
// reaches for them first. A filtered view may show LESS than the set holds; never more.
test("every item-yielding member is filtered, not just iteration", () => {
const items = [MINE, LINKED, FOREIGN, NOGRAPH];
const set = new Set<Item>(items);
const { caps, become } = setup("alice");
const view = makeReadFilteredView(set, caps) as any;
become("bob"); // holds nothing: only the graphless item may surface
expect([...view.values()].map((i: Item) => i.id)).toEqual(["x"]);
expect([...view.keys()].map((i: Item) => i.id)).toEqual(["x"]);
expect([...view.entries()].map(([i]: [Item]) => i.id)).toEqual(["x"]);
expect(view.map((i: Item) => i.id)).toEqual(["x"]);
expect(view.filter((i: Item) => true).map((i: Item) => i.id)).toEqual(["x"]);
expect(view.find((i: Item) => i.id === "a")).toBeUndefined();
expect(view.some((i: Item) => i.id === "a")).toBe(false);
expect(view.has(MINE)).toBe(false);
});
// REGRESSION (second adversarial pass). `DeepSignalSet` exposes the underlying
// collection on dunder keys; the view forwarded non-function properties untouched, so
// `view.__raw__` handed back every identity's items while `[...view]` showed none.
test("a dunder escape hatch cannot reach past the view", () => {
const set = new Set<Item>([MINE]) as any;
set.__raw__ = set;
const { caps, become } = setup("alice");
const view = makeReadFilteredView(set, caps) as any;
become("bob");
expect([...view]).toEqual([]);
expect(() => view.__raw__).toThrow(/raw collection/i);
});
// REGRESSION (second adversarial pass). The first whitelist covered half of the reactive
// set's iterator helpers and threw on the rest, so a holder's calls on their OWN data
// crashed. Filtering is the answer for all of them; refusing is only for the unknown.
test("every iterator helper is filtered, and none of them throws on one's own data", () => {
const set = new Set<Item>([MINE, FOREIGN]);
const { caps } = setup("alice"); // alice holds MINE only
const view = makeReadFilteredView(set, caps) as any;
expect(view.toArray().map((i: Item) => i.id)).toEqual(["a"]);
expect(view.first().id).toBe("a");
expect(view.take(1).map((i: Item) => i.id)).toEqual(["a"]);
expect(view.drop(1)).toEqual([]);
expect(view.flatMap((i: Item) => [i.id])).toEqual(["a"]);
expect(view.reduce((acc: string, i: Item) => acc + i.id, "")).toBe("a");
});
// An unknown member must REFUSE, not forward: forwarding is a silent leak, and this
// view's one job is that it cannot show more than the holder may read.
test("an unfiltered member throws rather than leaking", () => {
const set = new Set<Item>([MINE]) as any;
set.sample = () => [...set][0];
const { caps, become } = setup("alice");
const view = makeReadFilteredView(set, caps) as any;
become("bob");
expect(() => view.sample()).toThrow(/not filtered/i);
});
test("forEach is filtered too", () => {
const set = new Set<Item>([MINE, LINKED]);
const seen: string[] = [];
const { caps, become } = setup("alice");
become("bob");
makeReadFilteredView(set, caps).forEach((i) => seen.push((i as Item).id));
expect(seen).toEqual([]);
});
+267
View File
@@ -0,0 +1,267 @@
import { getCaps } from "../src/shared-wallet/bootstrap";
import { test, expect, mock, afterAll } from "bun:test";
import { readUnion } from "../src/surface/read-model";
import type { Nuri } from "../src/model/types";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps } from "../src/shared-wallet/bootstrap";
// 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
// NO anchorless union scan: each doc is read independently by its own anchor. Each
// entity subject IRI IS its own document NURI (writeEntity convention), so the
// fixture keys triples by the doc NURI and returns them for the matching anchor.
function fakeNgWith(triplesByDoc: Record<string, Array<[string, string]>>) {
return {
doc_create: mock(async () => "did:ng:o:new"),
sparql_update: mock(async () => undefined),
sparql_query: mock(async (_sid: string, _query: string, _base: unknown, anchor: unknown) => {
// Every read is ANCHORED to one doc NURI — never anchorless.
if (anchor === undefined) {
throw new Error("read-model must NEVER run an anchorless (union) query");
}
const doc = anchor as string;
const triples = triplesByDoc[doc];
if (!triples) return { results: { bindings: [] } };
const bindings = triples.map(([p, o]) => ({
s: { value: doc },
p: { value: p },
o: { value: o },
}));
return { results: { bindings } };
}),
};
}
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" }),
normalizeId: (u: string) => u,
});
return ng;
}
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
const FP = "http://festipod.org/";
test("readUnion reads each doc with its OWN anchored query (never anchorless)", async () => {
const ng = inject({
"did:ng:o:a": [[TYPE, `${FP}Event`], [`${FP}title`, "A"]],
"did:ng:o:b": [[TYPE, `${FP}Event`], [`${FP}title`, "B"]],
});
const subjects = await readUnion(["did:ng:o:a", "did:ng:o:b"]);
// One anchored query per doc = 2 sparql_query calls, each anchored (c[3] set).
expect(ng.sparql_query).toHaveBeenCalledTimes(2);
const anchored = ng.sparql_query.mock.calls.filter((c: unknown[]) => c[3] !== undefined);
expect(anchored.length).toBe(2);
// The anchors are exactly the requested doc NURIs.
expect(new Set(anchored.map((c: unknown[]) => c[3]))).toEqual(
new Set(["did:ng:o:a", "did:ng:o:b"]),
);
expect(subjects.length).toBe(2);
const a = subjects.find((s) => s.subject === "did:ng:o:a")!;
expect(a.props[`${FP}title`]).toEqual(["A"]);
expect(a.graph).toBe("did:ng:o:a");
});
test("readUnion groups predicates per subject", async () => {
inject({
"did:ng:o:p": [
[TYPE, `${FP}Participation`],
[`${FP}event`, "did:ng:o:e"],
[`${FP}user`, "urn:festipod:user:x"],
],
});
const s = (await readUnion(["did:ng:o:p"]))[0]!;
expect(s.subject).toBe("did:ng:o:p");
expect(s.props[`${FP}event`]).toEqual(["did:ng:o:e"]);
expect(s.props[`${FP}user`]).toEqual(["urn:festipod:user:x"]);
});
test("readUnion returns [] for an empty doc set (no query)", async () => {
const ng = inject({});
const subjects = await readUnion([]);
expect(subjects).toEqual([]);
expect(ng.sparql_query).toHaveBeenCalledTimes(0);
});
test("a doc that fails to read is skipped, not aborting the batch", async () => {
const ng = fakeNgWith({ "did:ng:o:ok": [[TYPE, `${FP}Event`], [`${FP}title`, "ok"]] });
const orig = ng.sparql_query;
// Make the anchored read throw for the bad doc only.
ng.sparql_query = mock(async (sid: string, query: string, base: unknown, anchor: unknown) => {
if (anchor === "did:ng:o:bad") throw new Error("RepoNotFound");
return orig(sid, query, base, anchor);
}) as any;
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
normalizeId: (u: string) => u,
});
const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]);
// 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([]);
});
test("readUnion tolerates holes in the list, and refuses a malformed reference", async () => {
// Two different things that must not be conflated, and conflating them broke a whole
// reconnect run: an EMPTY entry is absence — a scope index can carry one, and a caller
// assembling a list from optional values should not have to compact it — while a
// non-reference is a caller mistake worth a loud error. Validating before filtering
// turned the first into the second.
inject({});
await expect(readUnion(["", null as never, undefined as never])).resolves.toEqual([]);
await expect(readUnion(["not-a-nuri"])).rejects.toThrow(/not a NextGraph reference/i);
});
// ── several subjects inside one document ───────────────────────────────────
//
// The fixture above pins every row's subject to the doc NURI, which is what an
// application writing one entity per document produces — the recommended placement,
// and the only case it can exercise. A document may nevertheless carry SEVERAL
// subjects, and upstream that is the provided case, not an accident: the ORM carries
// `@id` and `@graph` as two distinct read-only properties and fabricates an `@id`
// when the writer leaves it empty, precisely so objects sharing a `@graph` stay
// distinguishable. This fixture lets the subject vary so that case can be tested.
function injectTriples(triplesByDoc: Record<string, Array<[string, string, string]>>) {
const ng = {
doc_create: mock(async () => "did:ng:o:new"),
sparql_update: mock(async () => undefined),
sparql_query: mock(async (_sid: string, _query: string, _base: unknown, anchor: unknown) => {
if (anchor === undefined) {
throw new Error("read-model must NEVER run an anchorless (union) query");
}
const triples = triplesByDoc[anchor as string];
if (!triples) return { results: { bindings: [] } };
return {
results: {
bindings: triples.map(([s, p, o]) => ({
s: { value: s },
p: { value: p },
o: { value: o },
})),
},
};
}),
};
resetCaps();
setCurrentUser(null);
configure({ ng, useShape: () => undefined });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
normalizeId: (u: string) => u,
});
return ng;
}
test("two subjects in ONE document come back as two entries, unmixed", async () => {
// The document holds two entities under two IRIs of the consumer's choosing.
injectTriples({
"did:ng:o:d": [
["urn:app:alice", TYPE, `${FP}Person`],
["urn:app:alice", `${FP}title`, "A"],
["urn:app:bob", TYPE, `${FP}Person`],
["urn:app:bob", `${FP}title`, "B"],
],
});
const subjects = await readUnion(["did:ng:o:d"]);
// Under the old per-document fold this was ONE entry subject'd `did:ng:o:d`,
// whose `${FP}title` carried BOTH ["A","B"] — two entities conflated into one bag,
// and both re-labelled with the document's own NURI.
expect(subjects.length).toBe(2);
expect(subjects.map((s) => s.subject).sort()).toEqual(["urn:app:alice", "urn:app:bob"]);
const alice = subjects.find((s) => s.subject === "urn:app:alice")!;
const bob = subjects.find((s) => s.subject === "urn:app:bob")!;
expect(alice.props[`${FP}title`]).toEqual(["A"]);
expect(bob.props[`${FP}title`]).toEqual(["B"]);
// Both were read from the same document, and `graph` is the reference the caller
// passed — that is what identifies the document and what goes back into the SDK.
expect(alice.graph).toBe("did:ng:o:d");
expect(bob.graph).toBe("did:ng:o:d");
});
test("a subject written under an IRI of its own is NOT re-labelled with the doc NURI", async () => {
injectTriples({
"did:ng:o:d": [["urn:app:thing", `${FP}title`, "T"]],
});
const [only] = await readUnion(["did:ng:o:d"]);
// The old fold reported `did:ng:o:d` here, a subject the document never carried.
expect(only!.subject).toBe("urn:app:thing");
expect(only!.graph).toBe("did:ng:o:d");
});
test("the SAME subject IRI in two documents stays two entries, told apart by graph", async () => {
injectTriples({
"did:ng:o:a": [["urn:app:shared", `${FP}title`, "in-a"]],
"did:ng:o:b": [["urn:app:shared", `${FP}title`, "in-b"]],
});
const subjects = await readUnion(["did:ng:o:a", "did:ng:o:b"]);
expect(subjects.length).toBe(2);
expect(subjects.map((s) => s.graph).sort()).toEqual(["did:ng:o:a", "did:ng:o:b"]);
// Never merged across documents: an object is identified by its subject WITHIN a graph.
expect(subjects.find((s) => s.graph === "did:ng:o:a")!.props[`${FP}title`]).toEqual(["in-a"]);
expect(subjects.find((s) => s.graph === "did:ng:o:b")!.props[`${FP}title`]).toEqual(["in-b"]);
});
test("machinery is dropped per subject, and the real subjects beside it survive", async () => {
injectTriples({
"did:ng:o:d": [
["urn:ng-eventually:shim:headerBranch", "urn:ng-eventually:p:inboxAddress", "did:ng:o:inbox"],
["urn:app:entity", `${FP}title`, "kept"],
],
});
const subjects = await readUnion(["did:ng:o:d"]);
expect(subjects.map((s) => s.subject)).toEqual(["urn:app:entity"]);
expect(subjects[0]!.props[`${FP}title`]).toEqual(["kept"]);
});
test("a document holding ONLY machinery yields no entry at all", async () => {
injectTriples({
"did:ng:o:d": [
["urn:ng-eventually:shim:headerBranch", "urn:ng-eventually:p:inboxAddress", "did:ng:o:inbox"],
],
});
expect(await readUnion(["did:ng:o:d"])).toEqual([]);
});
+78
View File
@@ -0,0 +1,78 @@
import { test, expect } from "bun:test";
import { escapeLiteral, escapeIri, assertNuri } from "../src/surface/sparql";
// --- escapeLiteral --------------------------------------------------------
test("escapeLiteral escapes backslash, quote and whitespace controls", () => {
expect(escapeLiteral('a"b')).toBe('a\\"b');
expect(escapeLiteral("a\\b")).toBe("a\\\\b");
expect(escapeLiteral("a\nb")).toBe("a\\nb");
expect(escapeLiteral("a\rb")).toBe("a\\rb");
expect(escapeLiteral("a\tb")).toBe("a\\tb");
});
test("escapeLiteral backslash-then-quote order does not double-escape", () => {
// `\` first so a raw `"` never becomes `\"` before the quote pass mangles it.
expect(escapeLiteral('\\"')).toBe('\\\\\\"');
});
test("escapeLiteral output can no longer close a SPARQL literal", () => {
const injected = '" ; <urn:evil> "pwn';
const escaped = escapeLiteral(injected);
// No RAW double-quote survives — every `"` is preceded by a backslash.
expect(/(^|[^\\])"/.test(escaped)).toBe(false);
});
// --- escapeIri ------------------------------------------------------------
test("escapeIri passes ordinary printable identifier chars through unchanged", () => {
expect(escapeIri("alice")).toBe("alice");
expect(escapeIri("a.b-c_d:e/f")).toBe("a.b-c_d:e/f");
});
test("escapeIri percent-encodes every IRI-breaking character", () => {
expect(escapeIri("a>b")).toBe("a%3Eb");
expect(escapeIri("a<b")).toBe("a%3Cb");
expect(escapeIri('a"b')).toBe("a%22b");
expect(escapeIri("a b")).toBe("a%20b");
expect(escapeIri("a\nb")).toBe("a%0Ab");
expect(escapeIri("a\tb")).toBe("a%09b");
expect(escapeIri("a\\b")).toBe("a%5Cb");
});
test("escapeIri neutralises a full breakout attempt", () => {
const attack = 'x> <urn:evil> "pwn';
const encoded = escapeIri(attack);
// The encoded id cannot contain a raw `>`, `<`, `"` or space, so it
// cannot escape the surrounding <PREFIX:...> IRI.
expect(encoded).not.toMatch(/[<>" ]/);
});
test("escapeIri handles unicode without corrupting it (round-trips via decode)", () => {
const u = "élan";
// "é" is a printable letter → left as-is; a space would be encoded.
expect(escapeIri(u)).toBe("élan");
expect(escapeIri("é ")).toBe("é%20");
});
// --- assertNuri -----------------------------------------------------------
test("assertNuri returns valid NURIs unchanged", () => {
expect(assertNuri("did:ng:o:doc1")).toBe("did:ng:o:doc1");
expect(assertNuri("urn:ng-eventually:shim")).toBe("urn:ng-eventually:shim");
expect(assertNuri("did:ng:PRIV")).toBe("did:ng:PRIV");
});
test("assertNuri throws on empty / non-string", () => {
expect(() => assertNuri("")).toThrow(/invalid NURI/);
// @ts-expect-error deliberately wrong type
expect(() => assertNuri(null)).toThrow(/invalid NURI/);
});
test("assertNuri throws on IRI-breaking characters", () => {
expect(() => assertNuri("did:ng:o> <urn:evil")).toThrow(/IRI-forbidden/);
expect(() => assertNuri('did:ng:"x')).toThrow(/IRI-forbidden/);
expect(() => assertNuri("did:ng: x")).toThrow(/IRI-forbidden/);
expect(() => assertNuri("did:ng:\nx")).toThrow(/IRI-forbidden/);
expect(() => assertNuri("did:ng:\tx")).toThrow(/IRI-forbidden/);
});
@@ -0,0 +1,393 @@
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
import {
ensureAccount,
resolveWriteGraph,
resolveAccount,
listMyEntityDocs,
resolveScopeGraph,
userInbox,
createEntityDoc,
resetRegistryCache,
} from "../src/shared-wallet/account-registry";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import { configure } from "../src/index";
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
// This suite injects a fake `ng` via configure(); bun runs test files in a
// shared process with a single module singleton, and may run this file BEFORE
// docs.test.ts's order-dependent "not configured" guard. Restore the un-
// configured state when we're done so that guard still sees a null config.
afterAll(() => {
resetConfig();
resetStoreRegistry();
});
// NOTE ORDER: the "not configured → throw" case MUST run first — configure*()
// sets module-level singletons and this suite never fully un-injects the real
// `ng` (docs' getConfig has no reset), so we exercise the registry-deps guard.
test("throws a clear error when configureStoreRegistry() was not called", async () => {
resetStoreRegistry();
resetRegistryCache();
await expect(ensureAccount("alice")).rejects.toThrow(
/configureStoreRegistry\(\) must be called before use/,
);
});
// --- A stateful fake `ng` that emulates just enough SPARQL over an in-memory
// quad store: INSERT DATA parsing + the two SELECT shapes the registry issues.
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;
}
function makeFakeNg() {
const quads: Quad[] = [];
let docCounter = 0;
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
// Parses `INSERT DATA { GRAPH <g> { <s> <p> "o"/<o>/;-lists } }`. The literal
// pattern honours backslash-escapes (`\"`, `\\`, `\n`…) so an escaped quote
// inside a value does NOT terminate the literal — this is what proves the
// injection escaping keeps the query well-formed.
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
// TWO shapes coexist here:
// - the shim account write STILL uses `GRAPH <${privateStore}>` — the
// private STORE repo's graph name equals the plain store NURI, so it
// round-trips (login must not regress). Key it by that GRAPH IRI.
// - the per-entity INDEX write has NO explicit GRAPH — the real broker keys
// it by the ANCHORED repo's default graph (repo_graph_name(id, overlay)),
// so key it by the ANCHOR arg (a[2]). An explicit `GRAPH <indexDoc>`
// would target a phantom graph → must NOT round-trip.
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*$/, "");
}
// Subject is the first <...> token in the body.
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
// Predicate/object pairs: `<p> "o"` (escape-aware) or `<p> <o>`; `a <type>`.
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
// Skip the subject token so we don't treat it as a predicate.
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? "urn:ng-eventually:shim:Account"; // `a` → rdf:type-ish
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: `<shim:root> <shim:shimDoc> ?shimDoc` in the store-root graph.
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
.map((q) => ({ shimDoc: { value: q.o } }));
return { results: { bindings } };
}
if (query.includes("<urn:ng-eventually:shim:id>")) {
// Account SELECT, anchored to the doc-shim's default graph (records live in the
// doc-shim now, no GRAPH wrapper). Two shapes: the full scan (`?acc a <Account>`)
// and the TARGETED bounded resolve (`<subj> a <Account>`) — honour the subject.
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 === "urn:ng-eventually:shim:id") rec.id = q.o;
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
if (q.p === "urn:ng-eventually: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 } };
}
// Entity-index SELECT: `<index> <contains> ?e` in the anchor graph.
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
.map((q) => ({ e: { value: q.o } }));
return { results: { bindings } };
});
return { doc_create, 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();
return ng;
}
let fake: ReturnType<typeof makeFakeNg>;
beforeEach(() => {
fake = inject();
});
test("ensureAccount creates 3 scope docs and persists them to the doc-shim", async () => {
const rec = await ensureAccount("Alice");
expect(rec.id).toBe("Alice");
// 4 doc_create: 1 doc-shim (first login, resolveShimDoc) + 3 scope docs.
expect(fake.doc_create).toHaveBeenCalledTimes(4);
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
expect(rec.docProtected).not.toBe(rec.docPublic);
expect(rec.docPrivate).not.toBe(rec.docProtected);
// The pointer (store-root -> doc-shim) was written into the store-root graph.
const pointerWrite = fake.sparql_update.mock.calls.find(
(c) => (c[1] as string).includes("<urn:ng-eventually:shim:shimDoc>"),
);
expect(pointerWrite?.[2]).toBe("did:ng:PRIV");
// The account record was persisted into the doc-shim (a did:ng:o: repo), not the root.
const recordWrite = fake.sparql_update.mock.calls.find(
(c) => (c[1] as string).includes("<urn:ng-eventually:shim:docPublic>"),
);
expect(recordWrite?.[2]).toMatch(/^did:ng:o:doc/);
});
test("ensureAccount is idempotent (case/@-insensitive key), no extra docs", async () => {
const a = await ensureAccount("Alice");
const b = await ensureAccount("@alice");
expect(b).toEqual(a);
expect(fake.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs, not 7
});
test("ensureAccount de-dupes CONCURRENT provisions (anti-fork): one account, 3 docs", async () => {
// The reconnection FORK: several callers (watchShape public+protected, the
// container subs, the owned-events effect) hit ensureAccount(SAME id) BEFORE
// the shim has synced, so each reads 0 rows and independently provisions a new
// set of scope docs — N forks, N×3 docs, duplicate docPublic/docProtected in the
// shim → a fresh reader picks a different canonical doc than the writer wrote to.
// The in-flight de-dup collapses N concurrent provisions into ONE.
const results = await Promise.all([
ensureAccount("Bob"),
ensureAccount("Bob"),
ensureAccount("@bob"),
ensureAccount("BOB"),
ensureAccount("bob"),
]);
// ONE set of 3 scope docs + 1 doc-shim (resolveShimDoc de-dupes concurrent pointer
// resolution too) — 4 total, not 5×3.
expect(fake.doc_create).toHaveBeenCalledTimes(4);
// Every caller got the SAME record (same docs), so writer/reader can never
// disagree on the canonical scope doc.
for (const r of results) expect(r).toEqual(results[0]!);
});
test("resolveWriteGraph returns the per-scope index doc", async () => {
const rec = await ensureAccount("Carol");
expect(await resolveWriteGraph("carol", "protected")).toBe(rec.docProtected);
});
test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to the caller)", async () => {
// Session with all three store ids: private → private store; public+protected
// co-locate on the protected native store (the polyfill's Axis-A placement).
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => ({
sessionId: "sid-2",
privateStoreId: "PRIV",
protectedStoreId: "PROT",
publicStoreId: "PUB",
}),
});
resetRegistryCache();
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
// 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 userInbox("@alice", "protected");
expect(mine).toMatch(/^did:ng:o:doc/);
expect(mine).not.toBe("did:ng:PRIV");
expect(await userInbox("@alice", "protected")).toBe(mine); // stable
expect(await userInbox("@bob", "protected")).not.toBe(mine); // another wallet, another inbox
});
test("resolveScopeGraph falls back to the private store when no protected id is injected", async () => {
// The default SESSION carries only privateStoreId — non-private scopes fall
// back to the private store rather than emitting a broken NURI.
expect(await resolveScopeGraph("protected")).toBe("did:ng:PRIV");
expect(await resolveScopeGraph("public")).toBe("did:ng:PRIV");
});
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 listMyEntityDocs("dave", "public");
expect(pub.sort()).toEqual([e1, e2].sort());
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/);
});
// --- SPARQL injection hardening (F1) --------------------------------------
//
// A malicious id must NOT be able to break out of the literal / IRI it
// lands in and inject arbitrary triples into the shim (the account→doc trust
// root). We inspect the exact SPARQL string the registry hands to sparql_update.
/** The raw INSERT DATA string produced by ensureAccount for `id`. */
async function insertFor(id: string): Promise<string> {
await ensureAccount(id);
const calls = fake.sparql_update.mock.calls;
return calls[calls.length - 1]![1] as string;
}
/** Count RAW (un-escaped) double-quotes — i.e. `"` not preceded by a `\`.
* Strip escaped pairs (`\\`, `\"`, …) first so only delimiter quotes remain. */
function rawQuoteCount(s: string): number {
const withoutEscapes = s.replace(/\\./g, "");
return (withoutEscapes.match(/"/g) ?? []).length;
}
test("injection: id with a quote cannot open extra literals", async () => {
const evil = 'x" ; <urn:evil> "pwn';
const update = await insertFor(evil);
// A well-formed INSERT DATA with 4 predicate literals has exactly 8 raw
// quotes (the delimiters). The injected `"` must have been escaped, so the
// count stays 8 — no extra literal was opened.
expect(rawQuoteCount(update)).toBe(8);
// The escaped id is present as a single literal value — the injected
// `<urn:evil>` survives only as INERT text inside that literal (its
// surrounding quotes are escaped `\"`), never as query syntax.
expect(update).toContain('"x\\" ; <urn:evil> \\"pwn"');
});
test("injection: id with '>' cannot break out of the account-subject IRI", async () => {
const evil = "x> <urn:evil";
const update = await insertFor(evil);
// The account subject is `<urn:ng-eventually:shim:account:...>` — the encoded
// id must NOT contain a raw `>` that would close the IRI early.
const subjMatch = update.match(/<urn:ng-eventually:shim:account:([^>]*)>/)!;
expect(subjMatch).not.toBeNull();
expect(subjMatch[1]).not.toMatch(/[<>" ]/); // fully percent-encoded
expect(subjMatch[1]).toContain("%3E"); // the `>` became %3E
});
test("injection: newline / control chars in id are neutralised", async () => {
const evil = "a\nb\tc";
const update = await insertFor(evil);
// In the literal: escaped to \n / \t (no raw control char).
expect(update).toContain('"a\\nb\\tc"');
// In the IRI subject: percent-encoded.
const subjMatch = update.match(/<urn:ng-eventually:shim:account:([^>]*)>/)!;
expect(subjMatch[1]).toContain("%0A");
expect(subjMatch[1]).toContain("%09");
});
test("injection: '; DELETE'-style payload stays inert inside the literal", async () => {
const evil = 'x"} ; DELETE WHERE { ?s ?p ?o } ; INSERT DATA { <a> <b> "';
const update = await insertFor(evil);
// The whole attack survives, escaped, as ONE literal value — the injected
// `"}` cannot close the literal/graph, so DELETE/second-INSERT stay text.
expect(update).toContain(escapeLiteralRef(evil));
// Quote count stays even (all delimiters balanced; the injected `"` escaped):
// 4 predicate literals → 8 raw delimiter quotes, nothing extra opened.
expect(rawQuoteCount(update)).toBe(8);
// The injected `"}` did not survive as raw syntax (it was escaped to `\"}`).
expect(update).not.toMatch(/[^\\]"} ; DELETE/);
});
// Local mirror of the lib's escapeLiteral so the assertion is self-checking.
function escapeLiteralRef(v: string): string {
return `"${v
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t")}"`;
}
test("injection: a malicious id still round-trips through the shim", async () => {
const evil = 'eve" ; <urn:evil> "x';
const rec = await ensureAccount(evil);
expect(rec.id).toBe(evil);
resetRegistryCache();
// 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 () => {
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 });
resetRegistryCache();
const a = await ensureAccount(" Ivy ");
const b = await ensureAccount("Ivy"); // trimmed key matches
expect(b).toEqual(a);
expect(ng.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs
});
test("a user has TWO inboxes — public and protected — and they are distinct documents", async () => {
// Upstream a site carries an inbox on its public store repo and another on its
// protected one (`engine/verifier/src/site.rs:127-152`), addressed separately down to
// the contact predicates (`ng:site_inbox` vs `ng:protected_inbox`). Exposing one was a
// cardinality this library invented; neither the ORM nor the wasm binding says
// anything about inboxes, so the engine's model is what decides.
resetRegistryCache();
const pub = await userInbox("@dana", "public");
const prot = await userInbox("@dana", "protected");
expect(pub).not.toBe(prot);
// …and each is stable for its own scope.
expect(await userInbox("@dana", "public")).toBe(pub);
expect(await userInbox("@dana", "protected")).toBe(prot);
});
+145
View File
@@ -0,0 +1,145 @@
import { test, expect, mock, afterAll } from "bun:test";
import { subscribeDoc, subscribeDocs } from "../src/surface/subscribe";
import { configure } from "../src/index";
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
// subscribeDoc/subscribeDocs wrap the REAL injected `ng.doc_subscribe`. This
// suite injects a fake `ng` whose `doc_subscribe` records the callback per doc
// and hands back an unsubscribe, so we can assert routing + isolation without a
// broker. Restore the un-configured state at the end.
afterAll(() => {
resetConfig();
resetStoreRegistry();
});
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
/**
* A fake reactive `ng`: `doc_subscribe(nuri, sid, cb)` registers `cb` for `nuri`,
* fires it once (initial State push), and returns an unsubscribe. `push(nuri)`
* drives a later change to that doc's subscribers. A per-doc `failFor` set makes
* `doc_subscribe` reject for chosen NURIs (a not-yet-synced doc).
*/
function makeFakeNg(failFor: Set<string> = new Set()) {
const subs = new Map<string, Set<(r: unknown) => void>>();
const doc_subscribe = mock(async (nuri: string, _sid: unknown, cb: (r: unknown) => void) => {
if (failFor.has(nuri)) throw new Error(`RepoNotFound: ${nuri}`);
let set = subs.get(nuri);
if (!set) {
set = new Set();
subs.set(nuri, set);
}
set.add(cb);
// Initial State push, delivered async (as the real RPC does).
queueMicrotask(() => cb({ V0: { State: { doc: nuri } } }));
return () => set!.delete(cb);
});
const push = (nuri: string): void => {
for (const cb of subs.get(nuri) ?? []) cb({ V0: { Patch: { doc: nuri } } });
};
const isSubscribed = (nuri: string): boolean => (subs.get(nuri)?.size ?? 0) > 0;
return { doc_subscribe, push, isSubscribed, _subs: subs };
}
function inject(failFor?: Set<string>) {
const ng = makeFakeNg(failFor);
configure({ ng: ng as any, useShape: (() => {}) as any });
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
configureStoreRegistry({ getSession: async () => SESSION });
return ng;
}
const A = "did:ng:o:docA";
const B = "did:ng:o:docB";
const tick = () => new Promise((r) => setTimeout(r, 5));
test("subscribeDoc calls ng.doc_subscribe with (nuri, sessionId, callback)", async () => {
const ng = inject();
const onChange = mock(() => {});
subscribeDoc(A, onChange);
await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
const call = ng.doc_subscribe.mock.calls[0]!;
expect(call[0]).toBe(A);
expect(call[1]).toBe("sid-1"); // sessionId from the injected session
expect(typeof call[2]).toBe("function"); // the callback
});
test("subscribeDoc routes the initial State push and every later change", async () => {
const ng = inject();
const seen: unknown[] = [];
subscribeDoc(A, (r) => seen.push(r));
await tick();
expect(seen).toHaveLength(1); // initial State push
ng.push(A);
ng.push(A);
expect(seen).toHaveLength(3); // + 2 patches
});
test("subscribeDoc unsubscribe stops further callbacks", async () => {
const ng = inject();
const seen: unknown[] = [];
const stop = subscribeDoc(A, (r) => seen.push(r));
await tick();
expect(seen).toHaveLength(1);
stop();
expect(ng.isSubscribed(A)).toBe(false); // real unsubscribe was invoked
ng.push(A); // ignored — no subscriber
expect(seen).toHaveLength(1);
});
test("subscribeDoc unsubscribe BEFORE async setup resolves cancels cleanly", async () => {
const ng = inject();
const seen: unknown[] = [];
const stop = subscribeDoc(A, (r) => seen.push(r));
stop(); // before the microtask/promise setup resolved
await tick();
// The subscription was cancelled the moment setup resolved: no callbacks, and
// no lingering subscriber.
expect(seen).toHaveLength(0);
expect(ng.isSubscribed(A)).toBe(false);
});
test("subscribeDocs fans out one subscription per doc and reports the source nuri", async () => {
const ng = inject();
const seen: Array<[string, unknown]> = [];
subscribeDocs([A, B], (nuri, r) => seen.push([nuri, r]));
await tick();
// Two initial pushes, one per doc.
expect(seen.map((s) => s[0]).sort()).toEqual([A, B]);
ng.push(B);
expect(seen.filter((s) => s[0] === B)).toHaveLength(2); // initial + patch
expect(seen.filter((s) => s[0] === A)).toHaveLength(1); // isolated: A didn't fire
});
test("subscribeDocs isolates a failing doc — the others still fire", async () => {
const ng = inject(new Set([A])); // A's subscription throws (RepoNotFound)
const seen: Array<[string, unknown]> = [];
subscribeDocs([A, B], (nuri, r) => seen.push([nuri, r]));
await tick();
// A failed to subscribe (logged, not thrown); B is unaffected and fired.
expect(seen.map((s) => s[0])).toEqual([B]);
ng.push(B);
expect(seen.filter((s) => s[0] === B)).toHaveLength(2);
});
test("subscribeDocs unsubscribe tears down all subscriptions", async () => {
const ng = inject();
const stop = subscribeDocs([A, B], () => {});
await tick();
expect(ng.isSubscribed(A)).toBe(true);
expect(ng.isSubscribed(B)).toBe(true);
stop();
expect(ng.isSubscribed(A)).toBe(false);
expect(ng.isSubscribed(B)).toBe(false);
});
test("subscribeDocs deduplicates repeated NURIs", async () => {
const ng = inject();
subscribeDocs([A, A, A], () => {});
await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
});
+307
View File
@@ -0,0 +1,307 @@
/**
* The published names may only use words the TARGET uses, or a marker that says why
* they exist here.
*
* ── Why this is a test and not a rule ─────────────────────────────────────
* The library corrected its vocabulary on 2026-07-30 — upstream a *wallet* is only a
* keyring, and what owns stores is a **user** (a *site*) — by a manual pass over the
* code and docs. `walletInbox` survived that pass and lived on for weeks, and it did
* damage: the name made "one inbox per wallet" sound obvious, hiding that a user
* upstream has **two** (public store repo and protected store repo — the only two
* `AddInboxCap` commits in the engine, `engine/verifier/src/site.rs:128,149`). A
* discipline applied by hand misses one; a test does not.
*
* So this pins the naming half of the design principle (`README.md`): a name either
* belongs to the target's vocabulary — in which case it needs no translation and
* survives migration — or it carries a marker saying WHY it exists only here, which
* also says when it disappears.
*
* ── What it checks, and what it deliberately does not ─────────────────────
* Only the PUBLISHED names, the ones a consumer application types. Internal names are
* held to the same intent but not mechanically: the folder they live in already states
* their fate, and pinning every internal identifier would fight refactoring for little.
*/
import { test, expect } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
/**
* Words the TARGET itself uses, verified in `nextgraph-rs`. A published name built
* from these needs no translation at migration.
*/
const TARGET_WORDS = new Set([
// addressing and objects
"nuri", "doc", "docs", "document", "repo", "store", "stores", "branch", "graph",
"overlay", "cap", "caps", "read", "write", "link", "links", "shape", "shapes",
// actors and containers
"user", "users", "session", "wallet", "inbox", "inboxes", "site", "principal",
// scopes (upstream store types, `StoreRepo::from_type_and_repo`)
"public", "protected", "private", "group", "dialog", "scope",
// acts the target performs
"create", "subscribe", "unsubscribe", "query", "update", "post", "share", "open",
"fetch", "init", "watch", "sparql", "ng", "orm", "type", "types",
// RDF / SPARQL terms the engine's own query paths use
"subject", "base", "schema", "connected", "identity", "identities",
// `publisher` is upstream's word for a pub/sub role on a topic (`as_publisher`,
// `publisher_advert`, 126 occurrences in the engine). Our own "publish a document" is
// banned as ambiguous, but that ban never reaches upstream's term — see the traps
// block in `docs/readcap-and-nuri-model.md`.
"publisher", "topic", "advert",
// the reactive model the ORM exposes (`OrmSubscription`, `DeepSignalSet`)
"observable", "deep", "signal", "set",
]);
/**
* Markers that name WHY something exists only in this library. Each says when it
* disappears, which a bare `fake`/`tmp` would not.
*/
const EMULATION_MARKERS = new Set([
"virtual", "physical", "shim", "emulated", "polyfill",
// `shared` as in "shared wallet" — the single fact every piece of scaffolding in this
// library descends from. A name carrying it says both what it is and when it goes.
"shared",
]);
/** Glue with no domain meaning — never the load-bearing part of a name. */
const NEUTRAL = new Set([
"get", "set", "is", "has", "to", "for", "of", "my", "own", "all", "by", "with",
"current", "reset", "configure", "config", "deps", "id", "ids", "address", "entity",
"list", "resolve", "assert", "escape", "literal", "iri", "record", "registry",
"change", "changed", "state", "value", "data", "info", "count", "the", "a", "an",
"options", "opts", "result", "error", "signal", "filter", "placement", "and", "or",
"make", "use", "on", "off", "from", "into", "at", "in", "out", "up", "down",
// `union` is OURS — the bounded multi-document read — but it names an operation,
// not a domain notion a consumer would have to unlearn. `eventually` is the
// library's own name.
"union", "eventually", "ensure",
// `…Like` is a structural-typing suffix (`NgLike` = "whatever has ng's shape"), not
// a domain word: it describes how the injection is typed, not what the thing is.
"like",
]);
/** `documentInboxAddress` → ["document","inbox","address"] ; `NG` → ["ng"]. */
function words(name: string): string[] {
return name
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
.split(/[\s_]+/)
.map((w) => w.toLowerCase())
.filter(Boolean);
}
const SRC = path.join(import.meta.dir, "..", "src");
/**
* The names a module really EXPORTS — declarations and named re-exports, comments
* stripped first.
*
* ── Why the comments have to go, and why it is not a detail ────────────────
* This used to be a word-search over the module's whole text. A name mentioned
* ANYWHERE — including in a comment explaining why it was removed — counted as
* exported. Measured on 2026-08-07: `documentInboxAddress`, `escapeLiteral`,
* `getCaps`, `addLink`, `setCurrentUser` all passed, and so did **`linkTo`** — the
* function deleted for breaking the access rule, whose absence is documented in a
* comment inside `surface/placement.ts`. So the two checks below could wave through
* a phantom, which is how the contract drifted through five sections unseen.
*
* A gate that says yes to a name that is not there is worse than no gate: it reads
* as verified.
*/
function moduleExports(file: string): Set<string> {
const out = new Set<string>();
if (!fs.existsSync(file)) return out;
const text = fs
.readFileSync(file, "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "") // block and JSDoc comments
.replace(/^\s*\/\/.*$/gm, ""); // line comments
// `export function x` / `export const x` / `export interface x` / …
for (const m of text.matchAll(/^export (?:declare )?(?:async )?(?:const|function|class|interface|type) (\w+)/gm)) {
out.add(m[1]!);
}
// `export { a, b as c }` and `export { … } from "…"`, single- and multi-line.
for (const m of text.matchAll(/export (?:type )?\{([^}]*)\}/g)) {
for (const raw of m[1]!.split(",")) {
const name = raw.trim().replace(/^type /, "").split(/\s+as\s+/).pop()?.trim();
if (name) out.add(name);
}
}
return out;
}
/** namespace name → the names it really carries (`export * as ns from "./x"`). */
function namespaces(): Map<string, Set<string>> {
const text = fs.readFileSync(path.join(SRC, "index.ts"), "utf8");
const out = new Map<string, Set<string>>();
for (const m of text.matchAll(/export \* as (\w+) from "\.\/([^"]+)"/g)) {
out.set(m[1]!, moduleExports(path.join(SRC, m[2]! + ".ts")));
}
return out;
}
/** Every identifier the entry point publishes, read from its `export` statements. */
function publishedNames(): string[] {
const text = fs.readFileSync(path.join(SRC, "index.ts"), "utf8");
const out = moduleExports(path.join(SRC, "index.ts"));
for (const m of text.matchAll(/export \* as (\w+) from/g)) out.add(m[1]!);
// `export * from "./x"` — re-exports every name that module declares.
for (const m of text.matchAll(/export \* from "\.\/([^"]+)"/g)) {
for (const n of moduleExports(path.join(SRC, m[1]! + ".ts"))) out.add(n);
}
return [...out];
}
/** Names that live inside a re-exported namespace rather than on the entry itself. */
function isNamespaceMember(name: string): boolean {
for (const members of namespaces().values()) if (members.has(name)) return true;
return false;
}
test("every published name is built from the target's vocabulary, or carries an emulation marker", () => {
const offenders: string[] = [];
for (const name of publishedNames()) {
const ws = words(name);
// A marker anywhere in the name licenses the whole name: it declares the thing
// as ours and says when it goes.
if (ws.some((w) => EMULATION_MARKERS.has(w))) continue;
const unknown = ws.filter((w) => !TARGET_WORDS.has(w) && !NEUTRAL.has(w));
if (unknown.length > 0) offenders.push(`${name}${unknown.join(", ")}`);
}
// A failure here is not "rename to satisfy the test": it is a question. Does the
// target have a word for this? Use it. Does the thing exist only here? Say so with a
// marker. Is the word genuinely neutral glue? Add it to NEUTRAL, deliberately.
expect(offenders).toEqual([]);
});
test("no published name says `wallet` where the target says `user`", () => {
// The specific regression that motivated this file. `wallet` is a legitimate target
// word (a keyring IS a wallet upstream), so the generic check above cannot catch it —
// what is wrong is using it for the thing that owns stores and inboxes.
const wrong = publishedNames().filter((n) =>
/wallet/i.test(n) && /(inbox|store|doc|cap)/i.test(n),
);
expect(wrong).toEqual([]);
});
// --- the invariant the internal contract flagged as a migration risk -------
test("a reserved-namespace key cannot be produced by a consumer's normalizeId", async () => {
// The reserved namespace hosts infrastructure accounts, and its guarantee is that no
// user id lands there. That guarantee is not the library's to make — `normalizeId` is
// injected by the consumer — so a careless one must be refused, not trusted. A
// collision would key a user onto an infrastructure account: reads and writes on
// documents that are not theirs.
const { configureStoreRegistry, resetStoreRegistry } = await import("../src/shared-wallet/bootstrap");
const { ensureAccount, resetRegistryCache } = await import(
"../src/shared-wallet/account-registry"
);
configureStoreRegistry({
getSession: async () => ({ sessionId: "s", privateStoreId: "did:ng:o:p" }),
normalizeId: () => "reserved:index", // pretends to be infrastructure
});
resetRegistryCache();
await expect(ensureAccount("mallory")).rejects.toThrow(/reserved namespace/i);
resetStoreRegistry();
resetRegistryCache();
});
// --- the contract's inventory must match the code ------------------------
/**
* Every `export` the contract SHOWS in a "### Today" block must actually be exported.
*
* ── Why this exists beside the appendix check ─────────────────────────────
* The appendix check pins the NAMES. It cannot see the rulings — the per-subject
* sections where each symbol gets its epistemic label — and on 2026-08-07 three of them
* had drifted without anything going red: § 11 documented `escapeLiteral` / `assertNuri`
* as published (they are not), § 12 listed seven `storeRegistry` functions (there are
* five), § 13 listed `IdentityStore` / `getCurrentUser` (removed two days earlier). A
* reader trusting the sections was reading the surface of a fortnight ago.
*
* A stale ruling is worse than a missing one: it reads as verified. So the sections are
* held to the same standard as the appendix — if a block shows `export function X`, X is
* exported. Anything kept for the record goes in a comment, which this check ignores by
* construction (it only looks at lines beginning with `export`).
*/
test("every `export` shown in a contract '### Today' block is actually exported", () => {
const md = fs.readFileSync(
path.join(import.meta.dir, "..", "..", "..", "docs", "api-contract.md"),
"utf8",
);
const published = new Set(publishedNames());
const stale: string[] = [];
// Sections run from a "### Today" heading to the next heading of any level.
for (const m of md.matchAll(/### Today[^\n]*\n([\s\S]*?)(?=\n#{2,3} )/g)) {
for (const block of m[1]!.matchAll(/```ts\n([\s\S]*?)```/g)) {
for (const line of block[1]!.split("\n")) {
const decl = line.match(
/^export (?:declare )?(?:async )?(?:const|function|class|interface|type) (\w+)/,
);
// Namespace members (`inbox.post`, `storeRegistry.createEntityDoc`) are exported
// from their module, not from the entry — the same allowance the appendix makes.
const name = decl?.[1];
if (name && !published.has(name) && !isNamespaceMember(name)) stale.push(name);
}
}
}
// A failure is a question, not a rename: has the symbol been removed (then say so in a
// comment and rule on why), or has the entry lost something it should still publish?
expect([...new Set(stale)]).toEqual([]);
});
/**
* Read the appendix of `docs/api-contract.md` back into `{ group: names }`.
* The appendix is a generated block; this parses the same shape.
*/
function contractInventory(): Record<string, string[]> {
const md = fs.readFileSync(
path.join(import.meta.dir, "..", "..", "..", "docs", "api-contract.md"),
"utf8",
);
const appendix = md.slice(md.indexOf("## Appendix — full export inventory"));
const out: Record<string, string[]> = {};
for (const block of appendix.matchAll(/```text\n([\s\S]*?)```/g)) {
for (const line of block[1]!.trim().split("\n")) {
const i = line.indexOf(":");
if (i < 0) continue;
const group = line.slice(0, i).trim();
const names = line.slice(i + 1).split(",").map((n) => n.trim()).filter(Boolean);
out[group] = [...(out[group] ?? []), ...names].sort();
}
}
return out;
}
test("the appendix's namespace groups list exactly what each namespace carries", () => {
// The check above compares the appendix against the ENTRY's own names, so a namespace
// member missing from its group slipped through: `inbox:` could lose an entry and
// nothing noticed. Here each group is compared to its module's real exports, both ways.
const inventory = contractInventory();
const drift: Record<string, { missing: string[]; extra: string[] }> = {};
for (const [ns, members] of namespaces()) {
const listed = new Set(inventory[ns] ?? []);
const missing = [...members].filter((n) => !listed.has(n)).sort();
const extra = [...listed].filter((n) => !members.has(n)).sort();
if (missing.length || extra.length) drift[ns] = { missing, extra };
}
expect(drift).toEqual({});
});
test("the api-contract appendix lists exactly what the entry exports", () => {
// The appendix is the instrument a reader diffs against when the surface moves. It
// went stale once — still naming `storeRegistry`'s shim internals after the entry had
// been narrowed to seven functions — and a stale inventory is worse than none: it
// reads as verified. So the code decides, and this test is what makes the document
// follow rather than drift.
const inventory = contractInventory();
const direct = new Set(publishedNames());
// The namespace names themselves are the appendix's group headings, not entries.
for (const group of Object.keys(inventory)) if (group !== "direct") direct.delete(group);
const missing = [...direct].filter(
(n) => !Object.values(inventory).some((names) => names.includes(n)),
);
const extra = Object.values(inventory)
.flat()
.filter((n) => !direct.has(n) && !isNamespaceMember(n));
expect({ missing, extra }).toEqual({ missing: [], extra: [] });
});
+385
View File
@@ -0,0 +1,385 @@
/**
* watch-shape.test.ts — behavioural tests for `watchShape` (src/watch-shape.ts),
* against a STATEFUL fake `ng` with a CONTROLLABLE `doc_subscribe`.
*
* The fake emulates just enough of the broker:
* - `doc_create` mints monotonic doc NURIs.
* - `sparql_update` parses the shim account writes + the per-entity index
* `contains` append + arbitrary anchored triple writes into an in-memory quad
* store (same tolerant parser shape as store-registry.test / read-model.test).
* - `sparql_query` answers the shim account SELECT, the scope-index `contains`
* SELECT, and the anchored per-doc `?s ?p ?o` read (readUnion) — each scoped to
* the anchor graph.
* - `doc_subscribe` models the platform push order TabInfo→State: on subscribe it
* records the callback and fires a `TabInfo` immediately, but the sync BARRIER
* `State` is fired only when the TEST releases it (`fireState`) — so we can
* assert isPending BEFORE the barrier and isSuccess AFTER. A later write to a
* subscribed doc fires a `Patch` push (reactivity).
*
* These prove the four distinctions the surface exists for:
* (a) isPending at first, isSuccess after the first State (barrier);
* (b) isSuccess + data:[] on a synced-but-EMPTY scope (the key distinction);
* (c) a write then push → data updates (reactivity, no polling);
* (d) timed-out → isSuccess (best-effort), NOT isError.
*/
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test";
import { watchShape } from "../src/surface/watch-shape";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetRegistryCache, createEntityDoc } from "../src/shared-wallet/account-registry";
import { resetOpenedRepos, setOpenTimeoutForTests, getSyncState } from "../src/emulated-verifier/open-repo";
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
const FP = "http://festipod.org/";
const SESSION = { sessionId: "sid-ws", privateStoreId: "PRIV-WS" };
interface Quad { g: string; s: string; p: string; o: string }
/** Reverse of 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;
}
interface SubRec { nuri: string; cb: (r: unknown) => void }
/**
* The stateful fake with a controllable doc_subscribe. `holdState: true` means a
* fresh subscription does NOT auto-fire its `State` — the test fires it via
* `fireState(nuri)`. `holdState: false` (default) auto-fires `State` on subscribe
* (synced immediately), which is the convenient mode for the reactivity/empty cases.
*/
function makeFake(opts?: { holdState?: boolean }) {
const quads: Quad[] = [];
let docCounter = 0;
const subs: SubRec[] = [];
const hold = opts?.holdState ?? false;
// Nuris whose barrier `State` has been released (auto-fire on future subscribe).
const released = new Set<string>();
// The shim ANCHOR (private-store-root) is ALWAYS loaded/synced on the real broker
// (the store repo is bootstrapped at connect), so its barrier `State` is always
// available. `resolveAccount`/`ensureAccount` now open it (the cold-start heal)
// before touching the shim — pre-release it here so `holdState` (which gates the
// per-ENTITY docs the tests control) never blocks the anchor open. This mirrors the
// real invariant the production heal relies on.
released.add(`did:ng:${SESSION.privateStoreId}`);
let releaseEverything = false;
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] ?? "urn:ng-eventually:shim:Account";
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g, s, p, o });
// The doc-shim (named by the write-once pointer triple) is INFRASTRUCTURE, like
// the store-root: `doc_create` bootstrapped it into the session, so its barrier
// `State` is immediately available. Pre-release it so `holdState` (which gates the
// per-ENTITY docs the tests control) never blocks the doc-shim open. The pointer is
// published BEFORE the doc-shim barrier open (resolveShimDoc first-login order).
if (p === "urn:ng-eventually:shim:shimDoc") {
released.add(o);
for (const sub of subs) if (sub.nuri === o) sub.cb({ V0: { State: {} } });
}
}
// A write to a subscribed doc fires a Patch push (reactivity signal).
for (const sub of subs) {
if (sub.nuri === g) sub.cb({ V0: { Patch: {} } });
}
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("<urn:ng-eventually:shim:shimDoc>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
.map((q) => ({ shimDoc: { value: q.o } }));
return { results: { bindings } };
}
if (query.includes("<urn:ng-eventually: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 === "urn:ng-eventually:shim:id") rec.id = q.o;
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
if (q.p === "urn:ng-eventually: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 } };
}
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")
.map((q) => ({ e: { value: q.o } }));
return { results: { bindings } };
}
// Anchored per-doc read (readUnion `SELECT ?s ?p ?o`).
const bindings = quads
.filter((q) => q.g === anchor)
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } }));
return { results: { bindings } };
});
const doc_subscribe = mock(
async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
subs.push({ nuri, cb });
// Platform pushes TabInfo FIRST (never the barrier).
setTimeout(() => cb({ V0: { TabInfo: {} } }), 0);
// Fire the barrier State if this fake auto-syncs, or if this nuri was already
// released (so a doc subscribed AFTER a release still crosses the barrier).
if (!hold || releaseEverything || released.has(nuri)) {
setTimeout(() => cb({ V0: { State: {} } }), 0);
}
return () => {};
},
);
/** Release the barrier for `nuri` (fire State now + auto-fire for future subs). */
function fireState(nuri: string): void {
released.add(nuri);
for (const sub of subs) if (sub.nuri === nuri) sub.cb({ V0: { State: {} } });
}
/** Release the barrier for EVERY doc, present and future. */
function releaseAll(): void {
releaseEverything = true;
for (const sub of subs) sub.cb({ V0: { State: {} } });
}
return {
doc_create,
sparql_update,
sparql_query,
doc_subscribe,
_quads: quads,
fireState,
releaseAll,
subs,
};
}
function inject(ng: ReturnType<typeof makeFake>) {
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
});
resetRegistryCache();
resetOpenedRepos();
resetCaps();
}
// Insert a triple straight into a doc's graph in the fake store (no push).
function seed(ng: ReturnType<typeof makeFake>, doc: string, p: string, o: string): void {
ng._quads.push({ g: doc, s: doc, p, o });
}
const tick = () => new Promise((r) => setTimeout(r, 5));
// A minimal SHEX ShapeType pinning rdf:type to `${FP}Event`.
const EventShape = {
shape: `${FP}EventShape`,
schema: {
[`${FP}EventShape`]: {
iri: `${FP}EventShape`,
predicates: [{ iri: TYPE, dataTypes: [{ literals: [`${FP}Event`], valType: "iri" }] }],
},
},
};
afterEach(() => {
setCurrentUser(null);
});
afterAll(() => {
resetConfig();
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", () => {
it("(a) isPending at first, then isSuccess after the first State (barrier)", async () => {
const ng = makeFake({ holdState: true });
inject(ng);
setCurrentUser("alice");
// One protected entity doc for alice, carrying an Event triple.
const doc = await createEntityDoc("alice", "protected");
seed(ng, doc, TYPE, `${FP}Event`);
seed(ng, doc, `${FP}title`, "Alpha");
const obs = watchShape(EventShape, "protected");
let notes = 0;
const unsub = obs.subscribe(() => {
notes += 1;
});
// Before the barrier: pending, no data.
await tick();
expect(obs.getSnapshot().isPending).toBe(true);
expect(obs.getSnapshot().isSuccess).toBe(false);
expect(obs.getSnapshot().data).toEqual([]);
// Release the barrier for every opened doc (present + future) → synced.
ng.releaseAll();
await tick();
await tick();
await tick();
const snap = obs.getSnapshot();
expect(snap.isPending).toBe(false);
expect(snap.isSuccess).toBe(true);
expect(snap.isError).toBe(false);
expect(snap.data.length).toBe(1);
expect(snap.data[0]!.props[`${FP}title`]).toEqual(["Alpha"]);
expect(notes).toBeGreaterThan(0);
unsub();
});
it("(b) isSuccess + data:[] on a synced-but-EMPTY scope (the key distinction)", async () => {
const ng = makeFake(); // auto-fires State → synced immediately
inject(ng);
setCurrentUser("bob");
// bob has NO entity docs in this scope — the scope is genuinely empty.
const obs = watchShape(EventShape, "protected");
const unsub = obs.subscribe(() => {});
await tick();
await tick();
const snap = obs.getSnapshot();
expect(snap.isPending).toBe(false);
expect(snap.isSuccess).toBe(true); // synced, NOT stuck pending
expect(snap.isError).toBe(false);
expect(snap.data).toEqual([]); // empty — distinguishable from "still syncing"
unsub();
});
it("(c) a write then push updates data (reactivity, no polling)", async () => {
const ng = makeFake(); // synced immediately
inject(ng);
setCurrentUser("carol");
const doc = await createEntityDoc("carol", "protected");
seed(ng, doc, TYPE, `${FP}Event`);
seed(ng, doc, `${FP}title`, "One");
const obs = watchShape(EventShape, "protected");
const unsub = obs.subscribe(() => {});
await tick();
await tick();
expect(obs.getSnapshot().data.length).toBe(1);
// Write a SECOND event doc + fire the push via a write to the ALREADY-subscribed
// doc. Because a new doc must appear in the set, write into the scope-INDEX
// (createEntityDoc appends to it, and the index is subscribed → re-resolve).
const doc2 = await createEntityDoc("carol", "protected");
seed(ng, doc2, TYPE, `${FP}Event`);
seed(ng, doc2, `${FP}title`, "Two");
// createEntityDoc's index append fired a Patch on the index doc → re-resolve.
await tick();
await tick();
const titles = obs
.getSnapshot()
.data.flatMap((s) => s.props[`${FP}title`] ?? [])
.sort();
expect(titles).toEqual(["One", "Two"]);
// No setInterval anywhere — reactivity was push-driven.
unsub();
});
it("(d) timed-out → isSuccess (best-effort), NOT isError", async () => {
// A doc whose subscription NEVER pushes a `State`: open-repo's bounded fallback
// fires and marks the nuri "timed-out" (NOT "synced"). We shrink the fallback to
// a few ms so this is fast, and assert the barrier is genuinely reached via
// timed-out (getSyncState === "timed-out") and that the snapshot maps that to
// isSuccess, never isError.
const ng = makeFake({ holdState: true }); // State is never released
inject(ng);
setOpenTimeoutForTests(20); // fallback fires quickly instead of after 8s
setCurrentUser("dave");
const doc = await createEntityDoc("dave", "protected");
seed(ng, doc, TYPE, `${FP}Event`);
seed(ng, doc, `${FP}title`, "Timed");
const obs = watchShape(EventShape, "protected");
const unsub = obs.subscribe(() => {});
await tick();
// Before the fallback fires: still pending (subscribed, no State).
expect(obs.getSnapshot().isPending).toBe(true);
// Let the bounded fallback elapse → open-repo marks each opened doc timed-out.
await new Promise((r) => setTimeout(r, 60));
await tick();
await tick();
// The entity doc's barrier resolved via timed-out (never a State).
expect(getSyncState(doc)).toBe("timed-out");
const snap = obs.getSnapshot();
expect(snap.isError).toBe(false);
expect(snap.isSuccess).toBe(true); // timed-out is best-effort success
expect(snap.isPending).toBe(false);
// The data still read (best-effort): the doc's triples resolved.
expect(snap.data.length).toBe(1);
unsub();
});
});