diff --git a/packages/client/src/caps.ts b/packages/client/src/caps.ts index cf2ddf2..fdc2206 100644 --- a/packages/client/src/caps.ts +++ b/packages/client/src/caps.ts @@ -83,6 +83,11 @@ export class CapRegistry { return this.readers.size > 0 || this.publicDocs.size > 0; } + /** No WRITE policy declared → the write guard stays inert (passthrough). */ + hasWritePolicy(): boolean { + return this.writers.size > 0; + } + clear(): void { this.readers.clear(); this.writers.clear(); diff --git a/packages/client/src/inbox.ts b/packages/client/src/inbox.ts index 51e173d..2e7a35d 100644 --- a/packages/client/src/inbox.ts +++ b/packages/client/src/inbox.ts @@ -1,29 +1,194 @@ /** - * Inbox — client side (deposit only). The MATERIALIZATION of deposits is the - * curator's job (a separate package, deferred — see repo README), never the client's. + * Inbox — the ONE deposit + materialization mechanism, reused for BOTH meeting- + * point registration AND submission to the discovery index (see the discovery- + * model decision: same `inbox.post` API, same watcher). GENERIC by construction: + * this module knows no application domain (no meeting-point, no notification). + * The consumer supplies the inbox document NURI and interprets the `payload`. + * + * ── TARGET vs POLYFILL ──────────────────────────────────────────────────── + * Target: `post` seals a reference into the inbox owner's native inbox + * (`ng.inbox_post_link(...)`), and a SEPARATE curator process (the deferred + * `@ng-eventually/service` package) MATERIALIZES the deposits into the owned + * document. Here, in the shared-wallet polyfill (everything is readable), + * both sides are emulated in-lib: + * - `post` appends a deposit `{ from, payload, ts }` as RDF into the inbox + * DOCUMENT (in the shared wallet) via the `docs.sparqlUpdate` primitive; + * - `read` / `watch` play the CURATOR: they read the deposits back via + * `docs.sparqlQuery` and expose them. This in-client emulation is enough + * for the polyfill — at migration the real materialization moves to the + * separate curator and this read side goes away. + * + * All NextGraph I/O routes through the T01.a `docs` primitives (the REAL + * injected `ng`, never `makeNg`), so this module imports NO `@ng-org` package. */ -import { getConfig, getCurrentUser } from "./polyfill"; -import type { Nuri } from "./types"; +import { sparqlUpdate, sparqlQuery } from "./docs"; +import { getCurrentUser, getStoreRegistryDeps } from "./polyfill"; +import { escapeLiteral } from "./sparql"; +import type { Nuri, PrincipalId } from "./types"; -/** - * Deposit a reference into a document's inbox (anticipated SDK shape). Target: - * `ng.inbox_post_link(...)`, sealed to the inbox owner; `from` optional → the - * sender is identified if known, anonymous otherwise. Polyfill: append a - * deposit to the emulated inbox document. - */ -export async function post( - targetInbox: Nuri, - payload: unknown, - opts?: { anonymous?: boolean }, -): Promise { - const { ng } = getConfig(); - const from = opts?.anonymous ? null : getCurrentUser(); - // TODO(polyfill): append { from, payload } to the emulated inbox document - // (targetInbox) via ng.sparql_update. - void ng; - void from; - void targetInbox; - void payload; - throw new Error("[ng-eventually] inbox.post: polyfill emulation not yet implemented"); +// --- deposit model -------------------------------------------------------- + +/** One deposit as materialized from an inbox document. */ +export interface Deposit { + /** The sender, if identified; `null` when the deposit was anonymous. */ + from: PrincipalId | null; + /** The consumer-defined payload (opaque here — JSON-serialized in storage). */ + payload: unknown; + /** Deposit timestamp (ms epoch). Caller may pass one for determinism. */ + ts: number; +} + +/** Options for {@link post}. `from` and `ts` are both optional. */ +export interface PostOptions { + /** + * Who is depositing. Omit (or pass `null`) for an ANONYMOUS deposit; pass a + * principal id to identify the sender. Defaults to the current polyfill user + * ({@link getCurrentUser}) when the property is entirely absent, so callers + * that want anonymity must pass `from: null` explicitly. + */ + from?: PrincipalId | null; + /** The payload to deposit (interpreted only by the consumer). */ + payload: unknown; + /** Optional deposit timestamp (ms epoch). Omitted → `Date.now()`. Passing it + * keeps tests deterministic. */ + ts?: number; +} + +const SHIM = "urn:ng-eventually:inbox"; +const P = { + type: `${SHIM}:Deposit`, + from: `${SHIM}:from`, + payload: `${SHIM}:payload`, + ts: `${SHIM}:ts`, +} as const; + +// --- session access (shared with the storeRegistry) ----------------------- + +/** The inbox documents live in the shared wallet, so we reuse the registry's + * injected session provider for the sessionId. Disappears at migration. */ +async function sessionId(): Promise { + return (await getStoreRegistryDeps().getSession()).sessionId; +} + +// --- SPARQL result helpers ------------------------------------------------ + +/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */ +function readBindings(result: unknown): Array> { + if (!result) return []; + if (Array.isArray(result)) return result as Array>; + const anyRes = result as { + results?: { bindings?: Array> }; + }; + return anyRes.results?.bindings ?? []; +} + +// --- deposit (client side) ------------------------------------------------ + +/** + * Deposit a payload into `targetInbox`. + * + * Appends `{ from, payload, ts }` into the inbox document via `docs.sparqlUpdate` + * (the real injected `ng`). Each deposit is a fresh RDF subject in the inbox + * graph, so concurrent deposits don't collide. `from` is optional: pass `null` + * for an anonymous deposit; omit it entirely to default to the current user. + */ +export async function post(targetInbox: Nuri, opts: PostOptions): Promise { + const from = opts.from === undefined ? getCurrentUser() : opts.from; + const ts = opts.ts ?? Date.now(); + const sid = await sessionId(); + + // A unique subject per deposit (in the inbox graph) — no collisions. + const subject = `${SHIM}:deposit:${ts}:${Math.random().toString(36).slice(2)}`; + const payloadLiteral = escapeLiteral(JSON.stringify(opts.payload ?? null)); + const fromTriple = + from == null ? "" : ` ;\n <${P.from}> "${escapeLiteral(from)}"`; + + const update = ` + INSERT DATA { + GRAPH <${targetInbox}> { + <${subject}> a <${P.type}> ; + <${P.payload}> "${payloadLiteral}" ; + <${P.ts}> "${ts}"${fromTriple} . + } + }`; + await sparqlUpdate(sid, update, targetInbox); +} + +// --- read / materialize (emulated curator) -------------------------------- + +/** + * Read (materialize) every deposit currently in `targetInbox`, sorted by `ts` + * ascending. This is the EMULATED CURATOR: at migration a separate curator + * process materializes deposits and this in-client read goes away. The consumer + * interprets each deposit's `payload`. + */ +export async function read(targetInbox: Nuri): Promise { + const sid = await sessionId(); + const query = ` + SELECT ?payload ?ts ?from WHERE { + GRAPH <${targetInbox}> { + ?d a <${P.type}> ; + <${P.payload}> ?payload ; + <${P.ts}> ?ts . + OPTIONAL { ?d <${P.from}> ?from } + } + }`; + const result = await sparqlQuery(sid, query, undefined, targetInbox); + const deposits: Deposit[] = []; + for (const row of readBindings(result)) { + const rawPayload = row.payload?.value ?? "null"; + let payload: unknown; + try { + payload = JSON.parse(rawPayload); + } catch { + payload = rawPayload; // tolerate a non-JSON literal + } + const tsRaw = row.ts?.value ?? "0"; + const ts = Number.parseInt(tsRaw, 10) || 0; + const fromValue = row.from?.value; + deposits.push({ from: fromValue ? fromValue : null, payload, ts }); + } + deposits.sort((a, b) => a.ts - b.ts); + return deposits; +} + +/** Alias for {@link read} — the name that reads as "run the curator now". */ +export const materialize = read; + +/** + * Subscription over an inbox — the emulated watcher. Polls {@link read} on an + * interval and invokes `onDeposits` with the full current deposit list whenever + * it changes (grows). Returns an unsubscribe function. The polyfill has no + * native reactive inbox subscription, so this emulates one; at migration it is + * replaced by the real curator's watch. `onDeposits` fires once immediately. + */ +export function watch( + targetInbox: Nuri, + onDeposits: (deposits: Deposit[]) => void, + opts?: { intervalMs?: number }, +): () => void { + let stopped = false; + let lastCount = -1; + const intervalMs = opts?.intervalMs ?? 1000; + + const tick = async (): Promise => { + if (stopped) return; + try { + const deposits = await read(targetInbox); + if (!stopped && deposits.length !== lastCount) { + lastCount = deposits.length; + onDeposits(deposits); + } + } catch (error) { + console.error("[inbox] watch read failed:", error); + } + }; + + void tick(); // fire once immediately + const handle = setInterval(() => void tick(), intervalMs); + return () => { + stopped = true; + clearInterval(handle); + }; } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index ec7b45e..4e5db12 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -23,6 +23,12 @@ export type { Connections, IsolationAccessors } from "./isolation"; export * as accounts from "./accounts"; export type { AccountStorage } from "./accounts"; +// SPARQL injection-safety helpers — so the app can reuse the same escaping / +// validation when it builds SPARQL by interpolation. `escapeLiteral` for string +// literals, `escapeIri` to embed untrusted values in an IRI, `assertNuri` to +// validate trusted-shaped NURIs before embedding them in an IRI. +export { escapeLiteral, escapeIri, assertNuri } from "./sparql"; + // SDK type re-exports — so the app imports these from @ng-eventually/client too, // not from @ng-org. `export type` is ERASED at build, so this adds NO runtime // @ng-org import to the lib (no risk of a duplicate SDK copy in the bundle). diff --git a/packages/client/src/ng-proxy.ts b/packages/client/src/ng-proxy.ts index 384d0d8..27879a9 100644 --- a/packages/client/src/ng-proxy.ts +++ b/packages/client/src/ng-proxy.ts @@ -4,7 +4,8 @@ * surface stays identical to `@ng-org/web`'s `ng`. */ -import { getConfig } from "./polyfill"; +import { getConfig, getCaps, getCurrentUser } from "./polyfill"; +import type { Nuri } from "./types"; export function makeNg(): Record { return new Proxy({} as Record, { @@ -21,10 +22,28 @@ export function makeNg(): Record { } // sparql_update → write guard (emulated write-cap check). + // Mirrors the target broker/verifier: a write is refused unless the wallet + // holds the document's WRITE cap. Emulated per-document via CapRegistry. + // args = (session_id, query, anchor?) — `anchor` is the target doc NURI. if (prop === "sparql_update") { return (...args: any[]) => { - // TODO(polyfill): reject when getCurrentUser() lacks the write grant - // on the target document (see access.canWrite). For now, passthrough. + const anchor = args[2] as Nuri | undefined; + const caps = getCaps(); + // Passthrough (no regression) unless a WRITE policy exists AND this + // specific document is governed by it. Ungoverned docs (mono-store + // default, no cap declared) flow through exactly as before. + if ( + typeof anchor === "string" && + caps.hasWritePolicy() && + caps.governsWrite(anchor) && + !caps.canWrite(anchor, getCurrentUser()) + ) { + return Promise.reject( + new Error( + `[ng-eventually] write denied: current user lacks the write cap for ${anchor}`, + ), + ); + } return ng.sparql_update!(...args); }; } diff --git a/packages/client/src/sparql.ts b/packages/client/src/sparql.ts new file mode 100644 index 0000000..6ff20f4 --- /dev/null +++ b/packages/client/src/sparql.ts @@ -0,0 +1,102 @@ +/** + * SPARQL string-building safety helpers — shared by every module that builds + * SPARQL by interpolation (inbox, store-registry). + * + * WHY THIS EXISTS: SPARQL injection. When an untrusted value (a username, a + * payload) is spliced verbatim into a query, a `"` closes a literal and a `>` + * closes an IRI, letting the value inject arbitrary triples (or wreck the shim + * graph, which is the trust root mapping accounts → document NURIs). Every + * value that reaches a query MUST pass through one of these helpers first. + * + * TWO POSITIONS, TWO STRATEGIES: + * - LITERAL position (`"..."`): {@link escapeLiteral}. We *escape* rather than + * reject because literals legitimately carry arbitrary text (JSON payloads, + * display names). Escaping is lossless and reversible. + * - IRI position (`<...>`): two cases. + * · Trusted-SHAPED NURIs coming back from `ng` (`did:ng:...`): validate + * with {@link assertNuri} — they should never contain IRI-breaking + * chars; if one does, something upstream is wrong, so we throw rather + * than silently build a broken/injected query. + * · UNTRUSTED values embedded into an IRI (a username used to mint an + * account-subject IRI): {@link escapeIri} percent-encodes every + * IRI-hostile character. We *encode* rather than reject so any username + * (spaces, unicode, punctuation) stays usable, while `<`, `>`, `"`, + * whitespace and control chars can never break out of the IRI. + */ + +/** + * Escape a value for embedding inside a SPARQL string literal (`"..."`). + * Escapes backslash, double-quote and the C0 whitespace controls that would + * otherwise terminate or corrupt the literal. Lossless / reversible. + */ +export function escapeLiteral(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t"); +} + +/** + * Delimiter characters that must never appear raw inside a SPARQL/Turtle IRI + * ref (`<...>`): the space plus `< > " { } | ^ backtick \`. Whitespace beyond + * the space and all C0/C1 control characters are handled by the code-point + * check in {@link isIriForbidden}. Any of these would let a value break out of + * the `<...>` and inject arbitrary syntax. + */ +const IRI_FORBIDDEN_DELIMS = /[<>"{}|^`\\ ]/; + +/** True if `ch` (a single code point) may not appear raw inside an IRI ref. */ +function isIriForbidden(ch: string): boolean { + const code = ch.codePointAt(0)!; + return IRI_FORBIDDEN_DELIMS.test(ch) || code < 0x20 || code === 0x7f; +} + +/** + * Percent-encode every IRI-hostile character in `value` so it is safe to embed + * inside a SPARQL IRI ref (``). Use this for + * UNTRUSTED values (e.g. a username minted into an account-subject IRI): + * encoding keeps every username usable while making breakout impossible. + * + * NOTE: this encodes only the delimiter/whitespace/control set, so ordinary + * printable characters (including `:` `/` `.` `-` `_` and unicode letters) pass + * through unchanged and the resulting IRI stays human-readable. + */ +export function escapeIri(value: string): string { + let out = ""; + for (const ch of value) { + if (isIriForbidden(ch)) { + // Percent-encode each UTF-8 byte of the offending character. Also encode + // the chars encodeURIComponent leaves alone but which are IRI-hostile. + out += encodeURIComponent(ch).replace( + /[!'()*]/g, + (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(), + ); + } else { + out += ch; + } + } + return out; +} + +/** + * Assert that `nuri` is safe to embed verbatim inside a SPARQL IRI ref. NURIs + * that come back from `ng` are trusted-SHAPED (`did:ng:...` or `urn:...`) and + * should never carry IRI-breaking characters; if one does, we throw rather than + * emit a query that could be malformed or injected. Returns the value unchanged + * so it can be used inline: `<${assertNuri(doc)}>`. + */ +export function assertNuri(nuri: string): string { + if (typeof nuri !== "string" || nuri.length === 0) { + throw new Error(`[sparql] invalid NURI (empty): ${JSON.stringify(nuri)}`); + } + for (const ch of nuri) { + if (isIriForbidden(ch)) { + throw new Error( + `[sparql] NURI contains IRI-forbidden characters, refusing to embed: ${JSON.stringify(nuri)}`, + ); + } + } + return nuri; +} diff --git a/packages/client/src/store-registry.ts b/packages/client/src/store-registry.ts index 59acc46..91867a7 100644 --- a/packages/client/src/store-registry.ts +++ b/packages/client/src/store-registry.ts @@ -34,6 +34,7 @@ import { docCreate, sparqlUpdate, sparqlQuery } from "./docs"; import { getStoreRegistryDeps } from "./polyfill"; +import { escapeLiteral, escapeIri, assertNuri } from "./sparql"; import type { Nuri, Scope } from "./types"; // --- sharedWalletShim model ---------------------------------------------- @@ -61,7 +62,11 @@ const P = { const INDEX_SUBJECT = `${SHIM}:index`; function accountSubject(username: string): string { - return `${SHIM}:account:${normalize(username)}`; + // The username is UNTRUSTED and lands in an IRI position. Percent-encode it + // (escapeIri) so no `>` / `"` / whitespace / control char can break out of + // the `<...>` and inject triples into the shim graph (the account→doc trust + // root). normalize() runs first so the subject stays stable per shim key. + return `${SHIM}:account:${escapeIri(normalize(username))}`; } // --- session / normalization access (injected by the consumer) ------------ @@ -123,7 +128,7 @@ export async function loadShim(): Promise> { const anchor = await anchorNuri(); const query = ` SELECT ?username ?docPublic ?docProtected ?docPrivate WHERE { - GRAPH <${anchor}> { + GRAPH <${assertNuri(anchor)}> { ?acc a <${P.type}> ; <${P.username}> ?username ; <${P.docPublic}> ?docPublic ; @@ -184,14 +189,18 @@ export async function ensureAccount(username: string): Promise { const s = await session(); const anchor = await anchorNuri(); const subj = accountSubject(username); + // `subj` is already IRI-safe (accountSubject → escapeIri). `anchor` is a + // trusted-shaped NURI → assertNuri. `username` is UNTRUSTED text in a LITERAL + // position → escapeLiteral. The doc NURIs come from `ng` but are stored as + // literals here, so they are escaped as literals too (defence in depth). const update = ` INSERT DATA { - GRAPH <${anchor}> { + GRAPH <${assertNuri(anchor)}> { <${subj}> a <${P.type}> ; - <${P.username}> "${username}" ; - <${P.docPublic}> "${docPublic}" ; - <${P.docProtected}> "${docProtected}" ; - <${P.docPrivate}> "${docPrivate}" . + <${P.username}> "${escapeLiteral(username)}" ; + <${P.docPublic}> "${escapeLiteral(docPublic)}" ; + <${P.docProtected}> "${escapeLiteral(docProtected)}" ; + <${P.docPrivate}> "${escapeLiteral(docPrivate)}" . } }`; try { @@ -246,7 +255,9 @@ export async function createEntityDoc(username: string, scope: Scope): Promise { <${INDEX_SUBJECT}> <${P.contains}> "${entityNuri}" } }`, + // indexDoc is a NURI in IRI position → assertNuri; entityNuri is a NURI + // stored as a literal → escapeLiteral. + `INSERT DATA { GRAPH <${assertNuri(indexDoc)}> { <${INDEX_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" } }`, indexDoc, ); } catch (error) { @@ -269,7 +280,7 @@ export async function listEntityDocs(scope: Scope): Promise { try { const res = await sparqlQuery( s.sessionId, - `SELECT ?e WHERE { GRAPH <${indexDoc}> { <${INDEX_SUBJECT}> <${P.contains}> ?e } }`, + `SELECT ?e WHERE { GRAPH <${assertNuri(indexDoc)}> { <${INDEX_SUBJECT}> <${P.contains}> ?e } }`, undefined, indexDoc, ); diff --git a/packages/client/test/caps.test.ts b/packages/client/test/caps.test.ts index 5a6b727..9803900 100644 --- a/packages/client/test/caps.test.ts +++ b/packages/client/test/caps.test.ts @@ -48,3 +48,17 @@ test("governsRead / hasReadPolicy distinguish governed from ungoverned documents expect(caps.governsRead("did:ng:o:doc1")).toBe(true); expect(caps.governsRead("did:ng:o:unknown")).toBe(false); // not declared → not enforced }); + +test("governsWrite / hasWritePolicy distinguish governed from ungoverned documents", () => { + const caps = new CapRegistry(); + expect(caps.hasWritePolicy()).toBe(false); + caps.open("did:ng:o:doc1", "private", "alice"); // owner gets the write cap + expect(caps.hasWritePolicy()).toBe(true); + expect(caps.governsWrite("did:ng:o:doc1")).toBe(true); + expect(caps.governsWrite("did:ng:o:unknown")).toBe(false); // not declared → not enforced + // A public doc grants read to all but its write cap is still owner-only. + const pub = new CapRegistry(); + pub.open("did:ng:o:pub", "public", "alice"); + expect(pub.hasWritePolicy()).toBe(true); + expect(pub.governsWrite("did:ng:o:pub")).toBe(true); +}); diff --git a/packages/client/test/inbox.test.ts b/packages/client/test/inbox.test.ts new file mode 100644 index 0000000..f10e7cf --- /dev/null +++ b/packages/client/test/inbox.test.ts @@ -0,0 +1,206 @@ +import { test, expect, mock, beforeEach, afterAll } from "bun:test"; +import { post, read, materialize, watch } from "../src/inbox"; +import type { Deposit } from "../src/inbox"; +import { + configure, + configureStoreRegistry, + resetStoreRegistry, + resetConfig, + setCurrentUser, +} from "../src/polyfill"; +import type { RegistrySession } from "../src/store-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[] = []; + + const doc_create = mock(async (..._a: unknown[]) => "did:ng:o:new"); + + // Parses one deposit: ` a ; "..." ; "..." [; "..."] .` + 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); + // predicate/object pairs: `a ` or `

"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 }); + } + return undefined; + }); + + const sparql_query = mock(async (...a: unknown[]) => { + const anchor = a[3] as string | undefined; + const bySubject = new Map>(); + 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 = { + 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, sparql_update, sparql_query, _quads: quads }; +} + +const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" }; +const TARGET = "did:ng:o:host-inbox"; + +function inject() { + const ng = makeFakeNg(); + configure({ ng: ng as any, useShape: (() => {}) as any }); + configureStoreRegistry({ getSession: async () => SESSION }); + setCurrentUser(null); + return ng; +} + +let fake: ReturnType; +beforeEach(() => { + fake = inject(); +}); + +test("post writes via the real injected ng.sparql_update (not makeNg), scoped to the inbox", async () => { + await post(TARGET, { from: "alice", payload: { kind: "join" }, ts: 100 }); + expect(fake.sparql_update).toHaveBeenCalledTimes(1); + const call = fake.sparql_update.mock.calls[0]!; + expect(call[0]).toBe("sid-1"); // sessionId from the injected session + expect(call[2]).toBe(TARGET); // anchored to the target inbox + expect(call[1] as string).toContain(`GRAPH <${TARGET}>`); +}); + +test("post → read round-trips payload, from and ts", async () => { + 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 }); +}); + +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 +}); diff --git a/packages/client/test/isolation-active.test.ts b/packages/client/test/isolation-active.test.ts new file mode 100644 index 0000000..4bf2e64 --- /dev/null +++ b/packages/client/test/isolation-active.test.ts @@ -0,0 +1,81 @@ +/** + * ReadCap ACTIVE — end-to-end proof that isolation is enforced by the emulated + * cap registry (not merely by the app's social isolation filter). + * + * This mirrors exactly what the app's storeRegistry wrapper does: create an + * entity document through the REAL registry (`createEntityDoc`), then declare + * its cap policy via `getCaps().open(doc, scope, owner)`. The read filter then + * hides one owner's private document from another principal — the faithful + * per-DOCUMENT NextGraph behavior. + */ +import { test, expect, mock, afterAll } from "bun:test"; +import { createEntityDoc, resetRegistryCache } from "../src/store-registry"; +import type { RegistrySession } from "../src/store-registry"; +import { + configure, + configureStoreRegistry, + resetStoreRegistry, + resetConfig, + getCaps, + resetCaps, +} from "../src/polyfill"; +import { filterReadable } from "../src/read-filter"; + +afterAll(() => { + resetConfig(); + resetStoreRegistry(); + resetCaps(); +}); + +const SESSION: RegistrySession = { sessionId: "sid", privateStoreId: "PRIV" }; + +function inject() { + let n = 0; + const ng = { + doc_create: mock(async () => `did:ng:o:doc${++n}`), + sparql_update: mock(async () => undefined), + sparql_query: mock(async () => ({ results: { bindings: [] } })), + }; + configure({ ng: ng as any, useShape: (() => {}) as any }); + configureStoreRegistry({ getSession: async () => SESSION, normalizeUser: (u) => u.trim() }); + resetRegistryCache(); + resetCaps(); + return ng; +} + +test("ReadCap active: a private entity doc created via the real registry is hidden from another principal", async () => { + inject(); + + // Alice creates a PRIVATE entity document via the REAL store-registry, then + // (as the app wrapper does) declares its cap policy: owner-only read. + const aliceDoc = await createEntityDoc("alice", "private"); + getCaps().open(aliceDoc, "private", "alice"); + + // Bob creates a PUBLIC entity document (world-readable). + const bobDoc = await createEntityDoc("bob", "public"); + getCaps().open(bobDoc, "public", "bob"); + + // The reactive set as the broker would deliver it (mono-store: items carry + // their @graph = the document they live in). + const items = [ + { "@graph": aliceDoc, "@id": "a1", label: "alice-private" }, + { "@graph": bobDoc, "@id": "b1", label: "bob-public" }, + ]; + + // Bob cannot read alice's private doc; he CAN read the public one and, since + // the read filter is now under a policy, alice's private item is filtered out. + const bobView = filterReadable(items, getCaps(), "bob").map((i) => i["@id"]); + expect(bobView).toEqual(["b1"]); + + // Alice reads her own private doc AND the public one. + const aliceView = filterReadable(items, getCaps(), "alice").map((i) => i["@id"]); + expect(aliceView.sort()).toEqual(["a1", "b1"]); + + // Anonymous sees only the public doc. + const anonView = filterReadable(items, getCaps(), null).map((i) => i["@id"]); + expect(anonView).toEqual(["b1"]); + + // Sanity: this is the REGISTRY talking, not app-level isolation — the caps + // registry has an active read policy. + expect(getCaps().hasReadPolicy()).toBe(true); +}); diff --git a/packages/client/test/ng-proxy.test.ts b/packages/client/test/ng-proxy.test.ts new file mode 100644 index 0000000..0f5c458 --- /dev/null +++ b/packages/client/test/ng-proxy.test.ts @@ -0,0 +1,88 @@ +import { test, expect, mock, afterEach } from "bun:test"; +import { makeNg } from "../src/ng-proxy"; +import { + configure, + resetConfig, + getCaps, + resetCaps, + setCurrentUser, +} from "../src/polyfill"; + +// This suite injects a fake `ng` via configure() and declares write caps. Reset +// both after each test so the docs.test.ts "not configured" guard still holds +// and no cap policy 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}> {

} }`; + +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().open("did:ng:o:other", "private", "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().open(DOC, "private", "alice"); // alice holds 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().open(DOC, "public", "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().open(DOC, "private", "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().open(DOC, "private", "alice"); + setCurrentUser("bob"); + const proxy = makeNg(); + await proxy.sparql_update("sid", "INSERT DATA {}"); // no anchor → passthrough + expect(ng.sparql_update).toHaveBeenCalledTimes(1); +}); diff --git a/packages/client/test/sparql.test.ts b/packages/client/test/sparql.test.ts new file mode 100644 index 0000000..db934bd --- /dev/null +++ b/packages/client/test/sparql.test.ts @@ -0,0 +1,78 @@ +import { test, expect } from "bun:test"; +import { escapeLiteral, escapeIri, assertNuri } from "../src/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 = '" ; "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 { + const attack = 'x> "pwn'; + const encoded = escapeIri(attack); + // The encoded username cannot contain a raw `>`, `<`, `"` or space, so it + // cannot escape the surrounding 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> 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/); +}); diff --git a/packages/client/test/store-registry.test.ts b/packages/client/test/store-registry.test.ts index daae55f..b1da1d7 100644 --- a/packages/client/test/store-registry.test.ts +++ b/packages/client/test/store-registry.test.ts @@ -43,13 +43,31 @@ test("throws a clear error when configureStoreRegistry() was not called", async 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 {

"o"//;-lists } }`. + // Parses `INSERT DATA { GRAPH {

"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 gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/); @@ -60,16 +78,14 @@ function makeFakeNg() { const sm = body.match(/<([^>]+)>/); if (!sm) return undefined; const s = sm[1]!; - // Predicate/object pairs: `

"o"` or `

` (a == rdf:type ignored form - // handled as literal-free; here the registry only uses

"literal" and - //

and `a `). - const pairRe = /(?:a|<([^>]+)>)\s+(?:"([^"]*)"|<([^>]+)>)/g; + // Predicate/object pairs: `

"o"` (escape-aware) or `

`; `a `. + 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] ?? m[3] ?? ""; + const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? ""); quads.push({ g, s, p, o }); } return undefined; @@ -190,6 +206,97 @@ test("allAccounts reflects every ensured account", async () => { expect(names).toEqual(["Gina", "Hank"]); }); +// --- SPARQL injection hardening (F1) -------------------------------------- +// +// A malicious username 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 `username`. */ +async function insertFor(username: string): Promise { + await ensureAccount(username); + 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: username with a quote cannot open extra literals", async () => { + const evil = 'x" ; "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 username is present as a single literal value — the injected + // `` survives only as INERT text inside that literal (its + // surrounding quotes are escaped `\"`), never as query syntax. + expect(update).toContain('"x\\" ; \\"pwn"'); +}); + +test("injection: username with '>' cannot break out of the account-subject IRI", async () => { + const evil = "x> ` — the encoded + // username must NOT contain a raw `>` that would close the IRI early. + const subjMatch = update.match(/]*)>/)!; + 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 username 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(/]*)>/)!; + 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 { "'; + 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 username still round-trips through the shim", async () => { + const evil = 'eve" ; "x'; + const rec = await ensureAccount(evil); + expect(rec.username).toBe(evil); + resetRegistryCache(); + const map = await loadShim(); + // The stored username 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)?.username).toBe(evil); + expect(map.size).toBe(1); +}); + test("normalizeUser defaults to trim when not provided", async () => { const ng = makeFakeNg(); configure({ ng: ng as any, useShape: (() => {}) as any });