ae9c32e271
Two batches, verified against nextgraph-rs throughout. P1a — the capability surface. Reading was an ACL (Map<doc, Set<principal>>), the exact inversion of key possession. It is now possession: `capFor(nuri)` is the only question, there is no principal parameter anywhere, and nothing turns a bare reference into a cap. Sharing is `shareCap(cap, toInbox)`, a Link deposit; receiving needs no operation. `Nuri` and `ReadCap` are template literal types, so passing a bare reference where a cap belongs is a compile error, with runtime guards behind it for JavaScript callers. The virtual user boundary. Every access function is now confined to the connected user, through two rules on one criterion (possession), implemented in two places so a lapse in either is caught by the other: authorization at the passage points, and "do not even attempt" at the callers. The polyfill's own machinery moved to physical.ts — unguarded, never exported — which replaced an exemption list: the machinery no longer gets waved through the guard, it calls something the guard never saw. Removed, as emulating capabilities the target does not have: - discovery.ts and its global index. There is no discovery in NextGraph; you follow links. It also pooled user data across wallets. - the cross-account fan-out (listEntityDocs, resolveReadGraphs, allAccounts, loadShim), which was cross-user enumeration by construction. - resolveInboxAnchor, a single inbox common to every user. Caps are now stored where NextGraph stores them, and read back rather than recomputed: AddRepo on the store's Store branch for documents a user creates, AddLink on its User branch for caps received. Inboxes belong to someone — the user's own, plus one per document — and connecting a user drains them all; that is the library's job, not the app's. Corrections worth recording: a ReadCap is `r:`, not `:k:` (reported by NextGraph's developer, verified in BlockRef::readcap_nuri); received caps DO have a register (AddLink), contrary to what this repo's notes claimed; and "wallet" upstream means keyring — what owns three stores is a user, so the vocabulary follows. The cap value is the constant OK: the only question the emulation answers is whether a cap is held. P1b replaces that one constant with a real key. After this the shape is right and the isolation is still fake. Nothing here may be described as anonymous or private.
330 lines
14 KiB
TypeScript
330 lines
14 KiB
TypeScript
/**
|
|
* 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/store-registry";
|
|
import type { RegistrySession } from "../src/store-registry";
|
|
import {
|
|
configure,
|
|
configureStoreRegistry,
|
|
resetStoreRegistry,
|
|
resetConfig,
|
|
} from "../src/polyfill";
|
|
import { resetOpenedRepos } from "../src/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
|
|
});
|
|
});
|
|
|