/** * SDK e2e harness entry — the MINIMAL page loaded inside the broker iframe. * * It imports the REAL `@ng-org/web` `ng` + this package (`@ng-eventually/sdk`), * configures the polyfill session injection exactly the way a consumer does * (`configure` + `configureStoreRegistry`), waits for the real broker to hand back * a session, then exposes `window.__sdk`: a flat bag of async methods the * Playwright test drives. This has ZERO application domain — no Festipod shapes, * screens, or entities. It exercises the polyfill's OWN surface against the real * broker. * * Why a bridge object of async methods (not calling the lib from Node): the real * `ng` is browser-only (WASM + iframe RPC). Every SDK call must run INSIDE the * broker iframe; Playwright reaches in via `frame.evaluate(() => window.__sdk.x())`. */ import { ng as realNg, init as realInit } from "@ng-org/web"; import { configure, docs, subscribeDoc, subscribeDocs, readUnion, inbox, storeRegistry, useShape as libUseShape, watchShape, } from "@ng-eventually/sdk"; // The harness tests the LIBRARY, so it legitimately reaches machinery a consumer // application must not — but through the internal path, never the published entry. // `storeRegistry` above is the app-facing slice; these are the shim internals. import * as registryInternals from "../src/shared-wallet/account-registry"; // The harness plays SEVERAL identities on one page — something no application does, and // the reason `setCurrentUser` / `configureStoreRegistry` are no longer published. It // reaches them by their internal path, like the rest of its machinery. import { configureStoreRegistry, setCurrentUser, getCaps, getCurrentUser, resetCaps, } from "../src/shared-wallet/bootstrap"; import { connectedUser } from "../src/emulated-verifier/connect"; import * as virtualUsers from "../src/shared-wallet/virtual-users"; import { ensureIdentity } from "@ng-eventually/sdk"; // The harness narrows for its OWN assertions; a consumer never has to (the entries take // plain strings and validate inside). Internal path, like the rest of its machinery. import { isNuri } from "../src/model/nuri"; import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/sdk"; const { IdentityStore } = virtualUsers; /** * The Playwright boundary. Every NURI reaching this harness crosses the bridge as * a plain `string` (Playwright serializes arguments), so it arrives untyped even * though the library's `Nuri` is a template literal type. Narrow it here, loudly: * a test that passes something which is not a NextGraph reference should fail with * that message, not with a confusing downstream error. Never cast — a cast would * re-open exactly the confusion the types exist to close. */ function asNuri(s: string): Nuri { if (!isNuri(s)) throw new Error(`[e2e] not a NextGraph reference: ${JSON.stringify(s)}`); return s; } /** Same, for an optional anchor. */ function asAnchor(s?: string): Nuri | undefined { return s === undefined ? undefined : asNuri(s); } // ── The broker session, resolved once the iframe connects ────────────────── interface BrokerSession { session_id: string; private_store_id: string; protected_store_id: string; public_store_id: string; [k: string]: unknown; } let session: BrokerSession | null = null; let sessionResolve!: (s: BrokerSession) => void; const sessionReady = new Promise((r) => (sessionResolve = r)); // A minimal in-memory Set-like, so the lib's read-filtered `useShape` view can be // exercised WITHOUT the real ORM (`@ng-org/orm` is not installed here, and the // read-filter is pure — it wraps whatever Set-like the injected useShape returns). // The injected useShape receives `(shapeType, scope)`; we ignore both and return // the items array captured by the test through window.__sdk.caps.seedSet(). let injectedSetItems: any[] = []; function fakeUseShape(_shape: unknown, _scope: unknown): any { const items = () => injectedSetItems; return { get size() { return items().length; }, [Symbol.iterator]() { return items()[Symbol.iterator](); }, forEach(cb: (v: unknown) => void) { items().forEach(cb); }, add(item: any) { injectedSetItems.push(item); }, }; } // ── Inject the real SDK + polyfill settings (the consumer bootstrap) ──────── configure({ ng: realNg, useShape: fakeUseShape, init: realInit, // The harness already holds an open wallet (the suite imports it itself), so it never // needs the gate's assisted import. These values exist only so the gate has something // to render when the access-gate steps exercise it — they are never used to import. sharedWallet: { fileUrl: "/harness-not-used.ngw", password: "harness" }, }); configureStoreRegistry({ // The registry (+ subscribe/inbox/read-model) reach the session // through this. It resolves once the broker connects. getSession: async () => { // Read the CURRENT session (mutable): a fresh session (session_stop+session_start // for the reconnection cold-start test) swaps `session` in place, and the SDK must // route reads/opens through the NEW session_id. Fall back to the first-connect // promise until the initial session lands. const s = session ?? (await sessionReady); return { sessionId: s.session_id, privateStoreId: s.private_store_id, protectedStoreId: s.protected_store_id, publicStoreId: s.public_store_id, }; }, // Identity normalization used as the shim key (lowercase, strip leading `@`). normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(), // REAL broker: enable the POINTER micro-guard. The account records live in a // subscribable doc-shim reached via a write-once pointer triple in the store-root; // the account read is barrier-authoritative (no account retry). The only residual // store-root sync-lag is the pointer read — this bounded guard re-reads JUST that // one write-once triple on a cold reconnect (CONTRACT 2 non-fork). It can never // provision or fork an account. pointerGuard: { attempts: 8, baseMs: 150, maxStepMs: 2000 }, }); // ── Bridge marshaling note ───────────────────────────────────────────────── // Everything crossing frame.evaluate must be structured-cloneable. NURIs/strings/ // numbers/plain objects are fine. We never return functions or the raw `ng`. const state: { status: string; error?: string } = { status: "connecting" }; // Identity store over the iframe's localStorage (the real VirtualUserStorage). const identity = new IdentityStore( typeof window !== "undefined" && window.localStorage ? window.localStorage : null, ); // Expose the bridge immediately (status reflects connection progress). (window as any).__sdk = { status: () => state.status, error: () => state.error ?? null, sessionInfo: () => session ? { session_id: session.session_id, private_store_id: session.private_store_id, protected_store_id: session.protected_store_id, public_store_id: session.public_store_id, } : null, /** * Export the CURRENT wallet as a `.ngw` file, base64-encoded so it can cross the * frame.evaluate bridge back to Node. Used by the reconnection cold-start test to * re-import the SAME wallet into a CLEAN browser profile (empty local repo storage) * — the only faithful way to force the broker-only cold-start (a fresh profile has * no local IndexedDB copy of the repos to eagerly rehydrate). */ async exportWalletFile() { const ws = await (realNg as any).get_wallets(); const walletName = Object.keys(ws ?? {})[0]; const file = await (realNg as any).wallet_get_file(walletName); const bytes = file instanceof Uint8Array ? file : new Uint8Array(file); let bin = ""; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!); return { walletName, b64: btoa(bin), len: bytes.length }; }, /** * THE ACCESS GATE, in a real browser — ported from the consumer's * `barriere-acces-identifiant` feature, which the library took over with the flow. * * Unit tests pin the resolution ORDER (`test/access-gate.test.ts`); only a real DOM can * pin the barrier itself: that it appears on a first access, that entering a value * settles the identity normalized, and — the one that matters most — that it does NOT * appear when the identity is already known, since a returning user seeing the barrier * again is the visible face of the silent bug (a second virtual space). */ async accessGateFirstVisit(raw: string) { setCurrentUser(null); try { window.localStorage.removeItem("ng-eventually:identity"); } catch {} const done = ensureIdentity(); const gate = document.querySelector('[data-ng-eventually="access-gate"]'); const root = gate?.shadowRoot ?? null; const input = root?.querySelector("input") as HTMLInputElement | null; const button = root?.querySelector("button.go") as HTMLButtonElement | null; const shown = input !== null && button !== null; const disabledWhenEmpty = button?.disabled ?? null; if (input && button) { input.value = raw; input.dispatchEvent(new Event("input")); button.click(); } await done; return { shown, disabledWhenEmpty, identity: getCurrentUser(), stillMounted: document.querySelector('[data-ng-eventually="access-gate"]') !== null, }; }, /** The barrier must stay away once an identity is known. */ async accessGateReturningVisit(known: string) { setCurrentUser(known); await ensureIdentity(); return { shown: document.querySelector('[data-ng-eventually="access-gate"]') !== null, identity: getCurrentUser(), }; }, // ── docs primitives ────────────────────────────────────────────────────── async docCreate() { const s = await sessionReady; return docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); }, async sparqlUpdate(query: string, anchor?: string) { const s = await sessionReady; return docs.sparqlUpdate(s.session_id, query, asAnchor(anchor)); }, async sparqlQuery(query: string, anchor?: string) { const s = await sessionReady; return docs.sparqlQuery(s.session_id, query, undefined, asAnchor(anchor)); }, /** * The load-bearing graph-behavior characterization against the REAL broker. * fake-ng cannot verify any of this — it needs the broker's repo_graph_name * overlay. This is the ground truth the lib's write/read comments cite. * * Anchored to doc D, it measures each write shape: * (a) `INSERT DATA {

o }` (NO GRAPH) → read anchored default graph * (b) `INSERT DATA { GRAPH {

o } }` (explicit plain NURI) → read * both the anchored default graph AND `GRAPH ` explicitly * (c) anchorless `SELECT … WHERE { GRAPH ?g { … } }` over TWO docs D and D2 → * does it see BOTH docs' graphs (the O(wallet) union scan)? */ async docRoundTrip() { const s = await sessionReady; const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); const subj = "urn:e2e:s"; // (a) anchored default-graph write (no GRAPH) — the canonical shape the lib writes. await docs.sparqlUpdate( s.session_id, `INSERT DATA { <${subj}> "yes" }`, doc, ); // (b) explicit `GRAPH ` write, anchored to the same doc. await docs.sparqlUpdate( s.session_id, `INSERT DATA { GRAPH <${doc}> { <${subj}> "yes" } }`, doc, ); // read back anchored default graph — both predicates expected if (b) round-trips. const res: any = await docs.sparqlQuery( s.session_id, `SELECT ?p ?o WHERE { <${subj}> ?p ?o }`, undefined, doc, ); const rows = Array.isArray(res) ? res : res?.results?.bindings ?? []; const preds = rows.map((r: any) => r.p?.value).filter(Boolean); // (b, cont.) read the explicit-graph triple back via its OWN named graph, to // confirm `GRAPH ` when anchored resolves to the same repo graph. const explicitNamed: any = await docs.sparqlQuery( s.session_id, `SELECT ?o WHERE { GRAPH <${doc}> { <${subj}> ?o } }`, undefined, doc, ); const enRows = Array.isArray(explicitNamed) ? explicitNamed : explicitNamed?.results?.bindings ?? []; // (c) anchorless `GRAPH ?g` union scan across TWO docs. Create a second doc // with a DISTINCT triple, then run an ANCHORLESS scan (anchor undefined → // UserSite → local union). If it returns rows from BOTH graphs, the anchorless // union spans every named graph in the session store — the O(wallet-size) cost // the read path exists to avoid. Two docs is enough to demonstrate the union; // it does not bloat the wallet. let unionSpan: { sawDocA: boolean; sawDocB: boolean; graphCount: number } = { sawDocA: false, sawDocB: false, graphCount: 0, }; try { const docB = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); await docs.sparqlUpdate( s.session_id, `INSERT DATA { "yes" }`, docB, ); const union: any = await docs.sparqlQuery( s.session_id, `SELECT ?g ?s ?p WHERE { GRAPH ?g { ?s ?p ?o } }`, undefined, undefined, // ANCHORLESS → UserSite → local union of all named graphs ); const uRows = Array.isArray(union) ? union : union?.results?.bindings ?? []; const subjs = new Set(uRows.map((r: any) => r.s?.value).filter(Boolean)); const graphs = new Set(uRows.map((r: any) => r.g?.value).filter(Boolean)); unionSpan = { sawDocA: subjs.has(subj), sawDocB: subjs.has("urn:e2e:s2"), graphCount: graphs.size, }; } catch (e: any) { unionSpan = { sawDocA: false, sawDocB: false, graphCount: -1 }; (unionSpan as any).error = String(e?.message ?? e); } return { doc, predicates: preds, anchoredPresent: preds.includes("urn:e2e:anchored"), explicitGraphPresent: preds.includes("urn:e2e:explicitGraph"), explicitViaNamedGraph: enRows.length > 0, unionSpan, }; }, // ── read-model ─────────────────────────────────────────────────────────── /** * Create N docs, write a marker triple into each (anchored default graph), then * readUnion over them → one subject per doc. Optionally inject a bad NURI to * prove per-doc tolerance (the bad one is skipped, the batch survives). */ async readUnionOverDocs(n: number, includeBad: boolean) { const s = await sessionReady; const docNuris: Nuri[] = []; for (let i = 0; i < n; i++) { const d = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); await docs.sparqlUpdate( s.session_id, `INSERT DATA { "${i}" }`, d, ); docNuris.push(d); } // Two SUBJECTS in the LAST document, so the probe can tell "one entry per document" // from "one entry per subject". Until 2026-08-10 `readUnion` folded every triple of a // document into one bag keyed by the document, and this step stayed green throughout // because it only ever counted entries and never looked at what they were. if (n > 0) { await docs.sparqlUpdate( s.session_id, `INSERT DATA { "extra" }`, docNuris[n - 1]!, ); } const toRead: Nuri[] = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris; const subjects = await readUnion(toRead); return { docNuris, subjectCount: subjects.length, // What each entry actually IS — the assertion the count could not make. subjectIris: subjects.map((x) => x.subject), graphs: subjects.map((x) => x.graph), subjects, }; }, /** * readUnion possession gate: create a doc as owner O (whose keyring gets its * cap), then read it as a DIFFERENT identity, which holds nothing → dropped. * The stranger has the document's NURI in hand throughout: naming is not reading. */ async readUnionCapGate() { const s = await sessionReady; resetCaps(); const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); await docs.sparqlUpdate(s.session_id, `INSERT DATA { "x" }`, doc); setCurrentUser("owner-O"); getCaps().open(doc, "protected"); setCurrentUser("someone-else"); const asStranger = await readUnion([doc]); setCurrentUser("owner-O"); const asOwner = await readUnion([doc]); resetCaps(); setCurrentUser(null); return { strangerCount: asStranger.length, ownerCount: asOwner.length }; }, // ── reactivity (doc_subscribe) ─────────────────────────────────────────── // The test installs a counter; subscribeDoc pushes an initial state, then a // push per subsequent write. We record every push and expose the count/log so // the test can wait event-driven (waitForFunction on the counter), never timed. _subs: {} as Record void }>, async subscribeDocStart(handle: string) { const s = await sessionReady; const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); const rec = { count: 0, unsub: () => {} }; (window as any).__sdk._subs[handle] = rec; rec.unsub = subscribeDoc(doc, () => { rec.count += 1; }); return { doc }; }, subscribeCount(handle: string) { return (window as any).__sdk._subs[handle]?.count ?? -1; }, async writeTo(doc: string, marker: string) { const s = await sessionReady; await docs.sparqlUpdate( s.session_id, `INSERT DATA { "${marker}" }`, asNuri(doc), ); }, subscribeStop(handle: string) { const rec = (window as any).__sdk._subs[handle]; if (rec) rec.unsub(); }, /** * subscribeDocs isolation: subscribe to [goodDoc, deadDoc]. The dead doc's * subscription fails in isolation; the good doc still fires on its write. */ _multiSub: { good: 0, bad: 0, unsub: () => {} }, async subscribeDocsStart() { const s = await sessionReady; const good = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); const bad = "did:ng:o:absent-doc-never-created"; const rec = { good: 0, bad: 0, unsub: () => {} }; (window as any).__sdk._multiSub = rec; rec.unsub = subscribeDocs([good, bad], (nuri) => { if (nuri === good) rec.good += 1; else rec.bad += 1; }); return { good, bad }; }, multiSubCounts() { const r = (window as any).__sdk._multiSub; return { good: r.good, bad: r.bad }; }, multiSubStop() { (window as any).__sdk._multiSub.unsub(); }, // ── inbox ──────────────────────────────────────────────────────────────── /** * `id` must be FRESH per run (run.ts stamps it). A user's inbox is stable over time — * that is the point of it — so re-running against a reused id accumulates the previous * runs' deposits on a persistent wallet, and the exact-count assertion drifts. The * thing to make disposable is the user, not the inbox. */ async inboxPostRead(id: string, payloadA: unknown, payloadB: unknown) { // The target must be that user's OWN inbox, not an arbitrary document: you may // deposit into anyone's, you may only read your own. Establishing the identity // FIRST is what makes `userInbox` resolve (and file) that user's inbox. setCurrentUser(id); const target = await registryInternals.userInbox(id, "protected"); await inbox.post(target, { payload: payloadA, from: null, ts: 1000 }); await inbox.post(target, { payload: payloadB, from: null, ts: 2000 }); const deposits = await inbox.read(target); setCurrentUser(null); return { target, deposits }; }, // watch (doc_subscribe-based) fires when a deposit lands. _inboxWatch: { fires: 0, lastLen: -1, unsub: () => {}, target: "" }, /** `id` fresh per run, for the same reason as {@link inboxPostRead}. */ async inboxWatchStart(id: string) { // Watching an inbox is READING it continuously, so the watcher stays connected // for the whole probe — including across `inboxWatchDeposit`. setCurrentUser(id); const target = await registryInternals.userInbox(id, "protected"); const rec = { fires: 0, lastLen: -1, unsub: () => {}, target }; (window as any).__sdk._inboxWatch = rec; rec.unsub = inbox.watch(target, (deposits) => { rec.fires += 1; rec.lastLen = deposits.length; }); return { target }; }, async inboxWatchDeposit(payload: unknown) { const rec = (window as any).__sdk._inboxWatch; await inbox.post(rec.target, { payload, from: null }); }, inboxWatchState() { const r = (window as any).__sdk._inboxWatch; return { fires: r.fires, lastLen: r.lastLen }; }, inboxWatchStop() { (window as any).__sdk._inboxWatch.unsub(); setCurrentUser(null); }, // spoof guard: depositing as another principal throws. async inboxSpoofGuard() { // A REAL inbox, obtained from the system. It used to be a plain `docs.docCreate` // document — a state the library never produces, and `inbox.post` now refuses it // (a deposit is addressed to an inbox, never to a document). The step is about the // `from` spoof guard; it should not also assert something the model forbids. setCurrentUser("alice"); const target = await registryInternals.userInbox("alice", "protected"); let threw = false; try { await inbox.post(target, { payload: { x: 1 }, from: "bob" }); } catch { threw = true; } // self + anonymous both allowed let selfOk = true, anonOk = true; try { await inbox.post(target, { payload: { x: 2 }, from: "alice" }); } catch { selfOk = false; } try { await inbox.post(target, { payload: { x: 3 }, from: null }); } catch { anonOk = false; } setCurrentUser(null); return { spoofRejected: threw, selfOk, anonOk }; }, // ── store-registry ─────────────────────────────────────────────────────── async ensureAccountIdempotent(id: string) { registryInternals.resetRegistryCache(); const first = await registryInternals.ensureAccount(id); registryInternals.resetRegistryCache(); const second = await registryInternals.ensureAccount(id); return { firstDocs: [first.docPublic, first.docProtected, first.docPrivate], secondDocs: [second.docPublic, second.docProtected, second.docPrivate], same: first.docPublic === second.docPublic && first.docProtected === second.docProtected && first.docPrivate === second.docPrivate, }; }, async entityDocsBounded(idA: string, idB: string) { registryInternals.resetRegistryCache(); // Each user creates its OWN documents: you act as one virtual user at a time, // and the caps of what you create are filed under the identity you were acting // as. Creating B's document while connected as A is not a thing the model has. setCurrentUser(idA); const dA1 = await storeRegistry.createEntityDoc("public"); const dA2 = await storeRegistry.createEntityDoc("public"); setCurrentUser(idB); const dB1 = await storeRegistry.createEntityDoc("public"); // listMyEntityDocs(A) → only A's docs (poll: the index append can lag). setCurrentUser(idA); let listA: string[] = []; for (let i = 0; i < 12; i++) { registryInternals.resetRegistryCache(); listA = await storeRegistry.listMyEntityDocs("public"); if (listA.includes(dA1) && listA.includes(dA2)) break; await new Promise((r) => setTimeout(r, 1000)); } setCurrentUser(null); return { dA1, dA2, dB1, listA, hasA1: listA.includes(dA1), hasA2: listA.includes(dA2), leaksB: listA.includes(dB1), }; }, /** * RECONNECTION cold-start seed (phase 1, run in the FIRST session). * * Create a per-entity document under (id, scope) — which also appends its NURI to * the account's scope-index document — and write a marker triple INTO the entity * doc (anchored default graph, the canonical shape). Poll until listMyEntityDocs * sees it, so the index append has actually landed on the broker before we tear * the session down. Returns everything the FRESH session needs to re-find it * purely from the persistent wallet: only `id` + `scope` are load-bearing (the * fresh session re-resolves the account from the shim); entityNuri/marker are the * expected values to assert against. */ async reconnectSeed(id: string, scope: "public" | "protected" | "private") { registryInternals.resetRegistryCache(); const s = await sessionReady; // Seed AS the user whose document this is — otherwise the cap of the created // document is filed under nobody and the very session that created it is // refused the write below. setCurrentUser(id); const entityNuri = await storeRegistry.createEntityDoc(scope); const marker = "recon-" + Date.now(); await docs.sparqlUpdate( s.session_id, `INSERT DATA { "${marker}" }`, entityNuri, ); // Wait until the index append + the triple are visible in THIS session (the // session that created them — where the repos are already open), so we know the // data is persisted before the fresh session tries to read it back. let listed: string[] = []; for (let i = 0; i < 15; i++) { registryInternals.resetRegistryCache(); listed = await storeRegistry.listMyEntityDocs(scope); if (listed.includes(entityNuri)) break; await new Promise((r) => setTimeout(r, 1000)); } return { id, scope, entityNuri, marker, listedInSeed: listed }; }, /** * RECONNECTION read (phase 2, run in a FRESH session over the SAME wallet). First a * DIAGNOSTIC raw anchored read with NO open (rawRowCount), then re-resolve the * account's entity docs of `scope` (listMyEntityDocs → readUserStore) and readUnion * them, purely from the persistent wallet — nothing from phase 1's session state * carries over. The SDK's open-before-read heal (open-repo.ts) opens each repo via * doc_subscribe before the anchored reads. NB: on the SDK/broker version tested here * the broker-login bootstrap already opens the user's repos, so rawRowCount is * non-zero even without the heal — this is a reconnection REGRESSION guard, not a * fail-without-the-fix proof (see run.ts's reconnection step comment). */ async reconnectRead(id: string, scope: "public" | "protected" | "private", entityNuri: string, marker: string) { const s = session ?? (await sessionReady); // A fresh session holds nothing in memory: connect AS the user so the caps are // restored from the durable registers (own documents from the Store branches, // received ones from the Links) before anything is read back. setCurrentUser(id); await connectedUser(); registryInternals.resetRegistryCache(); const listed = await storeRegistry.listMyEntityDocs(scope); // DIAGNOSTIC: a RAW anchored read of the entity doc with NO open — reports how // many rows the bare anchored query resolves for a not-yet-opened repo (the // premise: 0 until opened). Uses the low-level docs primitive directly, bypassing // readUnion's open step. // // Placed AFTER `listMyEntityDocs`, which is what restores the caps of the user's // own documents from the Store branch. Before it, the boundary refuses the read // and the probe would measure the guard rather than the open — a number that // looks like the premise holding while proving nothing about it. let rawRowCount = -1; try { const raw: any = await docs.sparqlQuery(s.session_id, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, asNuri(entityNuri)); rawRowCount = Array.isArray(raw) ? raw.length : (raw?.results?.bindings?.length ?? 0); } catch (e: any) { rawRowCount = -2; // threw (e.g. RepoNotFound / InvalidNuri) } const subjects = await readUnion(listed.length ? listed : [asNuri(entityNuri)]); const markers: string[] = []; for (const subj of subjects) { for (const vals of Object.values(subj.props)) { for (const v of vals) markers.push(v); } } return { rawRowCount, listed, listedCount: listed.length, foundEntity: listed.includes(asNuri(entityNuri)), subjectCount: subjects.length, markerPresent: markers.includes(marker), markers, }; }, /** * NON-FORK contract probe. Resolve (or create on first sight) the account for * `id` and return its THREE scope-document NURIs (docPublic/docProtected/ * docPrivate) exactly as ensureAccount records them in the shim. Run in session 1 * it PROVISIONS the account (first NURIs); run in a FRESH faithful reconnect * session it must RE-RESOLVE the SAME account from the synced shim and return the * SAME NURIs — never a second provisioning (an account fork). resetRegistryCache * first so the resolve goes to the shim, not a same-session in-memory hit. */ async accountDocs(id: string) { registryInternals.resetRegistryCache(); const rec = await registryInternals.ensureAccount(id); return { docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate }; }, async scopeResolvers() { const priv = await storeRegistry.resolveScopeGraph("private"); const prot = await storeRegistry.resolveScopeGraph("protected"); const pub = await storeRegistry.resolveScopeGraph("public"); return { priv, prot, pub }; }, /** * COLD-START anchor probe. Runs the EXACT shim SELECT the registry issues, * anchored to `did:ng:${private_store_id}` (the shim anchor), as the very first * thing in a fresh session — BEFORE anything opens that repo. Reports whether the * raw anchored query threw `RepoNotFound` (the cold-start bug: the private-store * repo not yet in `self.repos`) or returned rows. Uses the low-level docs * primitive directly so nothing (open-repo, ensureAccount) opens the repo first. */ async shimAnchorProbe() { const s = session ?? (await sessionReady); const anchor = `did:ng:${s.private_store_id}`; const query = "SELECT ?acc WHERE { GRAPH <" + anchor + "> { ?acc a } }"; try { const res: any = await docs.sparqlQuery(s.session_id, query, undefined, asNuri(anchor)); const rows = Array.isArray(res) ? res.length : (res?.results?.bindings?.length ?? 0); return { threw: false, error: null, rows, anchor }; } catch (e: any) { return { threw: true, error: String(e?.message ?? e), rows: -1, anchor }; } }, /** * COLD-START account provision. resetRegistryCache then ensureAccount(id) — the * real bootstrap the app runs on first login. On a fresh wallet, if the anchor * repo isn't open, resolveAccount's read AND ensureAccount's provision write both * hit RepoNotFound; the account never persists. Returns the 3 scope docs (all * truthy iff provisioning succeeded) so the runner can gate on real persistence. */ async coldEnsureAccount(id: string) { registryInternals.resetRegistryCache(); try { const rec = await registryInternals.ensureAccount(id); return { threw: false, error: null, docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate, }; } catch (e: any) { return { threw: true, error: String(e?.message ?? e), docPublic: "", docProtected: "", docPrivate: "" }; } }, /** * VERIFY the provisioned account actually PERSISTED to the shim: resetRegistryCache * then re-resolve the SAME id via a fresh anchored read. Returns whether the read * threw + the resolved docs. After the fix, on a fresh wallet this returns the SAME * docs coldEnsureAccount minted (real persistence, no RepoNotFound). */ async verifyShimPersisted(id: string) { registryInternals.resetRegistryCache(); try { const rec = await registryInternals.ensureAccount(id); return { threw: false, error: null, docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate }; } catch (e: any) { return { threw: true, error: String(e?.message ?? e), docPublic: "", docProtected: "", docPrivate: "" }; } }, // ── watchShape (reactive useQuery-shaped read) ─────────────────────────── // A minimal SHEX-ish ShapeType pinning rdf:type to an e2e class IRI. watchShape // reads only `.shape`/`.schema[...].predicates[rdf:type].dataTypes[].literals` // to know which @type to keep — generic, no application domain. _wsProbes: {} as Record< string, { obs: ShapeObservable; initial: ShapeQuery; unsub: () => void } >, /** * REAL-BROKER cycle proof. Under a fresh identity, create ONE protected entity * document carrying an rdf:type= triple, then open a `watchShape` over * (that class shape, "protected"). Capture the FIRST snapshot right after subscribe * (must be isPending) so the caller can then wait event-driven for isSuccess with * the seeded datum present. Returns the handle + the initial snapshot + the seed * doc/type so the runner can assert the data landed. */ async watchShapeSeedAndSubscribe(handle: string, cls: string) { registryInternals.resetRegistryCache(); const id = "@ws-" + handle; setCurrentUser(id); const doc = await storeRegistry.createEntityDoc("protected"); const s = await sessionReady; // Seed the entity doc with the shape's type + a title (anchored default graph). await docs.sparqlUpdate( s.session_id, `INSERT DATA { <${doc}> <${cls}> ; "seeded" }`, doc, ); // Wait until this session sees the index append (data persisted on the broker). for (let i = 0; i < 15; i++) { registryInternals.resetRegistryCache(); const listed = await storeRegistry.listMyEntityDocs("protected"); if (listed.includes(doc)) break; await new Promise((r) => setTimeout(r, 1000)); } const shape = { shape: "urn:e2e:ws:Shape", schema: { "urn:e2e:ws:Shape": { iri: "urn:e2e:ws:Shape", predicates: [ { iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", readablePredicate: "type", maxCardinality: 1, minCardinality: 1, dataTypes: [{ literals: [cls], valType: "iri" }], }, ], }, }, }; const obs = watchShape(shape, "protected") as ShapeObservable; const unsub = obs.subscribe(() => {}); // First snapshot immediately after subscribe — the barrier is not yet crossed // (docs opening via doc_subscribe), so this MUST be pending. const initial = obs.getSnapshot(); (window as any).__sdk._wsProbes[handle] = { obs, initial, unsub }; // NB: leave the current user SET for the probe's lifetime — watchShape resolves // the scope from getCurrentUser() on every (reactive) refresh, exactly as the app // keeps a stable identity. watchShapeStop clears it. return { doc, cls, initial: { isPending: initial.isPending, isSuccess: initial.isSuccess, dataLen: initial.data.length } }; }, /** Current snapshot of a watchShape probe (event-driven poll target). */ watchShapeSnapshot(handle: string) { const rec = (window as any).__sdk._wsProbes[handle]; if (!rec) return null; const snap: ShapeQuery = rec.obs.getSnapshot(); return { isPending: snap.isPending, isSuccess: snap.isSuccess, isError: snap.isError, dataLen: snap.data.length, // The seeded title, if the datum is present (proves the real data landed). titles: snap.data.flatMap((d: any) => d.props?.["urn:e2e:ws:title"] ?? []), }; }, watchShapeStop(handle: string) { const rec = (window as any).__sdk._wsProbes[handle]; if (rec) rec.unsub(); setCurrentUser(null); }, /** * EMPTY-scope proof: a brand-new identity with NO entity docs of `scope`. watchShape * must reach isSuccess with data:[] (synced-but-empty), NOT stay pending. Returns * the handle; poll watchShapeSnapshot for the transition. */ watchShapeEmptyStart(handle: string, cls: string) { registryInternals.resetRegistryCache(); const id = "@ws-empty-" + handle; setCurrentUser(id); const shape = { shape: "urn:e2e:ws:Shape", schema: { "urn:e2e:ws:Shape": { iri: "urn:e2e:ws:Shape", predicates: [ { iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", readablePredicate: "type", maxCardinality: 1, minCardinality: 1, dataTypes: [{ literals: [cls], valType: "iri" }], }, ], }, }, }; const obs = watchShape(shape, "protected") as ShapeObservable; const unsub = obs.subscribe(() => {}); const initial = obs.getSnapshot(); (window as any).__sdk._wsProbes[handle] = { obs, initial, unsub }; // Keep the identity set for the probe's lifetime (watchShapeStop clears it): a // faithful "empty scope for a real identity", not "no identity at all". return { initial: { isPending: initial.isPending, isSuccess: initial.isSuccess } }; }, // ── caps / read-filter (in-memory cap model) ───────────────────────────── // The read-filter over the injected useShape Set-like. Boundary note: the // caps/read-filter are EMULATED in-memory (CapRegistry) — the real broker does // NOT yet enforce per-doc read caps here (one shared wallet reads everything). // We test what the SDK enforces: the in-memory read-filtered VIEW, which after // P1a is KEY POSSESSION — you read what your keyring holds, nothing else. capsReadFilter() { resetCaps(); injectedSetItems = [ { "@graph": "did:ng:o:protdoc", "@id": "1", v: "protected-item" }, { "@graph": "did:ng:o:pubdoc", "@id": "2", v: "public-item" }, { "@graph": "did:ng:o:unheld", "@id": "3", v: "unheld-item" }, ]; setCurrentUser("owner-O"); getCaps().open("did:ng:o:protdoc", "protected"); const link = getCaps().open("did:ng:o:pubdoc", "public"); const ownerView = [...(libUseShape(null, null) as Iterable)].map((i) => i.v); // A stranger holds nothing, and this VIEW asks nobody: it is pure possession, with // no round-trip to spend (see `read-filter.ts`). That a public store would serve // the cap is proven on the read paths, not here. setCurrentUser("stranger"); const strangerView = [...(libUseShape(null, null) as Iterable)].map((i) => i.v); getCaps().learn(link); const strangerWithLinkView = [...(libUseShape(null, null) as Iterable)].map((i) => i.v); resetCaps(); injectedSetItems = []; setCurrentUser(null); return { ownerView, strangerView, strangerWithLinkView }; }, // MOVED to the applicative suite, `e2e/notebook.ts` (2026-08-07): // - `documentInboxDeposit` → "Bob leaves a message on Alice's note, and only Alice // reads it". This one is WHY that suite exists: it was green here while the // feature was unusable, because a harness can hand an inbox address across an // identity boundary through a variable and an application cannot. // - `capsShareCap` → "Alice's protected note stays shut until she gives Bob the key". // // What stays here is what an application does not do: primitives, characterisation, // and the cold-start regressions. // ── accounts (IdentityStore) ───────────────────────────────────────────── identitySet(id: string) { return identity.set(id); }, identityGet() { return identity.get(); }, identityClear() { identity.clear(); return identity.get(); }, // ── CONTRACT: first-State barrier (doc_subscribe sync-point) ──────────── // // The contract under test: the 1st event emitted by doc_subscribe is a `State` // that marks the end of the initial broker sync. After that push: // - if the doc has data → it MUST be present in the State (no "not yet synced") // - if the doc is empty → it IS definitively empty (no later arrival) // // We drive this via the polyfill's subscribeDoc (which calls ng.doc_subscribe // directly), but we capture the RAW AppResponse for each push so we can: // (a) identify the event type (State vs Patch vs TabInfo vs other) // (b) measure the time-to-first-State // (c) correlate "State received" with the SPARQL content query result // // Bridge state for a single active first-State probe. _stateProbe: null as null | { doc: string; startMs: number; events: Array<{ typeKey: string; elapsedMs: number }>; unsub: () => void; }, /** * PRESENCE probe — phase 1: write a triple into `doc` (same session, so the * write is already committed on the broker before we subscribe). Returns the doc * NURI so the test can hand it to stateProbeSubscribe. Split into write + subscribe * so the test controls the ordering precisely. */ async stateProbeWrite(triple: { s: string; p: string; o: string }): Promise { const s = await sessionReady; const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); await docs.sparqlUpdate( s.session_id, `INSERT DATA { <${triple.s}> <${triple.p}> "${triple.o}" }`, doc, ); return doc; }, /** * Subscribe to `doc` and record every raw event (type key + elapsed ms from * subscribe call). The state probe slot is single-use per call; a previous probe * is torn down first. `subscribeDoc` is the polyfill wrapper — it passes the raw * AppResponse straight through to the callback. */ stateProbeSubscribe(doc: string): void { // Tear down any prior probe. const prev = (window as any).__sdk._stateProbe; if (prev) { try { prev.unsub(); } catch { /* ignore */ } } const probe = { doc, startMs: Date.now(), events: [] as Array<{ typeKey: string; elapsedMs: number }>, unsub: () => {}, }; (window as any).__sdk._stateProbe = probe; probe.unsub = subscribeDoc(asNuri(doc), (resp: any) => { const elapsedMs = Date.now() - probe.startMs; // AppResponse shape: { V0: { State: … } } | { V0: { Patch: … } } | { V0: { TabInfo: … } } | … let typeKey = "unknown"; try { const v0 = resp?.V0 ?? resp?.v0 ?? resp; if (v0 && typeof v0 === "object") { const keys = Object.keys(v0); typeKey = keys[0] ?? "empty"; } else { typeKey = String(v0); } } catch { typeKey = "parse-error"; } probe.events.push({ typeKey, elapsedMs }); }); }, /** Return the accumulated event log (snapshot — safe to call at any time). */ stateProbeEvents(): Array<{ typeKey: string; elapsedMs: number }> { return (window as any).__sdk._stateProbe?.events ?? []; }, /** * Return the count of events with typeKey === `State` received so far. * Used by waitForFunction to wait for the first State (not just any event — * the broker pushes TabInfo first, State second). */ stateProbeStateCount(): number { const events: Array<{ typeKey: string }> = (window as any).__sdk._stateProbe?.events ?? []; return events.filter((e) => e.typeKey === "State").length; }, /** * After the first State has been received, run an anchored SPARQL query on the * probe doc to read back its triples. Returns the raw rows so the test can assert * presence/absence without re-querying from the test side. */ async stateProbeQuery(s_uri: string, p_uri: string): Promise<{ rows: number; found: boolean }> { const s = await sessionReady; const doc = (window as any).__sdk._stateProbe?.doc; if (!doc) return { rows: -1, found: false }; try { const res: any = await docs.sparqlQuery( s.session_id, `SELECT ?o WHERE { <${s_uri}> <${p_uri}> ?o }`, undefined, doc, ); const rows = Array.isArray(res) ? res.length : (res?.results?.bindings?.length ?? 0); return { rows, found: rows > 0 }; } catch (e: any) { return { rows: -2, found: false }; } }, /** Stop the active probe's subscription. */ stateProbeStop(): void { const probe = (window as any).__sdk._stateProbe; if (probe) { try { probe.unsub(); } catch { /* ignore */ } } (window as any).__sdk._stateProbe = null; }, /** * ABSENCE probe — subscribe to a doc NURI that was never written (non-existent). * Returns the doc immediately after creating it (empty), then subscribes. * The test waits for the first State, then verifies the doc is empty AND stays * empty for a grace window (no late Patch arrival). */ async stateProbeEmptyDoc(): Promise { // Create a real doc so it's valid for doc_subscribe, but write NOTHING into it. const s = await sessionReady; const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); // Subscribe immediately (no data written). (window as any).__sdk.stateProbeSubscribe(doc); return doc; }, }; // ── Connect to the real broker ───────────────────────────────────────────── // Mirrors ngSession.ts: register the init callback; the broker (this iframe is // loaded by it) drives the connection and calls back with the session. (async () => { try { await (realInit as any)( (event: any) => { session = event.session as BrokerSession; state.status = "connected"; sessionResolve(session); }, true, [], ); } catch (e: any) { state.status = "error"; state.error = String(e?.message ?? e); } })();