Files
ng-eventually/packages/client/e2e/sdk-entry.ts
T
Sylvain Duchesne 5a7009bd75 fix(inbox): une inbox appartient à un document, jamais à plusieurs
Retour sur l'adresse par défaut livrée en 8a382f2, qui faisait pointer tout
document vers l'inbox de son propriétaire. C'était acheter le coût au prix de
la forme — le mauvais arbitrage pour cette bibliothèque.

Vérifié en amont : le verifier route un message entrant par
`inboxes: PubKey → RepoId` (`engine/verifier/src/verifier.rs:1677,1928`) et le
déchiffre avec la moitié privée de CE repo. Et `InboxMsgBody`
(`engine/net/src/types.rs:4265`) ne porte aucun document cible — il n'en a pas
besoin : l'adresse EST l'identification. Une inbox appartient donc à exactement
un repo, et faire tenir plusieurs documents derrière une inbox émule une
relation que le modèle ne peut pas exprimer.

Conséquences :

- `createEntityDoc` ne publie plus rien. Un document neuf n'a pas d'inbox et
  `documentInboxAddress` rend `undefined`.
- Une inbox s'ouvre par `openDocumentInbox(doc)`, sur décision du propriétaire.
  C'est aussi ce qui règle le coût sans toucher à la forme : seuls les
  documents destinés à RECEVOIR en paient une — l'app le sait, la bibliothèque
  non.
- `inbox.postToDocument(doc, { payload })` : l'app nomme le DOCUMENT, jamais une
  inbox. Lève quand le document n'en a pas, au lieu de rendre la main
  silencieusement — un dépôt qui disparaît sans erreur est exactement le bug que
  ce chemin traînait.
- Pas de champ « document cible » sur un dépôt. Ce serait une invention que les
  apps devraient désapprendre à la migration.

README, principe de conception : les deux moitiés sont contraignantes, et c'est
la seconde qu'on brade. La surface doit être au plus près du futur SDK, mais
l'IMPLÉMENTATION aussi doit être au plus près de ce que NextGraph prévoit, sans
exception. Ce qui est connu vaut spécification. La pression à dévier ne se
présente jamais comme une déviation : elle arrive comme un coût, une latence,
une gêne d'ergonomie — bien réels. Deux cas déjà rencontrés sont consignés, avec
le signal commun : un choix qui ferait apprendre au consommateur quelque chose
qu'il devra DÉSAPPRENDRE.

157 tests unitaires, e2e 40/40 contre le broker en ligne.
2026-08-03 16:45:28 +02:00

1043 lines
45 KiB
TypeScript

/**
* 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,
capFor,
getCaps,
resetCaps,
shareCap,
connectedUser,
} from "@ng-eventually/client/polyfill";
import {
docs,
subscribeDoc,
subscribeDocs,
readModel,
inbox,
storeRegistry,
useShape as libUseShape,
watchShape,
accounts,
} from "@ng-eventually/client";
import { isNuri } from "@ng-eventually/client";
import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/client";
const { IdentityStore } = accounts;
/**
* 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<BrokerSession>((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/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 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, 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 { <s> <p> o }` (NO GRAPH) → read anchored default graph
* (b) `INSERT DATA { GRAPH <D> { <s> <p> o } }` (explicit plain NURI) → read
* both the anchored default graph AND `GRAPH <D>` 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}> <urn:e2e:anchored> "yes" }`,
doc,
);
// (b) explicit `GRAPH <plainNuri>` write, anchored to the same doc.
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { GRAPH <${doc}> { <${subj}> <urn:e2e:explicitGraph> "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 <plainNuri>` when anchored resolves to the same repo graph.
const explicitNamed: any = await docs.sparqlQuery(
s.session_id,
`SELECT ?o WHERE { GRAPH <${doc}> { <${subj}> <urn:e2e:explicitGraph> ?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 { <urn:e2e:s2> <urn:e2e:anchoredB> "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 { <urn:e2e:rm:${i}> <urn:e2e:idx> "${i}" }`,
d,
);
docNuris.push(d);
}
const toRead: Nuri[] = 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 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 { <urn:e2e:cg> <urn:e2e:p> "x" }`, doc);
setCurrentUser("owner-O");
getCaps().open(doc, "protected");
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<string, { count: number; unsub: () => 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 { <urn:e2e:sub:${marker}> <urn:e2e:m> "${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 `walletInbox` resolve (and file) that user's inbox.
setCurrentUser(id);
const target = await storeRegistry.walletInbox(id);
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 storeRegistry.walletInbox(id);
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() {
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 };
},
// ── 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();
// 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(idA, "public");
const dA2 = await storeRegistry.createEntityDoc(idA, "public");
setCurrentUser(idB);
const dB1 = await storeRegistry.createEntityDoc(idB, "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++) {
storeRegistry.resetRegistryCache();
listA = await storeRegistry.listMyEntityDocs(idA, "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") {
storeRegistry.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(id, scope);
const marker = "recon-" + Date.now();
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <urn:e2e:recon:subject> <urn:e2e:recon:marker> "${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 → 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();
storeRegistry.resetRegistryCache();
const listed = await storeRegistry.listMyEntityDocs(id, 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 readModel.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) {
storeRegistry.resetRegistryCache();
const rec = await storeRegistry.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 <urn:ng-eventually:shim:Account> } }";
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) {
storeRegistry.resetRegistryCache();
try {
const rec = await storeRegistry.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) {
storeRegistry.resetRegistryCache();
try {
const rec = await storeRegistry.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=<e2e class> 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) {
storeRegistry.resetRegistryCache();
const id = "@ws-" + handle;
setCurrentUser(id);
const doc = await storeRegistry.createEntityDoc(id, "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}> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <${cls}> ; <urn:e2e:ws:title> "seeded" }`,
doc,
);
// Wait until this session sees the index append (data persisted on the broker).
for (let i = 0; i < 15; i++) {
storeRegistry.resetRegistryCache();
const listed = await storeRegistry.listMyEntityDocs(id, "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) {
storeRegistry.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().publishRepoLink("did:ng:o:pubdoc");
const ownerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
// A stranger holds nothing — including the PUBLISHED document, until the repo
// link reaches them (§5: whoever has the URL reads the content).
setCurrentUser("stranger");
const strangerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
getCaps().learn(link);
const strangerWithLinkView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
resetCaps();
injectedSetItems = [];
setCurrentUser(null);
return { ownerView, strangerView, strangerWithLinkView };
},
/**
* Sharing a cap the way the model does it: the owner deposits it into the
* recipient's INBOX, and the recipient processing that inbox absorbs it. No
* "receive" operation exists, and no principal is ever named to the registry.
* Runs against the REAL broker inbox document, so it exercises the whole path.
*/
/**
* The DEPOSIT side of a document's inbox, end to end against the real broker: the
* owner opens it, a third party RESOLVES its address from the document itself and
* deposits, the owner reads it back.
*
* The point of the step is the resolution: nothing hands `depositorId` the address.
* It gets the document's link (which is what circulates in this model) and must find
* where to deposit on its own — which is exactly what a consumer app has to do, and
* what a unit test passing the NURI through a variable cannot prove.
*/
async documentInboxDeposit(ownerId: string, depositorId: string) {
storeRegistry.resetRegistryCache();
setCurrentUser(ownerId);
const doc = await storeRegistry.createEntityDoc(ownerId, "public");
const ownerInbox = await storeRegistry.openDocumentInbox(doc);
const link = capFor(doc)!; // the repo link the owner circulates
setCurrentUser(depositorId);
getCaps().learn(link);
const resolved = await storeRegistry.documentInboxAddress(doc);
// The one-call form an app actually uses: it names the DOCUMENT, never an inbox.
await inbox.postToDocument(doc, { payload: { viaPostToDocument: true }, ts: 900 });
// Opening one on someone else's document must be refused, not silently forked.
let openRefused = false;
try {
await storeRegistry.openDocumentInbox(doc);
} catch {
openRefused = true;
}
if (resolved) await inbox.post(resolved, { payload: { joining: true }, ts: 1000 });
setCurrentUser(ownerId);
const deposits = await inbox.read(ownerInbox);
// The address is machinery: it must not surface among the document's properties.
const subjects = await readModel.readUnion([doc]);
const props = Object.keys(subjects[0]?.props ?? {});
setCurrentUser(null);
return {
sameInbox: resolved === ownerInbox,
openRefused,
deposits: deposits.map((d) => d.payload),
props,
};
},
async capsShareCap(friendId: string) {
const s = await sessionReady;
resetCaps();
// The recipient's OWN inbox — the address a cap is delivered to. Resolved while
// connected as them, since that is who owns it and who may later read it.
// `friendId` is fresh per run: this test's assertions survive accumulated caps, but
// the recipient's durable Links would grow run after run on a persistent wallet,
// making every later `connectedUser()` re-apply a longer and longer history.
setCurrentUser(friendId);
const friendInbox = await storeRegistry.walletInbox(friendId);
setCurrentUser("owner-O");
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
injectedSetItems = [{ "@graph": doc, "@id": "1", v: "shared-item" }];
getCaps().open(doc, "protected");
const cap = capFor(doc)!;
setCurrentUser(friendId);
const before = [...(libUseShape(null, null) as Iterable<any>)].length;
setCurrentUser("owner-O");
await shareCap(cap, friendInbox);
setCurrentUser(friendId);
const absorbed = await inbox.read(friendInbox); // processing it applies the cap
const after = [...(libUseShape(null, null) as Iterable<any>)].length;
resetCaps();
injectedSetItems = [];
setCurrentUser(null);
// `absorbed` must be EMPTY: a cap delivery is infrastructure, never surfaced
// to the consumer as a deposit.
return { before, after, surfacedDeposits: absorbed.length };
},
// ── 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<string> {
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<string> {
// 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);
}
})();