5e91771da6
Bug: à la reconnexion, resolveAccount lisait le shim depuis le store-root (did🆖${privateStoreId}), NON abonnable → pas de barrière first-State → un "0 rows" à froid est ambigu → le retry (resolveAccountReliably/provisionRetry) échoue → nouveau compte provisionné → FORK → données du compte invisibles. Cause NextGraph (vérifiée nextgraph-rs): "trouvable-sans-lookup" (store-root) et "abonnable" (did:ng:o:<RepoID aléatoire>) sont DISJOINTS — pas de doc à la fois devinable et attendable → une résolution shim purement barrière est impossible. Fix (indirection pointeur → doc-shim abonnable): - Les AccountRecord migrent dans un doc-shim doc_create'd (did:ng:o:..., a une barrière). - Un pointeur écrit-une-fois dans le store-root (<shim:root> <shim:shimDoc> <docShim>) le nomme. resolveShimDoc lit le pointeur → ensureRepoOpen(docShim) [barrière] → lecture de compte AUTORITATIVE (cold 0 = absent pour de vrai). Retry de compte SUPPRIMÉ. - Micro-garde résiduel (pointerGuard, ex-provisionRetry) sur le SEUL triple pointeur écrit-une-fois; ne peut jamais forker un compte; fork de pointeur réconcilié au doc-shim canonique (lexicographiquement-min), sans perte. - Migration: migrateLegacyRecords copie (pas déplace) les comptes de l'ancien store-root vers le doc-shim avant toute conclusion "absent"; idempotent; wallet neuf → no-op. Tests: unit 128/128, e2e réel 42/42 (CONTRACT 2 = non-fork du compte à la reconnexion), red-before/green-after prouvé. Docs: nextgraph-current-state (antagonisme + indirection), simulation, migration-guide. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
406 lines
17 KiB
TypeScript
406 lines
17 KiB
TypeScript
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
|
||
import {
|
||
ensureAccount,
|
||
allAccounts,
|
||
loadShim,
|
||
resolveWriteGraph,
|
||
resolveReadGraphs,
|
||
resolveScopeGraph,
|
||
resolveInboxAnchor,
|
||
createEntityDoc,
|
||
listEntityDocs,
|
||
resetRegistryCache,
|
||
} from "../src/store-registry";
|
||
import type { RegistrySession } from "../src/store-registry";
|
||
import {
|
||
configure,
|
||
configureStoreRegistry,
|
||
resetStoreRegistry,
|
||
resetConfig,
|
||
} from "../src/polyfill";
|
||
|
||
// 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("loadShim round-trips a persisted account across a cache reset", async () => {
|
||
await ensureAccount("Bob");
|
||
resetRegistryCache(); // force a re-read from the fake store
|
||
const map = await loadShim();
|
||
const rec = map.get("bob");
|
||
expect(rec?.id).toBe("Bob");
|
||
expect(rec?.docPublic).toMatch(/^did:ng:o:doc/);
|
||
});
|
||
|
||
test("resolveWriteGraph returns the per-scope index doc; resolveReadGraphs fans out", async () => {
|
||
const rec = await ensureAccount("Carol");
|
||
expect(await resolveWriteGraph("carol", "protected")).toBe(rec.docProtected);
|
||
expect(await resolveReadGraphs("public")).toEqual([rec.docPublic]);
|
||
});
|
||
|
||
test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to the caller)", async () => {
|
||
// 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
|
||
// The inbox anchor is now a DEDICATED inbox DOCUMENT (a reserved account's
|
||
// public scope doc, from docCreate) — NOT the private-store root — so inbox
|
||
// deposits don't bloat the shim graph. It is a real repo NURI and STABLE
|
||
// across calls (same reserved account → same document).
|
||
const anchor = await resolveInboxAnchor();
|
||
expect(anchor).toMatch(/^did:ng:o:doc/);
|
||
expect(anchor).not.toBe("did:ng:PRIV");
|
||
expect(await resolveInboxAnchor()).toBe(anchor); // stable
|
||
});
|
||
|
||
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 + listEntityDocs round-trip via the per-scope index", async () => {
|
||
const rec = await ensureAccount("Dave");
|
||
const e1 = await createEntityDoc("dave", "public");
|
||
const e2 = await createEntityDoc("dave", "public");
|
||
const other = await createEntityDoc("dave", "protected");
|
||
// Public listing unions dave's public entities only.
|
||
const pub = await listEntityDocs("public");
|
||
expect(pub.sort()).toEqual([e1, e2].sort());
|
||
const prot = await listEntityDocs("protected");
|
||
expect(prot).toEqual([other]);
|
||
// The index append targets the account's public index doc.
|
||
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
||
});
|
||
|
||
test("listEntityDocs fans out across multiple accounts", async () => {
|
||
await ensureAccount("Eve");
|
||
await ensureAccount("Frank");
|
||
const e = await createEntityDoc("eve", "public");
|
||
const f = await createEntityDoc("frank", "public");
|
||
expect((await listEntityDocs("public")).sort()).toEqual([e, f].sort());
|
||
});
|
||
|
||
test("allAccounts reflects every ensured account", async () => {
|
||
await ensureAccount("Gina");
|
||
await ensureAccount("Hank");
|
||
const names = (await allAccounts()).map((a) => a.id).sort();
|
||
expect(names).toEqual(["Gina", "Hank"]);
|
||
});
|
||
|
||
// --- SPARQL injection hardening (F1) --------------------------------------
|
||
//
|
||
// 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();
|
||
const map = await loadShim();
|
||
// The stored id came back verbatim (escaping is lossless) under its
|
||
// normalized key, and exactly ONE account exists (no injected extra subject).
|
||
const key = evil.trim().replace(/^@+/, "").toLowerCase();
|
||
expect(map.get(key)?.id).toBe(evil);
|
||
expect(map.size).toBe(1);
|
||
});
|
||
|
||
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
|
||
});
|