refactor: renommer client → sdk, et fusionner les deux portes en une
Deux mouvements de surface, aucun changement de comportement. **`packages/client` → `packages/sdk`, `@ng-eventually/client` → `@ng-eventually/sdk`.** « client » ne disait rien : ce paquet EST le SDK que l'application appelle, et c'est tout ce qu'elle appelle. L'ancien nom reste comme mot-clé de recherche dans `docs/source-layout-by-fate.md` et le tableau des paquets du README. **Une seule entrée.** L'entrée `./polyfill` disparaît ; ses symboles applicatifs — `configure`, `configureStoreRegistry`, `setCurrentUser`, `connectedUser` et leurs types — vivent dans un bloc `POLYFILL-ERA` de `src/index.ts`. Ce que la seconde porte portait mérite d'être nommé avant d'être retiré : *ce qu'on importe de ce chemin est exactement ce qu'on supprimera à la migration*. Une seule porte perd ce signal — rien à la ligne d'import ne distingue `configure`, qui part, de `docs`, que le vrai SDK remplace sur place. Trois choses le portent désormais : le bloc lui-même, l'inventaire d'exports de `docs/api-contract.md` (épinglé par `test/vocabulary.test.ts`, donc il ne peut pas rancir en silence), et le contrôle de vocabulaire sur les noms publiés. **Six symboles quittent la surface au passage**, et la fusion est ce qui a rendu le choix visible plutôt qu'hérité : - `getConfig` / `getStoreRegistryDeps` — câblage interne, atteint par `shared-wallet/bootstrap` ; - `resetConfig` / `resetStoreRegistry` / `resetCaps` — remises à zéro de test, atteintes par leur chemin interne, ce qui est leur raison d'être ; - le `share` direct — `inbox.share` a toujours été la même fonction, et la publier deux fois brouillait la frontière qu'elle servait à marquer. Corrections d'affirmations fausses trouvées en chemin : le contrat annonçait `isNuri` / `hasReadCap` sur la porte SDK alors qu'ils ne sont plus exportés depuis le passage au permissif en entrée (`NuriLike` validé à la porte) ; le README du paquet documentait `capFor`, `shareCap`, `getCaps` et `publishRepoLink`, dont aucun n'existe ; et le README de l'app d'exemple affirmait que la suite e2e la pilote, ce qui reste à faire. 179 tests unitaires, typecheck bibliothèque / exemple / harnais, e2e 42/42 contre le broker en ligne — mesuré une fois après le renommage, une fois après la fusion.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 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, configureStoreRegistry, setCurrentUser } from "../src/index";
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* 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, configureStoreRegistry, connectedUser, setCurrentUser } from "../src/index";
|
||||
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
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* 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, configureStoreRegistry } from "../src/index";
|
||||
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";
|
||||
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-a",
|
||||
];
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* caps.test.ts — the cap surface as KEY POSSESSION.
|
||||
*
|
||||
* What these prove is a SHAPE, not a protection (the library is deliberately
|
||||
* insecure until P1b): the only question the registry can answer is "do I hold
|
||||
* this document's cap?", there is no principal to look up in a list, and no
|
||||
* function turns a bare reference into a cap.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import { CapRegistry, mintCap } from "../src/emulated-verifier/caps";
|
||||
import { hasReadCap, targetOf } from "../src/model/nuri";
|
||||
import type { ReadCap } from "../src/model/types";
|
||||
|
||||
/** A registry whose holder the test drives. */
|
||||
function registry(initial: string | null = "alice") {
|
||||
let holder = initial;
|
||||
const caps = new CapRegistry(() => holder);
|
||||
return { caps, become: (id: string | null) => (holder = id) };
|
||||
}
|
||||
|
||||
test("a cap NAMES and READS; the bare reference only names", () => {
|
||||
const { caps } = registry();
|
||||
const doc = "did:ng:o:doc1:v:overlay";
|
||||
|
||||
// Before anything: naming a document tells you nothing about reading it.
|
||||
expect(caps.capFor(doc)).toBeUndefined();
|
||||
|
||||
const cap = caps.mint(doc);
|
||||
expect(hasReadCap(cap)).toBe(true); // carries `:r:`
|
||||
expect(hasReadCap(doc)).toBe(false);
|
||||
expect(targetOf(cap)).toBe(doc); // same object, key inside
|
||||
expect(caps.capFor(doc)).toBe(cap);
|
||||
// Looking the cap up by the cap-bearing form resolves the same document.
|
||||
expect(caps.capFor(cap)).toBe(cap);
|
||||
});
|
||||
|
||||
test("no cap is derivable from a bare reference — you look it up or you were given it", () => {
|
||||
const { caps } = registry();
|
||||
caps.mint("did:ng:o:mine");
|
||||
// A document that never entered the held caps stays unreadable, however well-formed
|
||||
// its reference is. There is no `grantRead`, and no principal to name.
|
||||
expect(caps.capFor("did:ng:o:someone-else")).toBeUndefined();
|
||||
});
|
||||
|
||||
// Passing the naming form where the reading form is meant is now a COMPILE error
|
||||
// (`ReadCap` is a template literal type). The runtime refusal still has to hold,
|
||||
// because a JavaScript consumer — or a cap read back from storage, a URL or JSON
|
||||
// and cast rather than narrowed — never meets the compiler. The `as` below is
|
||||
// exactly that consumer: it is how the mistake reaches the library at all.
|
||||
// Unchecked, it would file a bare reference as its own cap and make the document
|
||||
// read — the exact inversion this batch removes.
|
||||
test("learn REFUSES a bare reference, even when the compiler was bypassed", () => {
|
||||
const { caps } = registry();
|
||||
const bare = "did:ng:o:someone-elses-doc" as ReadCap; // a JS consumer / an unchecked cast
|
||||
expect(() => caps.learn(bare)).toThrow(/naming is not reading|bare reference/i);
|
||||
expect(caps.capFor("did:ng:o:someone-elses-doc")).toBeUndefined(); // nothing was filed
|
||||
expect(caps.isEnforcing()).toBe(false); // and nothing was issued
|
||||
});
|
||||
|
||||
test("holding one document's cap grants nothing on another (no inheritance)", () => {
|
||||
const { caps } = registry();
|
||||
caps.mint("did:ng:o:doc1");
|
||||
expect(caps.capFor("did:ng:o:doc1")).toBeDefined();
|
||||
expect(caps.capFor("did:ng:o:doc2")).toBeUndefined(); // separate repo, separate cap
|
||||
});
|
||||
|
||||
test("one set of held caps PER holder: switching identity switches heldByHolder, it does not wipe", () => {
|
||||
const { caps, become } = registry("alice");
|
||||
const doc = "did:ng:o:alice-doc";
|
||||
const cap = caps.mint(doc);
|
||||
|
||||
become("bob");
|
||||
expect(caps.capFor(doc)).toBeUndefined(); // bob holds nothing of alice's
|
||||
|
||||
become("alice");
|
||||
expect(caps.capFor(doc)).toBe(cap); // …and alice did not lose hers
|
||||
});
|
||||
|
||||
test("a cap received (learn) reads, exactly like one minted", () => {
|
||||
const alice = registry("alice");
|
||||
const doc = "did:ng:o:shared";
|
||||
const cap = alice.caps.mint(doc);
|
||||
|
||||
const bob = registry("bob");
|
||||
expect(bob.caps.capFor(doc)).toBeUndefined();
|
||||
bob.caps.learn(cap); // delivered to bob's inbox, absorbed
|
||||
expect(bob.caps.capFor(doc)).toBe(cap);
|
||||
});
|
||||
|
||||
// A public store SERVES its documents' caps (`emulated-verifier/public-store.ts`).
|
||||
// This registry is one level below that: it records WHERE a document sits, and it
|
||||
// files a served cap apart from one that was minted or deposited — because the two
|
||||
// grant different things.
|
||||
test("markInPublicStore records where a document sits, and mints nothing", () => {
|
||||
const { caps, become } = registry("alice");
|
||||
const doc = "did:ng:o:public-doc";
|
||||
caps.markInPublicStore(doc);
|
||||
|
||||
expect(caps.isInPublicStore(doc)).toBe(true);
|
||||
expect(caps.isInPublicStore("did:ng:o:other")).toBe(false);
|
||||
// Marking is not holding: the fact is about the document, the cap is about a holder.
|
||||
expect(caps.capFor(doc)).toBeUndefined();
|
||||
become("bob");
|
||||
expect(caps.capFor(doc)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a cap SERVED by a public store reads, and is refused a write", () => {
|
||||
const { caps, become } = registry("alice");
|
||||
const doc = "did:ng:o:public-doc";
|
||||
const served = mintCap(doc);
|
||||
|
||||
become("bob");
|
||||
caps.learnFromPublicStore(served);
|
||||
expect(caps.capFor(doc)).toBe(served); // he reads it, like any held cap
|
||||
expect(caps.isReadOnlyPublicCap(doc)).toBe(true); // …and only that
|
||||
|
||||
// A stronger claim supersedes it: a cap DEPOSITED for me is not the network's copy.
|
||||
caps.learn(served);
|
||||
expect(caps.isReadOnlyPublicCap(doc)).toBe(false);
|
||||
});
|
||||
|
||||
test("the owner of a public document is never read-only on it", () => {
|
||||
const { caps, become } = registry("alice");
|
||||
const doc = "did:ng:o:mine";
|
||||
caps.open(doc, "public"); // alice created it
|
||||
|
||||
// A third party fetching the same document must not affect her claim on it.
|
||||
become("bob");
|
||||
caps.learnFromPublicStore(mintCap(doc));
|
||||
expect(caps.isReadOnlyPublicCap(doc)).toBe(true);
|
||||
become("alice");
|
||||
expect(caps.isReadOnlyPublicCap(doc)).toBe(false);
|
||||
});
|
||||
|
||||
test("open(): a public document is marked as sitting in a public store, a private one is not", () => {
|
||||
const { caps } = registry();
|
||||
const pub = caps.open("did:ng:o:pub", "public");
|
||||
const prot = caps.open("did:ng:o:prot", "protected");
|
||||
const priv = caps.open("did:ng:o:priv", "private");
|
||||
|
||||
expect(caps.isInPublicStore("did:ng:o:pub")).toBe(true);
|
||||
expect(caps.isInPublicStore("did:ng:o:prot")).toBe(false);
|
||||
expect(caps.isInPublicStore("did:ng:o:priv")).toBe(false);
|
||||
// All three are readable BY THEIR OWNER — a creator is never locked out.
|
||||
for (const [doc, cap] of [["did:ng:o:pub", pub], ["did:ng:o:prot", prot], ["did:ng:o:priv", priv]] as const) {
|
||||
expect(caps.capFor(doc)).toBe(cap);
|
||||
}
|
||||
});
|
||||
|
||||
test("open() is idempotent — re-listing my own documents refiles the same caps", () => {
|
||||
const { caps } = registry();
|
||||
const first = caps.open("did:ng:o:doc", "protected");
|
||||
let fired = 0;
|
||||
caps.onChange(() => (fired += 1));
|
||||
expect(caps.open("did:ng:o:doc", "protected")).toBe(first);
|
||||
expect(fired).toBe(0); // nothing changed → no spurious re-read
|
||||
});
|
||||
|
||||
test("isEnforcing is false until the first cap exists, then holds for every holder", () => {
|
||||
const { caps, become } = registry("alice");
|
||||
expect(caps.isEnforcing()).toBe(false);
|
||||
caps.mint("did:ng:o:doc1");
|
||||
expect(caps.isEnforcing()).toBe(true);
|
||||
// …including for a holder whose own holds nothing: that IS the isolation.
|
||||
become("bob");
|
||||
expect(caps.isEnforcing()).toBe(true);
|
||||
expect(caps.capFor("did:ng:o:doc1")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a cap arriving fires the change signal — an asynchronous delivery must re-trigger reads", () => {
|
||||
const { caps } = registry();
|
||||
let fired = 0;
|
||||
const unsub = caps.onChange(() => (fired += 1));
|
||||
|
||||
caps.learn(caps.mint("did:ng:o:doc1")); // mint fires once; the learn is a no-op
|
||||
expect(fired).toBe(1);
|
||||
|
||||
unsub();
|
||||
caps.mint("did:ng:o:doc2");
|
||||
expect(fired).toBe(1); // unsubscribed
|
||||
});
|
||||
|
||||
test("write is restricted to write-cap holders (decorative until P1b)", () => {
|
||||
const { caps } = registry();
|
||||
expect(caps.hasWritePolicy()).toBe(false);
|
||||
caps.grantWrite("did:ng:o:doc", "alice");
|
||||
expect(caps.hasWritePolicy()).toBe(true);
|
||||
expect(caps.governsWrite("did:ng:o:doc")).toBe(true);
|
||||
expect(caps.governsWrite("did:ng:o:unknown")).toBe(false); // not declared → not enforced
|
||||
expect(caps.canWrite("did:ng:o:doc", "alice")).toBe(true);
|
||||
expect(caps.canWrite("did:ng:o:doc", "bob")).toBe(false);
|
||||
expect(caps.canWrite("did:ng:o:doc", null)).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 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, configureStoreRegistry, setCurrentUser } from "../src/index";
|
||||
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
|
||||
import { resetInfrastructure } from "../src/emulated-verifier/reach";
|
||||
|
||||
const SESSION = { sessionId: "sid-cold", privateStoreId: "PRIV-COLD" };
|
||||
const ANCHOR = `did:ng:${SESSION.privateStoreId}`;
|
||||
|
||||
afterAll(() => {
|
||||
resetConfig();
|
||||
resetStoreRegistry();
|
||||
resetRegistryCache();
|
||||
resetOpenedRepos();
|
||||
resetCaps();
|
||||
resetInfrastructure();
|
||||
setCurrentUser(null);
|
||||
});
|
||||
|
||||
// The reach guard is process-wide and so is the cap registry: once ANY cap exists
|
||||
// the boundary applies to every reader. A suite that declares none must therefore
|
||||
// start from an empty one, or it inherits another suite's enforcement.
|
||||
beforeEach(() => {
|
||||
resetRegistryCache();
|
||||
resetOpenedRepos();
|
||||
resetCaps();
|
||||
resetInfrastructure();
|
||||
setCurrentUser(null);
|
||||
});
|
||||
|
||||
interface Quad { g: string; s: string; p: string; o: string }
|
||||
|
||||
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,535 @@
|
||||
/**
|
||||
* 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,
|
||||
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, configureStoreRegistry, connectedUser, setCurrentUser } from "../src/index";
|
||||
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;
|
||||
}
|
||||
const body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
||||
const sm = body.match(/<([^>]+)>/);
|
||||
if (!sm) return undefined;
|
||||
const s = sm[1]!;
|
||||
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
||||
while ((m = pairRe.exec(after)) !== null) {
|
||||
const p = m[1] ?? (query.includes(`${INBOX}:Deposit`) ? `${INBOX}:Deposit` : `${SHIM}:Account`);
|
||||
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
||||
quads.push({ g: anchor, s, p, o });
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const sparql_query = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[3] as string | undefined;
|
||||
if (query.includes(`<${SHIM}:shimDoc>`)) {
|
||||
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`).map((q) => ({ shimDoc: { value: q.o } })) } };
|
||||
}
|
||||
if (query.includes(`<${SHIM}:id>`)) {
|
||||
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
||||
const only = subjM ? subjM[1]! : null;
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
if (q.g !== anchor) continue;
|
||||
if (only !== null && q.s !== only) continue;
|
||||
const rec = bySubject.get(q.s) ?? {};
|
||||
if (q.p === `${SHIM}:id`) rec.id = q.o;
|
||||
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
|
||||
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
|
||||
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
|
||||
bySubject.set(q.s, rec);
|
||||
}
|
||||
return {
|
||||
results: {
|
||||
bindings: [...bySubject.values()].filter((r) => r.id).map((r) => ({
|
||||
id: { value: r.id! },
|
||||
docPublic: { value: r.docPublic ?? "" },
|
||||
docProtected: { value: r.docProtected ?? "" },
|
||||
docPrivate: { value: r.docPrivate ?? "" },
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (query.includes(`<${INBOX}:payload>`)) {
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
if (q.g !== anchor) continue;
|
||||
const rec = bySubject.get(q.s) ?? {};
|
||||
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
|
||||
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
|
||||
if (q.p === `${INBOX}:from`) rec.from = q.o;
|
||||
bySubject.set(q.s, rec);
|
||||
}
|
||||
return {
|
||||
results: {
|
||||
bindings: [...bySubject.values()]
|
||||
.filter((r) => r.payload !== undefined && r.ts !== undefined)
|
||||
.map((r) => {
|
||||
const row: Record<string, { value: string }> = { payload: { value: r.payload! }, ts: { value: r.ts! } };
|
||||
if (r.from !== undefined) row.from = { value: r.from };
|
||||
return row;
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
// User-branch `link` SELECT (the emulated AddLink records).
|
||||
// User-branch `inboxCap` SELECT (the emulated AddInboxCap records).
|
||||
if (query.includes(`<${SHIM}:inboxCap>`)) {
|
||||
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxCap`).map((q) => ({ c: { value: q.o } })) } };
|
||||
}
|
||||
// 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 } })) } };
|
||||
}
|
||||
// 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);
|
||||
});
|
||||
|
||||
// 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.
|
||||
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.
|
||||
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.
|
||||
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
|
||||
|
||||
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]);
|
||||
});
|
||||
|
||||
test("connecting drains BOTH levels: the user's inbox and its documents'", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const protDoc = await createEntityDoc("alice", "protected");
|
||||
const pubDoc = await createEntityDoc("alice", "public");
|
||||
const docInbox = await openDocumentInbox(pubDoc);
|
||||
const aliceInbox = await userInbox("alice", "protected");
|
||||
|
||||
// Two deposits, one at each level, both made by someone else.
|
||||
setCurrentUser("carol");
|
||||
const carolDoc = await createEntityDoc("carol", "protected");
|
||||
await share(carolDoc, "alice"); // a Link, to alice herself
|
||||
await post(docInbox, { payload: { onTheDocument: true }, ts: 2 });
|
||||
|
||||
// Alice connects: one call, both queues.
|
||||
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.
|
||||
test("a third party resolves another user's inbox (the wallet level)", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const aliceView = await userInbox("alice", "protected");
|
||||
setCurrentUser("bob");
|
||||
const bobView = await userInbox("alice", "protected");
|
||||
expect(bobView).toBe(aliceView);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
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, setCurrentUser } from "../src/index";
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
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, configureStoreRegistry, setCurrentUser } from "../src/index";
|
||||
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 } } });
|
||||
};
|
||||
|
||||
const doc_create = mock(async (..._a: unknown[]) => "did:ng:o:new");
|
||||
|
||||
// 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 anchor = a[3] as string | undefined;
|
||||
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]);
|
||||
});
|
||||
|
||||
test("from is optional — omitting it defaults to the current user", async () => {
|
||||
setCurrentUser("bob");
|
||||
await post(TARGET, { payload: { hi: 1 }, ts: 200 });
|
||||
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 });
|
||||
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 () => {
|
||||
await post(TARGET, { from: null, payload: "mine", ts: 1 });
|
||||
await post("did:ng:o:other-inbox", { 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,418 @@
|
||||
/**
|
||||
* 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, configureStoreRegistry, setCurrentUser } from "../src/index";
|
||||
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 } };
|
||||
}
|
||||
// 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
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { getCaps } from "../src/shared-wallet/bootstrap";
|
||||
import { test, expect, mock, afterEach } from "bun:test";
|
||||
import { makeNg } from "../src/surface/ng-proxy";
|
||||
import { configure, setCurrentUser } from "../src/index";
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 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, configureStoreRegistry, setCurrentUser } from "../src/index";
|
||||
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
|
||||
import { resetInfrastructure } from "../src/emulated-verifier/reach";
|
||||
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();
|
||||
resetInfrastructure();
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* public-store.test.ts — the emulated *"downloaded from the outerOverlay"*, in isolation.
|
||||
*
|
||||
* `cross-user-access.test.ts` proves the consequence end to end (Bob reads Alice's
|
||||
* public document from a bare reference). This file pins the primitive itself: what it
|
||||
* asks, what it refuses, and when it says nothing at all.
|
||||
*/
|
||||
import { test, expect, mock, afterEach } from "bun:test";
|
||||
import { exposeReadCap, fetchReadCap, resetPublicStoreFetches } from "../src/emulated-verifier/public-store";
|
||||
import { mintCap } from "../src/emulated-verifier/caps";
|
||||
import { getCaps } from "../src/shared-wallet/bootstrap";
|
||||
import { configure, configureStoreRegistry, setCurrentUser } from "../src/index";
|
||||
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);
|
||||
});
|
||||
|
||||
/** Arm the emulation without giving the current holder anything: some OTHER document. */
|
||||
function armEmulation(): void {
|
||||
setCurrentUser("someone-else");
|
||||
getCaps().mint("did:ng:o:unrelated");
|
||||
}
|
||||
|
||||
const PUB = "did:ng:o:pub" as Nuri;
|
||||
|
||||
test("a cap exposed on a document is downloaded by a holder that has nothing", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
await exposeReadCap(PUB, mintCap(PUB));
|
||||
|
||||
setCurrentUser("bob");
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
expect(getCaps().capFor(PUB)).toBeUndefined();
|
||||
|
||||
expect(await fetchReadCap(PUB)).toBe(true);
|
||||
expect(getCaps().capFor(PUB)).toBe(mintCap(PUB));
|
||||
// …and what he got is a READ grant, recorded as such.
|
||||
expect(getCaps().isReadOnlyPublicCap(PUB)).toBe(true);
|
||||
expect(getCaps().isInPublicStore(PUB)).toBe(true);
|
||||
});
|
||||
|
||||
test("a document that exposes nothing yields nothing — that is the normal case, not an error", async () => {
|
||||
inject();
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
expect(await fetchReadCap("did:ng:o:protected" as Nuri)).toBe(false);
|
||||
expect(getCaps().capFor("did:ng:o:protected" as Nuri)).toBeUndefined();
|
||||
});
|
||||
|
||||
// A document speaks for itself and for nothing else. Without this, whoever can write
|
||||
// into one public document could file caps for every document they care to name.
|
||||
test("a cap naming ANOTHER document is refused, not filed", async () => {
|
||||
const { quads } = inject();
|
||||
setCurrentUser("alice");
|
||||
await exposeReadCap(PUB, mintCap(PUB));
|
||||
// Forge the exposed value so it names a different document.
|
||||
quads[0]!.o = mintCap("did:ng:o:someone-elses" as Nuri);
|
||||
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
expect(await fetchReadCap(PUB)).toBe(false);
|
||||
expect(getCaps().capFor(PUB)).toBeUndefined();
|
||||
expect(getCaps().capFor("did:ng:o:someone-elses" as Nuri)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("inert while no cap has been issued at all — nothing to obtain, nothing asked", async () => {
|
||||
const { sparql_query } = inject();
|
||||
setCurrentUser("bob");
|
||||
expect(await fetchReadCap(PUB)).toBe(false);
|
||||
expect(sparql_query).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test("asked once per document: the outcome is memoised, in both directions", async () => {
|
||||
const { sparql_query } = inject();
|
||||
setCurrentUser("alice");
|
||||
await exposeReadCap(PUB, mintCap(PUB));
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
|
||||
await fetchReadCap(PUB);
|
||||
const afterHit = sparql_query.mock.calls.length;
|
||||
await fetchReadCap(PUB); // held now → not even the memo is consulted
|
||||
expect(sparql_query.mock.calls.length).toBe(afterHit);
|
||||
|
||||
const absent = "did:ng:o:nothing-here" as Nuri;
|
||||
await fetchReadCap(absent);
|
||||
const afterMiss = sparql_query.mock.calls.length;
|
||||
await fetchReadCap(absent); // a miss is remembered too
|
||||
expect(sparql_query.mock.calls.length).toBe(afterMiss);
|
||||
});
|
||||
|
||||
test("resetting the caps forgets the memo — a stale yes would hand back what is no longer held", async () => {
|
||||
const { sparql_query } = inject();
|
||||
setCurrentUser("alice");
|
||||
await exposeReadCap(PUB, mintCap(PUB));
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
await fetchReadCap(PUB);
|
||||
|
||||
resetCaps(); // also calls resetPublicStoreFetches
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
const before = sparql_query.mock.calls.length;
|
||||
expect(await fetchReadCap(PUB)).toBe(true);
|
||||
expect(sparql_query.mock.calls.length).toBeGreaterThan(before); // asked again
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* reach.test.ts — the virtual user boundary, at the passage points.
|
||||
*
|
||||
* A virtual user must simulate the boundary of the future single-user wallet: the
|
||||
* access functions are confined to the user currently connected, and no cross-user
|
||||
* access is permitted. Before this, `docs.sparqlQuery`/`sparqlUpdate` — both
|
||||
* exported from the SDK entry — reached ANY document of ANY identity given a
|
||||
* session id and a NURI.
|
||||
*
|
||||
* The one act that legitimately crosses: DEPOSITING into someone's inbox. It is
|
||||
* how a link travels between users at all, and it gives the depositor nothing back.
|
||||
*/
|
||||
import { test, expect, mock, afterAll } from "bun:test";
|
||||
import { sparqlQuery, sparqlUpdate, depositInto } from "../src/surface/docs";
|
||||
import { createEntityDoc, resetRegistryCache, userInbox } from "../src/shared-wallet/account-registry";
|
||||
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
||||
import { configure, configureStoreRegistry, setCurrentUser } from "../src/index";
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
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);
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
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, configureStoreRegistry, setCurrentUser } from "../src/index";
|
||||
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);
|
||||
});
|
||||
@@ -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,392 @@
|
||||
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, configureStoreRegistry } from "../src/index";
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { test, expect, mock, afterAll } from "bun:test";
|
||||
import { subscribeDoc, subscribeDocs } from "../src/surface/subscribe";
|
||||
import { configure, configureStoreRegistry } from "../src/index";
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 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");
|
||||
|
||||
/** Every identifier the entry point publishes, read from its `export` statements. */
|
||||
function publishedNames(): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const entry of ["index.ts"]) {
|
||||
const text = fs.readFileSync(path.join(SRC, entry), "utf8");
|
||||
// `export * as ns from "…"`
|
||||
for (const m of text.matchAll(/export \* as (\w+) from/g)) out.add(m[1]!);
|
||||
// `export { a, b as c }` / `export type { … }`, 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);
|
||||
}
|
||||
}
|
||||
// `export const x` / `export function x` / `export interface x`
|
||||
for (const m of text.matchAll(/export (?:declare )?(?:const|function|class|interface|type) (\w+)/g)) {
|
||||
out.add(m[1]!);
|
||||
}
|
||||
// `export * from "./x"` — re-exports every name that module declares.
|
||||
for (const m of text.matchAll(/export \* from "\.\/([^"]+)"/g)) {
|
||||
const file = path.join(SRC, m[1]! + ".ts");
|
||||
if (!fs.existsSync(file)) continue;
|
||||
const t = fs.readFileSync(file, "utf8");
|
||||
for (const mm of t.matchAll(/^export (?:declare )?(?:const|function|class|interface|type) (\w+)/gm)) {
|
||||
out.add(mm[1]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
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: () => " | ||||