refactor: le paquet s'appelle polyfill, « SDK » désigne celui de NextGraph
Le nom @ng-eventually/sdk entrait en collision avec le SDK de NextGraph, dont ce paquet est justement un polyfill. Impossible d'écrire « le SDK » sans lever l'ambiguïté à chaque phrase — et le contrat publié, lu par une application, était le pire endroit pour laisser traîner ça. packages/sdk → packages/polyfill, @ng-eventually/sdk → @ng-eventually/polyfill, contract_sdk-surface → contract_polyfill-surface, e2e/sdk-entry.ts → e2e/polyfill-entry.ts, docs/sdk-reference.md → docs/polyfill-reference.md. Les occurrences de « SDK » qui désignent celui de NextGraph restent intactes, y compris les chemins dans nextgraph-rs (sdk/js/orm, sdk/js/web). Le tri s'est fait occurrence par occurrence, pas par substitution. Le contrat énonce désormais son identité en une phrase : « This package is a polyfill of NextGraph's SDK. »
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
|
||||
import {
|
||||
ensureAccount,
|
||||
resolveWriteGraph,
|
||||
resolveAccount,
|
||||
listMyEntityDocs,
|
||||
resolveScopeGraph,
|
||||
userInbox,
|
||||
createEntityDoc,
|
||||
resetRegistryCache,
|
||||
} from "../src/shared-wallet/account-registry";
|
||||
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
||||
import { configure } from "../src/index";
|
||||
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
|
||||
import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
|
||||
|
||||
// This suite injects a fake `ng` via configure(); bun runs test files in a
|
||||
// shared process with a single module singleton, and may run this file BEFORE
|
||||
// docs.test.ts's order-dependent "not configured" guard. Restore the un-
|
||||
// configured state when we're done so that guard still sees a null config.
|
||||
afterAll(() => {
|
||||
resetConfig();
|
||||
resetStoreRegistry();
|
||||
});
|
||||
|
||||
// NOTE ORDER: the "not configured → throw" case MUST run first — configure*()
|
||||
// sets module-level singletons and this suite never fully un-injects the real
|
||||
// `ng` (docs' getConfig has no reset), so we exercise the registry-deps guard.
|
||||
|
||||
test("throws a clear error when configureStoreRegistry() was not called", async () => {
|
||||
resetStoreRegistry();
|
||||
resetRegistryCache();
|
||||
await expect(ensureAccount("alice")).rejects.toThrow(
|
||||
/configureStoreRegistry\(\) must be called before use/,
|
||||
);
|
||||
});
|
||||
|
||||
// --- A stateful fake `ng` that emulates just enough SPARQL over an in-memory
|
||||
// quad store: INSERT DATA parsing + the two SELECT shapes the registry issues.
|
||||
|
||||
interface Quad { g: string; s: string; p: string; o: string }
|
||||
|
||||
/** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */
|
||||
function unescapeLiteral(s: string): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (s[i] === "\\" && i + 1 < s.length) {
|
||||
const next = s[++i];
|
||||
out +=
|
||||
next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next;
|
||||
} else {
|
||||
out += s[i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeFakeNg() {
|
||||
const quads: Quad[] = [];
|
||||
let docCounter = 0;
|
||||
|
||||
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
|
||||
|
||||
// Parses `INSERT DATA { GRAPH <g> { <s> <p> "o"/<o>/;-lists } }`. The literal
|
||||
// pattern honours backslash-escapes (`\"`, `\\`, `\n`…) so an escaped quote
|
||||
// inside a value does NOT terminate the literal — this is what proves the
|
||||
// injection escaping keeps the query well-formed.
|
||||
const sparql_update = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[2] as string | undefined;
|
||||
// TWO shapes coexist here:
|
||||
// - the shim account write STILL uses `GRAPH <${privateStore}>` — the
|
||||
// private STORE repo's graph name equals the plain store NURI, so it
|
||||
// round-trips (login must not regress). Key it by that GRAPH IRI.
|
||||
// - the per-entity INDEX write has NO explicit GRAPH — the real broker keys
|
||||
// it by the ANCHORED repo's default graph (repo_graph_name(id, overlay)),
|
||||
// so key it by the ANCHOR arg (a[2]). An explicit `GRAPH <indexDoc>`
|
||||
// would target a phantom graph → must NOT round-trip.
|
||||
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
||||
let g: string;
|
||||
let body: string;
|
||||
if (gm) {
|
||||
g = gm[1]!;
|
||||
body = gm[2]!;
|
||||
} else {
|
||||
if (!anchor) return undefined;
|
||||
g = anchor;
|
||||
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
||||
}
|
||||
// Subject is the first <...> token in the body.
|
||||
const sm = body.match(/<([^>]+)>/);
|
||||
if (!sm) return undefined;
|
||||
const s = sm[1]!;
|
||||
// Predicate/object pairs: `<p> "o"` (escape-aware) or `<p> <o>`; `a <type>`.
|
||||
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
// Skip the subject token so we don't treat it as a predicate.
|
||||
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
||||
while ((m = pairRe.exec(after)) !== null) {
|
||||
const p = m[1] ?? "urn:ng-eventually:shim:Account"; // `a` → rdf:type-ish
|
||||
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
||||
quads.push({ g, s, p, o });
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const sparql_query = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[3] as string | undefined;
|
||||
// Pointer SELECT: `<shim:root> <shim:shimDoc> ?shimDoc` in the store-root graph.
|
||||
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
|
||||
.map((q) => ({ shimDoc: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
||||
// Account SELECT, anchored to the doc-shim's default graph (records live in the
|
||||
// doc-shim now, no GRAPH wrapper). Two shapes: the full scan (`?acc a <Account>`)
|
||||
// and the TARGETED bounded resolve (`<subj> a <Account>`) — honour the subject.
|
||||
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
||||
const onlySubject = subjM ? subjM[1]! : null;
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
if (q.g !== anchor) continue;
|
||||
if (onlySubject !== null && q.s !== onlySubject) continue;
|
||||
const rec = bySubject.get(q.s) ?? {};
|
||||
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
|
||||
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
|
||||
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
|
||||
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o;
|
||||
bySubject.set(q.s, rec);
|
||||
}
|
||||
const bindings = [...bySubject.values()]
|
||||
.filter((r) => r.id)
|
||||
.map((r) => ({
|
||||
id: { value: r.id! },
|
||||
docPublic: { value: r.docPublic ?? "" },
|
||||
docProtected: { value: r.docProtected ?? "" },
|
||||
docPrivate: { value: r.docPrivate ?? "" },
|
||||
}));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
// Entity-index SELECT: `<index> <contains> ?e` in the anchor graph.
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
|
||||
.map((q) => ({ e: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
});
|
||||
|
||||
return { doc_create, sparql_update, sparql_query, _quads: quads };
|
||||
}
|
||||
|
||||
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
|
||||
|
||||
function inject() {
|
||||
const ng = makeFakeNg();
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
configureStoreRegistry({
|
||||
getSession: async () => SESSION,
|
||||
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
|
||||
});
|
||||
resetRegistryCache();
|
||||
return ng;
|
||||
}
|
||||
|
||||
let fake: ReturnType<typeof makeFakeNg>;
|
||||
beforeEach(() => {
|
||||
fake = inject();
|
||||
});
|
||||
|
||||
test("ensureAccount creates 3 scope docs and persists them to the doc-shim", async () => {
|
||||
const rec = await ensureAccount("Alice");
|
||||
expect(rec.id).toBe("Alice");
|
||||
// 4 doc_create: 1 doc-shim (first login, resolveShimDoc) + 3 scope docs.
|
||||
expect(fake.doc_create).toHaveBeenCalledTimes(4);
|
||||
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
||||
expect(rec.docProtected).not.toBe(rec.docPublic);
|
||||
expect(rec.docPrivate).not.toBe(rec.docProtected);
|
||||
// The pointer (store-root -> doc-shim) was written into the store-root graph.
|
||||
const pointerWrite = fake.sparql_update.mock.calls.find(
|
||||
(c) => (c[1] as string).includes("<urn:ng-eventually:shim:shimDoc>"),
|
||||
);
|
||||
expect(pointerWrite?.[2]).toBe("did:ng:PRIV");
|
||||
// The account record was persisted into the doc-shim (a did:ng:o: repo), not the root.
|
||||
const recordWrite = fake.sparql_update.mock.calls.find(
|
||||
(c) => (c[1] as string).includes("<urn:ng-eventually:shim:docPublic>"),
|
||||
);
|
||||
expect(recordWrite?.[2]).toMatch(/^did:ng:o:doc/);
|
||||
});
|
||||
|
||||
test("ensureAccount is idempotent (case/@-insensitive key), no extra docs", async () => {
|
||||
const a = await ensureAccount("Alice");
|
||||
const b = await ensureAccount("@alice");
|
||||
expect(b).toEqual(a);
|
||||
expect(fake.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs, not 7
|
||||
});
|
||||
|
||||
test("ensureAccount de-dupes CONCURRENT provisions (anti-fork): one account, 3 docs", async () => {
|
||||
// The reconnection FORK: several callers (watchShape public+protected, the
|
||||
// container subs, the owned-events effect) hit ensureAccount(SAME id) BEFORE
|
||||
// the shim has synced, so each reads 0 rows and independently provisions a new
|
||||
// set of scope docs — N forks, N×3 docs, duplicate docPublic/docProtected in the
|
||||
// shim → a fresh reader picks a different canonical doc than the writer wrote to.
|
||||
// The in-flight de-dup collapses N concurrent provisions into ONE.
|
||||
const results = await Promise.all([
|
||||
ensureAccount("Bob"),
|
||||
ensureAccount("Bob"),
|
||||
ensureAccount("@bob"),
|
||||
ensureAccount("BOB"),
|
||||
ensureAccount("bob"),
|
||||
]);
|
||||
// ONE set of 3 scope docs + 1 doc-shim (resolveShimDoc de-dupes concurrent pointer
|
||||
// resolution too) — 4 total, not 5×3.
|
||||
expect(fake.doc_create).toHaveBeenCalledTimes(4);
|
||||
// Every caller got the SAME record (same docs), so writer/reader can never
|
||||
// disagree on the canonical scope doc.
|
||||
for (const r of results) expect(r).toEqual(results[0]!);
|
||||
});
|
||||
|
||||
|
||||
test("resolveWriteGraph returns the per-scope index doc", async () => {
|
||||
const rec = await ensureAccount("Carol");
|
||||
expect(await resolveWriteGraph("carol", "protected")).toBe(rec.docProtected);
|
||||
});
|
||||
|
||||
test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to the caller)", async () => {
|
||||
// Session with all three store ids: private → private store; public+protected
|
||||
// co-locate on the protected native store (the polyfill's Axis-A placement).
|
||||
const ng = makeFakeNg();
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
configureStoreRegistry({
|
||||
getSession: async () => ({
|
||||
sessionId: "sid-2",
|
||||
privateStoreId: "PRIV",
|
||||
protectedStoreId: "PROT",
|
||||
publicStoreId: "PUB",
|
||||
}),
|
||||
});
|
||||
resetRegistryCache();
|
||||
expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV");
|
||||
expect(await resolveScopeGraph("protected")).toBe("did:ng:PROT");
|
||||
expect(await resolveScopeGraph("public")).toBe("did:ng:PROT"); // co-located
|
||||
// An inbox belongs to ONE virtual user — it is a dedicated document (from
|
||||
// docCreate), not the private-store root, so deposits never bloat the shim graph.
|
||||
// Stable per wallet, and DISJOINT between wallets: reading someone else's inbox
|
||||
// would collect the caps addressed to them (see inbox.ts's read guard).
|
||||
const mine = await userInbox("@alice", "protected");
|
||||
expect(mine).toMatch(/^did:ng:o:doc/);
|
||||
expect(mine).not.toBe("did:ng:PRIV");
|
||||
expect(await userInbox("@alice", "protected")).toBe(mine); // stable
|
||||
expect(await userInbox("@bob", "protected")).not.toBe(mine); // another wallet, another inbox
|
||||
});
|
||||
|
||||
test("resolveScopeGraph falls back to the private store when no protected id is injected", async () => {
|
||||
// The default SESSION carries only privateStoreId — non-private scopes fall
|
||||
// back to the private store rather than emitting a broken NURI.
|
||||
expect(await resolveScopeGraph("protected")).toBe("did:ng:PRIV");
|
||||
expect(await resolveScopeGraph("public")).toBe("did:ng:PRIV");
|
||||
});
|
||||
|
||||
test("createEntityDoc + listMyEntityDocs round-trip via the per-scope index", async () => {
|
||||
const rec = await ensureAccount("Dave");
|
||||
const e1 = await createEntityDoc("dave", "public");
|
||||
const e2 = await createEntityDoc("dave", "public");
|
||||
const other = await createEntityDoc("dave", "protected");
|
||||
// Public listing unions dave's public entities only.
|
||||
const pub = await listMyEntityDocs("dave", "public");
|
||||
expect(pub.sort()).toEqual([e1, e2].sort());
|
||||
const prot = await listMyEntityDocs("dave", "protected");
|
||||
expect(prot).toEqual([other]);
|
||||
// The index append targets the account's public index doc.
|
||||
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
||||
});
|
||||
|
||||
|
||||
|
||||
// --- SPARQL injection hardening (F1) --------------------------------------
|
||||
//
|
||||
// A malicious id must NOT be able to break out of the literal / IRI it
|
||||
// lands in and inject arbitrary triples into the shim (the account→doc trust
|
||||
// root). We inspect the exact SPARQL string the registry hands to sparql_update.
|
||||
|
||||
/** The raw INSERT DATA string produced by ensureAccount for `id`. */
|
||||
async function insertFor(id: string): Promise<string> {
|
||||
await ensureAccount(id);
|
||||
const calls = fake.sparql_update.mock.calls;
|
||||
return calls[calls.length - 1]![1] as string;
|
||||
}
|
||||
|
||||
/** Count RAW (un-escaped) double-quotes — i.e. `"` not preceded by a `\`.
|
||||
* Strip escaped pairs (`\\`, `\"`, …) first so only delimiter quotes remain. */
|
||||
function rawQuoteCount(s: string): number {
|
||||
const withoutEscapes = s.replace(/\\./g, "");
|
||||
return (withoutEscapes.match(/"/g) ?? []).length;
|
||||
}
|
||||
|
||||
test("injection: id with a quote cannot open extra literals", async () => {
|
||||
const evil = 'x" ; <urn:evil> "pwn';
|
||||
const update = await insertFor(evil);
|
||||
// A well-formed INSERT DATA with 4 predicate literals has exactly 8 raw
|
||||
// quotes (the delimiters). The injected `"` must have been escaped, so the
|
||||
// count stays 8 — no extra literal was opened.
|
||||
expect(rawQuoteCount(update)).toBe(8);
|
||||
// The escaped id is present as a single literal value — the injected
|
||||
// `<urn:evil>` survives only as INERT text inside that literal (its
|
||||
// surrounding quotes are escaped `\"`), never as query syntax.
|
||||
expect(update).toContain('"x\\" ; <urn:evil> \\"pwn"');
|
||||
});
|
||||
|
||||
test("injection: id with '>' cannot break out of the account-subject IRI", async () => {
|
||||
const evil = "x> <urn:evil";
|
||||
const update = await insertFor(evil);
|
||||
// The account subject is `<urn:ng-eventually:shim:account:...>` — the encoded
|
||||
// id must NOT contain a raw `>` that would close the IRI early.
|
||||
const subjMatch = update.match(/<urn:ng-eventually:shim:account:([^>]*)>/)!;
|
||||
expect(subjMatch).not.toBeNull();
|
||||
expect(subjMatch[1]).not.toMatch(/[<>" ]/); // fully percent-encoded
|
||||
expect(subjMatch[1]).toContain("%3E"); // the `>` became %3E
|
||||
});
|
||||
|
||||
test("injection: newline / control chars in id are neutralised", async () => {
|
||||
const evil = "a\nb\tc";
|
||||
const update = await insertFor(evil);
|
||||
// In the literal: escaped to \n / \t (no raw control char).
|
||||
expect(update).toContain('"a\\nb\\tc"');
|
||||
// In the IRI subject: percent-encoded.
|
||||
const subjMatch = update.match(/<urn:ng-eventually:shim:account:([^>]*)>/)!;
|
||||
expect(subjMatch[1]).toContain("%0A");
|
||||
expect(subjMatch[1]).toContain("%09");
|
||||
});
|
||||
|
||||
test("injection: '; DELETE'-style payload stays inert inside the literal", async () => {
|
||||
const evil = 'x"} ; DELETE WHERE { ?s ?p ?o } ; INSERT DATA { <a> <b> "';
|
||||
const update = await insertFor(evil);
|
||||
// The whole attack survives, escaped, as ONE literal value — the injected
|
||||
// `"}` cannot close the literal/graph, so DELETE/second-INSERT stay text.
|
||||
expect(update).toContain(escapeLiteralRef(evil));
|
||||
// Quote count stays even (all delimiters balanced; the injected `"` escaped):
|
||||
// 4 predicate literals → 8 raw delimiter quotes, nothing extra opened.
|
||||
expect(rawQuoteCount(update)).toBe(8);
|
||||
// The injected `"}` did not survive as raw syntax (it was escaped to `\"}`).
|
||||
expect(update).not.toMatch(/[^\\]"} ; DELETE/);
|
||||
});
|
||||
|
||||
// Local mirror of the lib's escapeLiteral so the assertion is self-checking.
|
||||
function escapeLiteralRef(v: string): string {
|
||||
return `"${v
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/\r/g, "\\r")
|
||||
.replace(/\t/g, "\\t")}"`;
|
||||
}
|
||||
|
||||
test("injection: a malicious id still round-trips through the shim", async () => {
|
||||
const evil = 'eve" ; <urn:evil> "x';
|
||||
const rec = await ensureAccount(evil);
|
||||
expect(rec.id).toBe(evil);
|
||||
resetRegistryCache();
|
||||
// The stored id comes back verbatim (escaping is lossless) when resolved by its
|
||||
// own key — and no injected extra subject answers in its place.
|
||||
const back = await resolveAccount(evil);
|
||||
expect(back?.id).toBe(evil);
|
||||
expect(back?.docPublic).toBe(rec.docPublic);
|
||||
});
|
||||
|
||||
test("normalizeId defaults to trim when not provided", async () => {
|
||||
const ng = makeFakeNg();
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||
configureStoreRegistry({ getSession: async () => SESSION });
|
||||
resetRegistryCache();
|
||||
const a = await ensureAccount(" Ivy ");
|
||||
const b = await ensureAccount("Ivy"); // trimmed key matches
|
||||
expect(b).toEqual(a);
|
||||
expect(ng.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs
|
||||
});
|
||||
|
||||
test("a user has TWO inboxes — public and protected — and they are distinct documents", async () => {
|
||||
// Upstream a site carries an inbox on its public store repo and another on its
|
||||
// protected one (`engine/verifier/src/site.rs:127-152`), addressed separately down to
|
||||
// the contact predicates (`ng:site_inbox` vs `ng:protected_inbox`). Exposing one was a
|
||||
// cardinality this library invented; neither the ORM nor the wasm binding says
|
||||
// anything about inboxes, so the engine's model is what decides.
|
||||
resetRegistryCache();
|
||||
const pub = await userInbox("@dana", "public");
|
||||
const prot = await userInbox("@dana", "protected");
|
||||
expect(pub).not.toBe(prot);
|
||||
// …and each is stable for its own scope.
|
||||
expect(await userInbox("@dana", "public")).toBe(pub);
|
||||
expect(await userInbox("@dana", "protected")).toBe(prot);
|
||||
});
|
||||
Reference in New Issue
Block a user