feat(client): inbox mechanism, write-guard, SPARQL injection hardening

Polyfill capabilities landed for Festipod's T02 features (all generic,
zero-domain — the consumer injects the domain).

- inbox: implement the previously-stubbed namespace. post(target,{from?,
  payload,ts?}) deposits {from,payload,ts} as RDF via docs.sparqlUpdate (the
  real injected ng, never makeNg); read/materialize + watch emulate the curator
  in-lib (deposits read via docs.sparqlQuery). `from` optional = anonymity.
- write-guard: caps.hasWritePolicy() + ng-proxy.sparql_update rejects when the
  target doc is under a write policy and the current user lacks its write cap;
  passthrough otherwise (no regression). Read-cap registry unchanged.
- sparql.ts (new): escapeLiteral / escapeIri / assertNuri, exported from index.
  store-registry now escapes every literal and validates/encodes every IRI-
  position value — closes a SPARQL-injection hole where an untrusted username
  could inject triples into the shim (the account→doc-NURI trust root).

Tests: inbox, sparql (incl. injection), ng-proxy write-guard, isolation-active.
68 tests pass; tsc --noEmit rc=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-03 15:51:00 +02:00
parent 654cb90d99
commit d804a436d7
12 changed files with 924 additions and 42 deletions
+5
View File
@@ -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();
+189 -24
View File
@@ -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<void> {
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<string> {
return (await getStoreRegistryDeps().getSession()).sessionId;
}
// --- SPARQL result helpers ------------------------------------------------
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
function readBindings(result: unknown): Array<Record<string, { value: string }>> {
if (!result) return [];
if (Array.isArray(result)) return result as Array<Record<string, { value: string }>>;
const anyRes = result as {
results?: { bindings?: Array<Record<string, { value: string }>> };
};
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<void> {
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<Deposit[]> {
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<void> => {
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);
};
}
+6
View File
@@ -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).
+22 -3
View File
@@ -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<string, any> {
return new Proxy({} as Record<string, any>, {
@@ -21,10 +22,28 @@ export function makeNg(): Record<string, any> {
}
// 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);
};
}
+102
View File
@@ -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 (`<PREFIX:${escapeIri(value)}>`). 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;
}
+20 -9
View File
@@ -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<Map<string, AccountRecord>> {
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<AccountRecord> {
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<N
try {
await sparqlUpdate(
s.sessionId,
`INSERT DATA { GRAPH <${indexDoc}> { <${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<Nuri[]> {
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,
);