bf753770b8
The ReadCap filter now enforces on per-entity documents (consumers create one doc per entity, so each has a declared policy — private→owner, protected→owner+ connections, public→all). Isolation is genuinely active, not dormant. - connections.ts (new): a BILATERAL connection registry — a link grants protected read only when BOTH sides have asserted it (each assertion bound to its author). A unilateral/self-declared connection grants nothing (closes the confused-deputy hole). declareConnections is authenticated to the current identity. - inbox.post: `from` is bound to the current identity — a spoofed `from` throws. - discovery.submitToIndex: PUBLIC-ONLY — a governed non-public doc is refused (no protected/private leak into the world-readable index). - docs/simulation.md: documents this as application-level emulated isolation on a shared wallet (not crypto); at NextGraph maturity → real caps, consumer unchanged. 89 tests pass (+10 covering: active protected isolation via bilateral connect, unilateral grants nothing, from-spoof rejected, non-public submit refused). tsc rc=0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
|
||
import { submitToIndex, readIndex, watchIndex, INDEX_ACCOUNT } from "../src/discovery";
|
||
import type { IndexEntry } from "../src/discovery";
|
||
import {
|
||
configure,
|
||
configureStoreRegistry,
|
||
resetStoreRegistry,
|
||
resetConfig,
|
||
setCurrentUser,
|
||
getCaps,
|
||
resetCaps,
|
||
} from "../src/polyfill";
|
||
import { resetRegistryCache, ensureAccount } from "../src/store-registry";
|
||
import type { RegistrySession } from "../src/store-registry";
|
||
|
||
// discovery.ts submits to / reads from a global index owned by a RESERVED
|
||
// SPECIAL ACCOUNT (@index) in the shim. This suite injects one fake `ng` that
|
||
// emulates BOTH the shim SPARQL (ensureAccount('@index') → doc_create ×3 +
|
||
// shim INSERT/SELECT) AND the inbox SPARQL (deposit INSERT + read SELECT), over
|
||
// a single in-memory quad store. Restore un-configured state at the end.
|
||
afterAll(() => {
|
||
resetConfig();
|
||
resetStoreRegistry();
|
||
setCurrentUser(null);
|
||
resetCaps();
|
||
});
|
||
|
||
test("throws a clear error when configureStoreRegistry() was not called", async () => {
|
||
resetStoreRegistry();
|
||
resetRegistryCache();
|
||
await expect(submitToIndex({ ref: 1 })).rejects.toThrow(
|
||
/configureStoreRegistry\(\) must be called before use/,
|
||
);
|
||
});
|
||
|
||
interface Quad { g: string; s: string; p: string; o: string }
|
||
|
||
const SHIM = "urn:ng-eventually:shim";
|
||
const INBOX = "urn:ng-eventually:inbox";
|
||
|
||
/** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */
|
||
function unescapeLiteral(s: string): string {
|
||
let out = "";
|
||
for (let i = 0; i < s.length; i++) {
|
||
if (s[i] === "\\" && i + 1 < s.length) {
|
||
const next = s[++i];
|
||
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next;
|
||
} else {
|
||
out += s[i];
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// A stateful fake `ng` serving BOTH the shim and the inbox SPARQL.
|
||
function makeFakeNg() {
|
||
const quads: Quad[] = [];
|
||
let docCounter = 0;
|
||
|
||
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
|
||
|
||
const sparql_update = mock(async (...a: unknown[]) => {
|
||
const query = a[1] as string;
|
||
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
||
if (!gm) return undefined;
|
||
const g = gm[1]!;
|
||
const body = gm[2]!;
|
||
const sm = body.match(/<([^>]+)>/);
|
||
if (!sm) return undefined;
|
||
const s = sm[1]!;
|
||
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
||
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
||
let m: RegExpExecArray | null;
|
||
while ((m = pairRe.exec(after)) !== null) {
|
||
// `a` → an rdf:type marker; the two type IRIs the modules use differ, so
|
||
// pick by which body we're in (deposit vs account) — harmless if wrong,
|
||
// the SELECT filters by the real predicates below.
|
||
const isDeposit = query.includes(`${INBOX}:Deposit`);
|
||
const p = m[1] ?? (isDeposit ? `${INBOX}:Deposit` : `${SHIM}:Account`);
|
||
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
||
quads.push({ g, s, p, o });
|
||
}
|
||
return undefined;
|
||
});
|
||
|
||
const sparql_query = mock(async (...a: unknown[]) => {
|
||
const query = a[1] as string;
|
||
const anchor = a[3] as string | undefined;
|
||
// Shim account SELECT.
|
||
if (query.includes(`<${SHIM}:username>`)) {
|
||
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 === `${SHIM}:username`) rec.username = 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.username)
|
||
.map((r) => ({
|
||
username: { value: r.username! },
|
||
docPublic: { value: r.docPublic ?? "" },
|
||
docProtected: { value: r.docProtected ?? "" },
|
||
docPrivate: { value: r.docPrivate ?? "" },
|
||
}));
|
||
return { results: { bindings } };
|
||
}
|
||
// Inbox deposit SELECT (?payload ?ts ?from).
|
||
if (query.includes(`<${INBOX}:payload>`)) {
|
||
const bySubject = new Map<string, Record<string, string>>();
|
||
for (const q of quads) {
|
||
if (q.g !== anchor) continue;
|
||
if (q.p === `${INBOX}:Deposit`) {
|
||
if (!bySubject.has(q.s)) bySubject.set(q.s, {});
|
||
continue;
|
||
}
|
||
const rec = bySubject.get(q.s) ?? {};
|
||
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
|
||
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
|
||
if (q.p === `${INBOX}:from`) rec.from = q.o;
|
||
bySubject.set(q.s, rec);
|
||
}
|
||
const bindings = [...bySubject.values()]
|
||
.filter((r) => r.payload !== undefined && r.ts !== undefined)
|
||
.map((r) => {
|
||
const row: Record<string, { value: string }> = {
|
||
payload: { value: r.payload! },
|
||
ts: { value: r.ts! },
|
||
};
|
||
if (r.from !== undefined) row.from = { value: r.from };
|
||
return row;
|
||
});
|
||
return { results: { bindings } };
|
||
}
|
||
// Entity-index SELECT (shim contains) — unused here.
|
||
return { results: { bindings: [] } };
|
||
});
|
||
|
||
return { doc_create, 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,
|
||
normalizeUser: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
|
||
});
|
||
resetRegistryCache();
|
||
setCurrentUser(null);
|
||
return ng;
|
||
}
|
||
|
||
let fake: ReturnType<typeof makeFakeNg>;
|
||
beforeEach(() => {
|
||
fake = inject();
|
||
});
|
||
|
||
test("submitToIndex creates the @index special account on first sight (3 docs)", async () => {
|
||
await submitToIndex({ nuri: "did:ng:o:event1", title: "Concert" });
|
||
// ensureAccount('@index') created its 3 scope docs.
|
||
expect(fake.doc_create).toHaveBeenCalledTimes(3);
|
||
// The deposit landed in the @index public document (its inbox).
|
||
const depositCall = fake.sparql_update.mock.calls.find((c) =>
|
||
(c[1] as string).includes(`${INBOX}:Deposit`),
|
||
)!;
|
||
expect(depositCall, "a deposit INSERT was issued").not.toBeUndefined();
|
||
expect(depositCall[2]).toMatch(/^did:ng:o:doc/); // the index document NURI
|
||
});
|
||
|
||
test("submit → read round-trips the reference as an index entry", async () => {
|
||
setCurrentUser("alice"); // `from` is bound to the current identity
|
||
const ref = { nuri: "did:ng:o:event1", title: "Concert au parc" };
|
||
await submitToIndex(ref, { from: "alice", ts: 100 });
|
||
const entries = await readIndex();
|
||
expect(entries).toHaveLength(1);
|
||
expect(entries[0]).toEqual({ ref, from: "alice", ts: 100 } as IndexEntry);
|
||
});
|
||
|
||
test("a reference submitted by A is discovered by a NON-connected reader via the index", async () => {
|
||
// A submits (identified). No connection is ever declared. A separate reader
|
||
// materializes the SAME index (same special account → same document) and sees
|
||
// the reference — discovery is via the index, not any direct fan-out/link.
|
||
setCurrentUser("alice");
|
||
const ref = { nuri: "did:ng:o:evA", title: "Public event by A" };
|
||
await submitToIndex(ref, { ts: 100 });
|
||
|
||
// Reader B: a fresh cache, never connected to A, reads the index.
|
||
resetRegistryCache();
|
||
setCurrentUser("bob");
|
||
const entries = await readIndex();
|
||
const refs = entries.map((e) => e.ref);
|
||
expect(refs).toContainEqual(ref);
|
||
expect(entries.find((e) => JSON.stringify(e.ref) === JSON.stringify(ref))!.from).toBe("alice");
|
||
});
|
||
|
||
test("readIndex deduplicates identical references (materialization moderation point)", async () => {
|
||
const ref = { nuri: "did:ng:o:dup", title: "Twice" };
|
||
// Anonymous submissions (dedup keys on the ref, not the submitter).
|
||
await submitToIndex(ref, { from: null, ts: 100 });
|
||
await submitToIndex(ref, { from: null, ts: 200 }); // duplicate reference
|
||
const entries = await readIndex();
|
||
expect(entries).toHaveLength(1); // surfaced once
|
||
});
|
||
|
||
test("from: null makes an anonymous submission", async () => {
|
||
await submitToIndex({ nuri: "did:ng:o:anon" }, { from: null, ts: 100 });
|
||
const entries = await readIndex();
|
||
expect(entries[0]!.from).toBeNull();
|
||
});
|
||
|
||
// (d) PUBLIC-ONLY: a protected/private document must NOT be submittable to the
|
||
// world-readable discovery index; a public (or ungoverned) document is fine.
|
||
test("(d) submitToIndex refuses a PROTECTED/PRIVATE document (public-only)", async () => {
|
||
resetCaps();
|
||
// A PROTECTED and a PRIVATE governed document, and a PUBLIC one.
|
||
getCaps().open("did:ng:o:prot", "protected", "alice");
|
||
getCaps().open("did:ng:o:priv", "private", "alice");
|
||
getCaps().open("did:ng:o:pub", "public", "alice");
|
||
|
||
// Submitting the protected doc's NURI is REJECTED.
|
||
await expect(
|
||
submitToIndex({ nuri: "did:ng:o:prot" }, { from: null, doc: "did:ng:o:prot" }),
|
||
).rejects.toThrow(/PUBLIC|public-only|protected\/private/i);
|
||
// Private too.
|
||
await expect(
|
||
submitToIndex({ nuri: "did:ng:o:priv" }, { from: null, doc: "did:ng:o:priv" }),
|
||
).rejects.toThrow(/PUBLIC|public-only|protected\/private/i);
|
||
// The PUBLIC document passes.
|
||
await submitToIndex({ nuri: "did:ng:o:pub" }, { from: null, doc: "did:ng:o:pub", ts: 1 });
|
||
const entries = await readIndex();
|
||
expect(entries.map((e) => (e.ref as { nuri: string }).nuri)).toEqual(["did:ng:o:pub"]);
|
||
resetCaps();
|
||
});
|
||
|
||
test("INDEX_ACCOUNT lives in the reserved namespace (no typed username can equal it)", () => {
|
||
// The index account occupies a key no user input can produce: it is prefixed
|
||
// with a NUL control char, which a user cannot type into a username field and
|
||
// which no `normalizeUser` output (a typeable value) contains. So it is
|
||
// disjoint from the keys "index" / "@index" a hostile user would submit.
|
||
expect(INDEX_ACCOUNT.startsWith("\u0000")).toBe(true); // unreachable-by-typing sentinel
|
||
expect(INDEX_ACCOUNT).not.toBe("index");
|
||
expect(INDEX_ACCOUNT).not.toBe("@index");
|
||
});
|
||
|
||
test("a user named 'index'/'@index' does NOT resolve to the index account's document", async () => {
|
||
// The discovery index lives on INDEX_ACCOUNT. A hostile (or unlucky) user who
|
||
// registers as "index" or "@index" normalizes to key "index" — which must be
|
||
// a DISJOINT key from the reserved index account, so they get their own
|
||
// documents and cannot hijack / read-write the global index document.
|
||
const indexRecord = await ensureAccount(INDEX_ACCOUNT);
|
||
|
||
// A real user "index" — same normalized form as "@index".
|
||
const userIndex = await ensureAccount("index");
|
||
expect(userIndex.docPublic).not.toBe(indexRecord.docPublic);
|
||
expect(userIndex.docProtected).not.toBe(indexRecord.docProtected);
|
||
expect(userIndex.docPrivate).not.toBe(indexRecord.docPrivate);
|
||
|
||
// "@index" must land on the SAME account as "index" (both normalize to
|
||
// "index") — and still NOT on the reserved index account.
|
||
const userAtIndex = await ensureAccount("@index");
|
||
expect(userAtIndex.docPublic).toBe(userIndex.docPublic);
|
||
expect(userAtIndex.docPublic).not.toBe(indexRecord.docPublic);
|
||
});
|
||
|
||
test("watchIndex fires immediately then when a submission arrives", async () => {
|
||
const seen: IndexEntry[][] = [];
|
||
const stop = watchIndex((e) => seen.push(e), { intervalMs: 5 });
|
||
await new Promise((r) => setTimeout(r, 20));
|
||
expect(seen.length).toBeGreaterThanOrEqual(1);
|
||
expect(seen[seen.length - 1]).toEqual([]);
|
||
|
||
await submitToIndex({ nuri: "did:ng:o:watched" }, { from: null, ts: 1 });
|
||
await new Promise((r) => setTimeout(r, 20));
|
||
const last = seen[seen.length - 1]!;
|
||
expect(last.map((e) => (e.ref as any).nuri)).toContain("did:ng:o:watched");
|
||
|
||
stop();
|
||
const countAfterStop = seen.length;
|
||
await submitToIndex({ nuri: "did:ng:o:after" }, { from: null, ts: 2 });
|
||
await new Promise((r) => setTimeout(r, 20));
|
||
expect(seen.length).toBe(countAfterStop);
|
||
});
|