Files
ng-eventually/packages/client/src/inbox.ts
T
Sylvain Duchesne d804a436d7 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>
2026-07-03 15:51:00 +02:00

195 lines
7.6 KiB
TypeScript

/**
* 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 { sparqlUpdate, sparqlQuery } from "./docs";
import { getCurrentUser, getStoreRegistryDeps } from "./polyfill";
import { escapeLiteral } from "./sparql";
import type { Nuri, PrincipalId } from "./types";
// --- 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);
};
}