ae9c32e271
Two batches, verified against nextgraph-rs throughout. P1a — the capability surface. Reading was an ACL (Map<doc, Set<principal>>), the exact inversion of key possession. It is now possession: `capFor(nuri)` is the only question, there is no principal parameter anywhere, and nothing turns a bare reference into a cap. Sharing is `shareCap(cap, toInbox)`, a Link deposit; receiving needs no operation. `Nuri` and `ReadCap` are template literal types, so passing a bare reference where a cap belongs is a compile error, with runtime guards behind it for JavaScript callers. The virtual user boundary. Every access function is now confined to the connected user, through two rules on one criterion (possession), implemented in two places so a lapse in either is caught by the other: authorization at the passage points, and "do not even attempt" at the callers. The polyfill's own machinery moved to physical.ts — unguarded, never exported — which replaced an exemption list: the machinery no longer gets waved through the guard, it calls something the guard never saw. Removed, as emulating capabilities the target does not have: - discovery.ts and its global index. There is no discovery in NextGraph; you follow links. It also pooled user data across wallets. - the cross-account fan-out (listEntityDocs, resolveReadGraphs, allAccounts, loadShim), which was cross-user enumeration by construction. - resolveInboxAnchor, a single inbox common to every user. Caps are now stored where NextGraph stores them, and read back rather than recomputed: AddRepo on the store's Store branch for documents a user creates, AddLink on its User branch for caps received. Inboxes belong to someone — the user's own, plus one per document — and connecting a user drains them all; that is the library's job, not the app's. Corrections worth recording: a ReadCap is `r:`, not `:k:` (reported by NextGraph's developer, verified in BlockRef::readcap_nuri); received caps DO have a register (AddLink), contrary to what this repo's notes claimed; and "wallet" upstream means keyring — what owns three stores is a user, so the vocabulary follows. The cap value is the constant OK: the only question the emulation answers is whether a cap is held. P1b replaces that one constant with a real key. After this the shape is right and the isolation is still fake. Nothing here may be described as anonymous or private.
300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
/**
|
|
* access-log.test.ts — behavioral tests for logAccess / setAccessLog / enabled
|
|
* (src/access-log.ts), as wired through the docs primitives (src/docs.ts).
|
|
*
|
|
* Tests:
|
|
* (a) OFF by default: reads + writes via sparqlQuery / sparqlUpdate / docCreate
|
|
* emit nothing to console.log.
|
|
* (b) ON via configure({ debugAccessLog: true }): each read/write emits a line
|
|
* matching `[<identity>][polyfill] READ/WRITE <shortNuri> (<label>)` (identity
|
|
* FIRST, `[polyfill]` glued right after) plus row-count suffix on READs. The
|
|
* NURI is shortened by shortNuri (did:ng:o: prefix + :v: suffix stripped,
|
|
* RepoID truncated to 8 chars + ellipsis).
|
|
* (c) ON via env var NG_EVENTUALLY_ACCESS_LOG=1: same behavior without changing
|
|
* calling code.
|
|
* (d) Identity follows setCurrentUser: after setCurrentUser the prefix changes.
|
|
*
|
|
* Spy approach: replace console.log with a mock, restore it after each test.
|
|
* Env var tests set/delete process.env.NG_EVENTUALLY_ACCESS_LOG and restore it.
|
|
*/
|
|
|
|
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test";
|
|
import { setAccessLog, enabled, shortNuri } from "../src/access-log";
|
|
import { docCreate, sparqlUpdate, sparqlQuery } from "../src/docs";
|
|
import {
|
|
configure,
|
|
configureStoreRegistry,
|
|
resetStoreRegistry,
|
|
resetConfig,
|
|
setCurrentUser,
|
|
getCurrentUser,
|
|
resetCaps,
|
|
connectedUser,
|
|
} from "../src/polyfill";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function fakeNg() {
|
|
return {
|
|
doc_create: mock(async (..._a: unknown[]) => "did:ng:o:log-doc"),
|
|
sparql_update: mock(async (..._a: unknown[]) => undefined),
|
|
sparql_query: mock(async (..._a: unknown[]) => ({
|
|
results: { bindings: [{ x: { value: "v" } }] }, // 1 row so row-count is visible
|
|
})),
|
|
};
|
|
}
|
|
|
|
function injectFake(debugAccessLog = false) {
|
|
const ng = fakeNg();
|
|
configure({ ng: ng as any, useShape: (() => {}) as any, debugAccessLog });
|
|
configureStoreRegistry({
|
|
getSession: async () => ({ sessionId: "sid-log", privateStoreId: "P" }),
|
|
});
|
|
return ng;
|
|
}
|
|
|
|
// Capture console.log lines for the duration of a test.
|
|
// Returns the captured lines array and a restore function.
|
|
function spyConsoleLog(): { lines: string[]; restore: () => void } {
|
|
const lines: string[] = [];
|
|
const orig = console.log;
|
|
console.log = (...args: unknown[]) => {
|
|
lines.push(args.map(String).join(" "));
|
|
};
|
|
return { lines, restore: () => { console.log = orig; } };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lifecycle: restore config state after each test to avoid cross-test bleed.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Save the env var value that was present BEFORE any test ran, so tests
|
|
// that run in an environment where NG_EVENTUALLY_ACCESS_LOG is already set
|
|
// don't permanently destroy that value.
|
|
const _originalEnvVar = process.env?.NG_EVENTUALLY_ACCESS_LOG;
|
|
|
|
afterEach(() => {
|
|
setAccessLog(false); // always reset the config toggle
|
|
setCurrentUser(null); // clear active identity
|
|
// Restore the original env var value (don't just delete — it may have existed before)
|
|
if ((globalThis as any)?.process?.env) {
|
|
if (_originalEnvVar === undefined) {
|
|
delete process.env.NG_EVENTUALLY_ACCESS_LOG;
|
|
} else {
|
|
process.env.NG_EVENTUALLY_ACCESS_LOG = _originalEnvVar;
|
|
}
|
|
}
|
|
});
|
|
|
|
afterAll(() => {
|
|
resetConfig();
|
|
resetStoreRegistry();
|
|
setAccessLog(false);
|
|
setCurrentUser(null);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Two process-wide things bite this suite, which only wants to watch the log:
|
|
// - the cap registry: once ANY cap exists the reach guard applies to every reader;
|
|
// - connecting a user does WORK (restore + drain its inbox, see connect.ts), which
|
|
// both logs and files caps, asynchronously.
|
|
// So: let any in-flight connection finish, THEN clear. Awaiting rather than hoping
|
|
// is what makes this deterministic — `setCurrentUser` is fire-and-forget by design.
|
|
beforeEach(async () => {
|
|
await connectedUser();
|
|
resetCaps();
|
|
});
|
|
|
|
describe("access-log: OFF by default", () => {
|
|
beforeEach(() => {
|
|
// Force the env var OFF for these tests, regardless of the shell environment.
|
|
if ((globalThis as any)?.process?.env) {
|
|
delete process.env.NG_EVENTUALLY_ACCESS_LOG;
|
|
}
|
|
setAccessLog(false);
|
|
});
|
|
|
|
it("no console.log output for sparqlQuery when disabled", async () => {
|
|
injectFake(false);
|
|
const { lines, restore } = spyConsoleLog();
|
|
try {
|
|
await sparqlQuery("sid-log", "SELECT * {}");
|
|
} finally {
|
|
restore();
|
|
}
|
|
expect(lines.length).toBe(0);
|
|
});
|
|
|
|
it("no console.log output for sparqlUpdate when disabled", async () => {
|
|
injectFake(false);
|
|
const { lines, restore } = spyConsoleLog();
|
|
try {
|
|
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:x");
|
|
} finally {
|
|
restore();
|
|
}
|
|
expect(lines.length).toBe(0);
|
|
});
|
|
|
|
it("no console.log output for docCreate when disabled", async () => {
|
|
injectFake(false);
|
|
const { lines, restore } = spyConsoleLog();
|
|
try {
|
|
await docCreate("sid-log", "Graph", "data:graph", "store");
|
|
} finally {
|
|
restore();
|
|
}
|
|
expect(lines.length).toBe(0);
|
|
});
|
|
|
|
it("enabled() returns false when disabled", () => {
|
|
setAccessLog(false);
|
|
if (process.env) delete process.env.NG_EVENTUALLY_ACCESS_LOG;
|
|
expect(enabled()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("access-log: ON via configure({ debugAccessLog: true })", () => {
|
|
beforeEach(async () => {
|
|
setCurrentUser("alice");
|
|
await connectedUser(); // drain the connection work before counting log lines
|
|
resetCaps();
|
|
});
|
|
|
|
it("sparqlQuery emits a READ line with identity, nuri, label, and row-count", async () => {
|
|
injectFake(true);
|
|
setCurrentUser("alice");
|
|
const { lines, restore } = spyConsoleLog();
|
|
try {
|
|
await sparqlQuery("sid-log", "SELECT * {}", undefined, "did:ng:o:q", "myLabel");
|
|
} finally {
|
|
restore();
|
|
}
|
|
expect(lines.length).toBe(1);
|
|
expect(lines[0]).toMatch(/^\[alice\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
|
expect(lines[0]).toMatch(/READ/);
|
|
expect(lines[0]).toContain(shortNuri("did:ng:o:q")); // NURI shortened
|
|
expect(lines[0]).not.toContain("did:ng:o:"); // full prefix stripped
|
|
expect(lines[0]).toMatch(/myLabel/);
|
|
expect(lines[0]).toMatch(/→ 1 triple-rows/); // triple-count from the 1-row fake result
|
|
});
|
|
|
|
it("sparqlUpdate emits a WRITE line with identity, anchor nuri, and label", async () => {
|
|
injectFake(true);
|
|
setCurrentUser("alice");
|
|
const { lines, restore } = spyConsoleLog();
|
|
try {
|
|
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:w", "writeLabel");
|
|
} finally {
|
|
restore();
|
|
}
|
|
expect(lines.length).toBe(1);
|
|
expect(lines[0]).toMatch(/^\[alice\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
|
expect(lines[0]).toMatch(/WRITE/);
|
|
expect(lines[0]).toContain(shortNuri("did:ng:o:w"));
|
|
expect(lines[0]).toMatch(/writeLabel/);
|
|
});
|
|
|
|
it("docCreate emits a WRITE line with identity and the returned nuri", async () => {
|
|
injectFake(true);
|
|
setCurrentUser("alice");
|
|
const { lines, restore } = spyConsoleLog();
|
|
try {
|
|
await docCreate("sid-log", "Graph", "data:graph", "store");
|
|
} finally {
|
|
restore();
|
|
}
|
|
expect(lines.length).toBe(1);
|
|
expect(lines[0]).toMatch(/^\[alice\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
|
expect(lines[0]).toMatch(/WRITE/);
|
|
// The nuri is the value returned by ng.doc_create, shortened by shortNuri.
|
|
expect(lines[0]).toContain(shortNuri("did:ng:o:log-doc"));
|
|
});
|
|
|
|
it("enabled() returns true when set via setAccessLog", () => {
|
|
setAccessLog(true);
|
|
expect(enabled()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("access-log: ON via env var NG_EVENTUALLY_ACCESS_LOG=1", () => {
|
|
it("emits READ line when env var is set, even without configure() setting", async () => {
|
|
// Set env var but do NOT pass debugAccessLog=true to configure
|
|
if (!(globalThis as any)?.process?.env) return; // skip in env-less runtimes
|
|
process.env.NG_EVENTUALLY_ACCESS_LOG = "1";
|
|
injectFake(false); // debugAccessLog = false explicitly
|
|
setCurrentUser("bob");
|
|
const { lines, restore } = spyConsoleLog();
|
|
try {
|
|
await sparqlQuery("sid-log", "SELECT * {}", undefined, "did:ng:o:env-q");
|
|
} finally {
|
|
restore();
|
|
}
|
|
expect(lines.length).toBe(1);
|
|
expect(lines[0]).toMatch(/^\[bob\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
|
expect(lines[0]).toMatch(/READ/);
|
|
expect(lines[0]).toContain(shortNuri("did:ng:o:env-q"));
|
|
});
|
|
|
|
it("env var NG_EVENTUALLY_ACCESS_LOG=true also enables the log", async () => {
|
|
if (!(globalThis as any)?.process?.env) return;
|
|
process.env.NG_EVENTUALLY_ACCESS_LOG = "true";
|
|
injectFake(false);
|
|
setCurrentUser("charlie");
|
|
const { lines, restore } = spyConsoleLog();
|
|
try {
|
|
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:env-w");
|
|
} finally {
|
|
restore();
|
|
}
|
|
expect(lines.length).toBe(1);
|
|
expect(lines[0]).toMatch(/^\[charlie\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
|
expect(lines[0]).toMatch(/WRITE/);
|
|
});
|
|
|
|
it("enabled() returns true when env var is set", () => {
|
|
if (!(globalThis as any)?.process?.env) return;
|
|
process.env.NG_EVENTUALLY_ACCESS_LOG = "1";
|
|
setAccessLog(false); // config toggle is off
|
|
expect(enabled()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("access-log: identity follows setCurrentUser", () => {
|
|
it("prefix changes after setCurrentUser", async () => {
|
|
injectFake(true);
|
|
setCurrentUser("first-user");
|
|
const { lines, restore } = spyConsoleLog();
|
|
try {
|
|
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:id1", "step1");
|
|
setCurrentUser("second-user");
|
|
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:id2", "step2");
|
|
} finally {
|
|
restore();
|
|
}
|
|
// Only this test's own lines: connecting a user legitimately logs its own reads.
|
|
const mine = lines.filter((l) => l.includes("step1") || l.includes("step2"));
|
|
expect(mine.length).toBe(2);
|
|
expect(mine[0]).toMatch(/^\[first-user\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
|
expect(mine[1]).toMatch(/^\[second-user\]\[polyfill\] /);
|
|
});
|
|
|
|
it("prefix is (none) when no identity is set", async () => {
|
|
injectFake(true);
|
|
setCurrentUser(null); // no active identity
|
|
const { lines, restore } = spyConsoleLog();
|
|
try {
|
|
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:anon");
|
|
} finally {
|
|
restore();
|
|
}
|
|
expect(lines.length).toBe(1);
|
|
expect(lines[0]).toMatch(/^\[\(none\)\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
|
});
|
|
});
|