bf753770b8
The ReadCap filter now enforces on per-entity documents (consumers create one doc per entity, so each has a declared policy — private→owner, protected→owner+ connections, public→all). Isolation is genuinely active, not dormant. - connections.ts (new): a BILATERAL connection registry — a link grants protected read only when BOTH sides have asserted it (each assertion bound to its author). A unilateral/self-declared connection grants nothing (closes the confused-deputy hole). declareConnections is authenticated to the current identity. - inbox.post: `from` is bound to the current identity — a spoofed `from` throws. - discovery.submitToIndex: PUBLIC-ONLY — a governed non-public doc is refused (no protected/private leak into the world-readable index). - docs/simulation.md: documents this as application-level emulated isolation on a shared wallet (not crypto); at NextGraph maturity → real caps, consumer unchanged. 89 tests pass (+10 covering: active protected isolation via bilateral connect, unilateral grants nothing, from-spoof rejected, non-public submit refused). tsc rc=0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
215 lines
8.5 KiB
TypeScript
215 lines
8.5 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 BOUND TO THE CURRENT IDENTITY — it is authenticated, not
|
|
* caller-supplied. Omit it to stamp the current user; pass `null` to deposit
|
|
* ANONYMOUSLY (a legitimate choice — "identified if known, anonymous otherwise").
|
|
* A `from` naming ANOTHER principal is a SPOOF and is REJECTED: in the target the
|
|
* broker seals the sender from the wallet's own key, so a client cannot forge
|
|
* another's identity. (At migration this check is redundant — the seal enforces
|
|
* it — but until then it closes the spoof the shared wallet would otherwise allow.)
|
|
*/
|
|
export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void> {
|
|
const current = getCurrentUser();
|
|
let from: PrincipalId | null;
|
|
if (opts.from === undefined) {
|
|
from = current; // default: stamp the current identity
|
|
} else if (opts.from === null) {
|
|
from = null; // explicit anonymous deposit
|
|
} else if (opts.from === current) {
|
|
from = opts.from; // identifying as self — allowed
|
|
} else {
|
|
throw new Error(
|
|
"[ng-eventually] inbox.post: `from` must be the current identity or null " +
|
|
"(anonymous) — depositing as another principal is a spoof.",
|
|
);
|
|
}
|
|
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);
|
|
};
|
|
}
|