/** * SDK e2e harness entry — the MINIMAL page loaded inside the broker iframe. * * It imports the REAL `@ng-org/web` `ng` + this package (`@ng-eventually/client`), * 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, configureStoreRegistry, setCurrentUser, getCaps, resetCaps } from "@ng-eventually/client/polyfill"; import { docs, subscribeDoc, subscribeDocs, readModel, inbox, discovery, storeRegistry, useShape as libUseShape, accounts, } from "@ng-eventually/client"; const { IdentityStore } = accounts; // ── 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, }); configureStoreRegistry({ // The registry (+ subscribe/inbox/discovery/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(), }); // ── 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 AccountStorage). 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 }; }, // ── 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, anchor); }, async sparqlQuery(query: string, anchor?: string) { const s = await sessionReady; return docs.sparqlQuery(s.session_id, query, undefined, 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: string[] = []; 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); } const toRead = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris; const subjects = await readModel.readUnion(toRead); return { docNuris, subjectCount: subjects.length, subjects }; }, /** * readUnion cap gate: create a doc, mark it protected for owner O, set the * current user to a DIFFERENT identity, and readUnion → the doc is dropped. */ 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); getCaps().open(doc, "protected", "owner-O"); setCurrentUser("someone-else"); const asStranger = await readModel.readUnion([doc]); setCurrentUser("owner-O"); const asOwner = await readModel.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}" }`, 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 ──────────────────────────────────────────────────────────────── async inboxPostRead(payloadA: unknown, payloadB: unknown) { const s = await sessionReady; const target = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); setCurrentUser("inbox-user"); 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: "" }, async inboxWatchStart() { const s = await sessionReady; const target = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); 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; setCurrentUser("watcher"); await inbox.post(rec.target, { payload, from: null }); setCurrentUser(null); }, inboxWatchState() { const r = (window as any).__sdk._inboxWatch; return { fires: r.fires, lastLen: r.lastLen }; }, inboxWatchStop() { (window as any).__sdk._inboxWatch.unsub(); }, // spoof guard: depositing as another principal throws. async inboxSpoofGuard() { const s = await sessionReady; const target = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); setCurrentUser("alice"); 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 }; }, // ── discovery index ────────────────────────────────────────────────────── async discoverySubmitRead(ref: unknown) { setCurrentUser("publisher"); await discovery.submitToIndex(ref); setCurrentUser(null); const entries = await discovery.readIndex(); return { entries }; }, _discWatch: { fires: 0, lastLen: -1, unsub: () => {} }, discoveryWatchStart() { const rec = { fires: 0, lastLen: -1, unsub: () => {} }; (window as any).__sdk._discWatch = rec; rec.unsub = discovery.watchIndex((entries) => { rec.fires += 1; rec.lastLen = entries.length; }); }, async discoverySubmit(ref: unknown) { setCurrentUser("publisher2"); await discovery.submitToIndex(ref); setCurrentUser(null); }, discoveryWatchState() { const r = (window as any).__sdk._discWatch; return { fires: r.fires, lastLen: r.lastLen }; }, discoveryWatchStop() { (window as any).__sdk._discWatch.unsub(); }, // reserved @index account isolation: a real user named "index"/"@index" resolves // to a DIFFERENT account than the reserved index owner. async discoveryIndexIsolation() { const userIndex = await storeRegistry.ensureAccount("@index"); const reserved = await storeRegistry.ensureAccount(discovery.INDEX_ACCOUNT); return { userIndexDoc: userIndex.docPublic, reservedDoc: reserved.docPublic, disjoint: userIndex.docPublic !== reserved.docPublic, }; }, // ── store-registry ─────────────────────────────────────────────────────── async ensureAccountIdempotent(id: string) { storeRegistry.resetRegistryCache(); const first = await storeRegistry.ensureAccount(id); storeRegistry.resetRegistryCache(); const second = await storeRegistry.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) { storeRegistry.resetRegistryCache(); const dA1 = await storeRegistry.createEntityDoc(idA, "public"); const dA2 = await storeRegistry.createEntityDoc(idA, "public"); const dB1 = await storeRegistry.createEntityDoc(idB, "public"); // listMyEntityDocs(A) → only A's docs (poll: the index append can lag). let listA: string[] = []; for (let i = 0; i < 12; i++) { storeRegistry.resetRegistryCache(); listA = await storeRegistry.listMyEntityDocs(idA, "public"); if (listA.includes(dA1) && listA.includes(dA2)) break; await new Promise((r) => setTimeout(r, 1000)); } 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") { storeRegistry.resetRegistryCache(); const s = await sessionReady; const entityNuri = await storeRegistry.createEntityDoc(id, 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++) { storeRegistry.resetRegistryCache(); listed = await storeRegistry.listMyEntityDocs(id, 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 → readScopeIndex) 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) { // DIAGNOSTIC: a RAW anchored read of the entity doc with NO open at all, first // thing in the fresh session — 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. const s = session ?? (await sessionReady); let rawRowCount = -1; try { const raw: any = await docs.sparqlQuery(s.session_id, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, entityNuri); rawRowCount = Array.isArray(raw) ? raw.length : (raw?.results?.bindings?.length ?? 0); } catch (e: any) { rawRowCount = -2; // threw (e.g. RepoNotFound / InvalidNuri) } storeRegistry.resetRegistryCache(); const listed = await storeRegistry.listMyEntityDocs(id, scope); const subjects = await readModel.readUnion(listed.length ? listed : [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(entityNuri), subjectCount: subjects.length, markerPresent: markers.includes(marker), markers, }; }, async scopeResolvers() { const priv = await storeRegistry.resolveScopeGraph("private"); const prot = await storeRegistry.resolveScopeGraph("protected"); const pub = await storeRegistry.resolveScopeGraph("public"); return { priv, prot, pub }; }, // ── 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. 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:ungoverned", "@id": "3", v: "ungoverned-item" }, ]; getCaps().open("did:ng:o:protdoc", "protected", "owner-O"); getCaps().makePublic("did:ng:o:pubdoc"); // as owner-O setCurrentUser("owner-O"); const ownerView = [...(libUseShape(null, null) as Iterable)].map((i) => i.v); // as a stranger setCurrentUser("stranger"); const strangerView = [...(libUseShape(null, null) as Iterable)].map((i) => i.v); resetCaps(); injectedSetItems = []; setCurrentUser(null); return { ownerView, strangerView }; }, capsDirectedGrant() { resetCaps(); injectedSetItems = [{ "@graph": "did:ng:o:sharedoc", "@id": "1", v: "shared-item" }]; getCaps().open("did:ng:o:sharedoc", "protected", "owner-O"); setCurrentUser("friend"); const before = [...(libUseShape(null, null) as Iterable)].length; getCaps().grantRead("did:ng:o:sharedoc", "friend"); const after = [...(libUseShape(null, null) as Iterable)].length; resetCaps(); injectedSetItems = []; setCurrentUser(null); return { before, after }; }, // ── accounts (IdentityStore) ───────────────────────────────────────────── identitySet(id: string) { return identity.set(id); }, identityGet() { return identity.get(); }, identityClear() { identity.clear(); return identity.get(); }, }; // ── 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); } })();