feat(client): OFF-by-default document access log, prefixed by active identity

Observability probe for the shared-wallet isolation footgun: on one physical
wallet several virtual identities coexist, and a read must never surface a doc
scoped to another identity. When it does (B reading A's doc), the leak is
invisible in the data — it looks like a normal read. This makes it VISIBLE.

Every real read/write is logged, prefixed by the ACTIVE virtual identity
(getCurrentUser → the account the op is scoped under, NOT the constant shared
physical wallet id). Reads append the row count — a strong leak signal:

  [urn:festipod:user:bob] READ did:ng:o:docA (readDoc) → 3 rows

Instrumented at the LOW common point in docs.ts: every read routes through
sparqlQuery, every write through sparqlUpdate, container creation through
docCreate. Callers pass a semantic label (readDoc|readUnion|listMyEntityDocs|
writeEntity|deposit|…) that is a lib-internal probe param, NOT forwarded to the
real `ng` (preserves docs.test.ts exact-forwarding assertions).

OFF by default → one boolean read on the hot path, zero output. On via
configure({ debugAccessLog: true }) or env NG_EVENTUALLY_ACCESS_LOG=1 (no code
change). Polyfill-era; removed at the real multi-store migration.

tsc --noEmit: 0 errors. bun test: 91 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-07 21:20:47 +02:00
parent dd1313258f
commit d8c36bac3b
6 changed files with 133 additions and 7 deletions
+35 -2
View File
@@ -14,8 +14,26 @@
*/
import { getConfig } from "./polyfill";
import { logAccess, enabled as accessLogEnabled } from "./access-log";
import type { Nuri } from "./types";
// The low common point for ALL document access: every read in the SDK routes
// through `sparqlQuery`, every write through `sparqlUpdate` (+ container creation
// through `docCreate`) — each ultimately calling the real injected `ng` here. The
// access log is therefore instrumented HERE so no access path escapes it. Callers
// pass a semantic `label` (readDoc|readUnion|listMyEntityDocs|writeEntity|deposit
// |…); it is a lib-internal probe param, NOT forwarded to the real `ng` (the docs
// primitives forward the exact SDK signature — see test/docs.test.ts). When the
// log is OFF (default) the extra param is inert and costs one boolean read.
/** Count rows in a raw SPARQL SELECT result, tolerant of the possible shapes. */
function rowCount(result: unknown): number {
if (!result) return 0;
if (Array.isArray(result)) return result.length;
const anyRes = result as { results?: { bindings?: unknown[] } };
return anyRes.results?.bindings?.length ?? 0;
}
/**
* Create one document → its NURI.
*
@@ -31,7 +49,10 @@ export async function docCreate(
store?: unknown,
): Promise<Nuri> {
const { ng } = getConfig();
return ng.doc_create(sessionId, crdt, cls, dest, store);
const nuri = await ng.doc_create(sessionId, crdt, cls, dest, store);
// A container creation is a WRITE; the NURI only exists after the call.
logAccess("WRITE", nuri, "docCreate");
return nuri;
}
/**
@@ -44,8 +65,11 @@ export async function sparqlUpdate(
sessionId: string,
query: string,
anchor?: Nuri,
label = "sparqlUpdate",
): Promise<void> {
const { ng } = getConfig();
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
logAccess("WRITE", anchor ?? "(no anchor)", label);
return ng.sparql_update(sessionId, query, anchor);
}
@@ -60,7 +84,16 @@ export async function sparqlQuery(
query: string,
base?: string,
anchor?: Nuri,
label = "sparqlQuery",
): Promise<unknown> {
const { ng } = getConfig();
return ng.sparql_query(sessionId, query, base, anchor);
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
const result = await ng.sparql_query(sessionId, query, base, anchor);
// Log AFTER the read so the row count (a strong leak signal: a doc rendering
// rows under an identity that should see nothing) can be appended. Skip the
// rowCount work entirely when the log is off.
if (accessLogEnabled()) {
logAccess("READ", anchor ?? "(no anchor)", label, " → " + rowCount(result) + " rows");
}
return result;
}