Files
ng-eventually/packages/polyfill/e2e/polyfill-entry.ts
T
Sylvain Duchesne f36ed5c164 fix!: un scope nomme refuse se rouvre quand la cle arrive, et la barriere cesse d-etre serie
Revue adversariale : la barriere livree au tour precedent rendait le refus
TERMINAL. Le signal du trousseau s'ecrit `if (query !== null) publish(...)`, parce
qu'avant la barriere il n'y avait qu'une seule raison de n'avoir pas de requete -
rien n'avait encore ete tente - et qu'une cle qui arrive ne pouvait changer que ce
qu'une requete OUVERTE avait le droit de montrer. La barriere en a ajoute une
seconde : elle abandonne AVANT que la requete existe, donc le seul canal qui fait
converger une cle tardive passait a cote de l'etat qui en avait le plus besoin.
Une page qui s'abonne pendant que son trousseau se restaure, ou un document
partage sous les yeux de son destinataire, publiait `isError` et y restait. Ce
n'est pas une surface plus stricte, c'est une pire : echanger un vide mensonger
contre une erreur bloquee ne repare rien, et cela casse une garantie que ce paquet
publie.

Le signal rouvre donc un scope refuse, et seulement quand le refus est leve
(`namedDocumentsReachable`). Le garde-fou porte autant que la reouverture :
rouvrir a chaque changement de trousseau rejouerait la barriere pour chaque cle
qu'une connexion depose, redemanderait au reseau tout document dont le
telechargement avait ECHOUE - un echec n'est deliberement pas memoise - et
republierait la meme erreur a chaque abonne. La variante indiscriminee casse deux
tests, dont celui de demontage qui preexistait.

La barriere passe en parallele. Elle etait serie, donc N documents qui ne poussent
jamais de `State` coutaient N replis bornes, 8s chacun en production. Les refus
restent lus dans l'ordre du scope apres coup.

Et ce qui a laisse passer les deux : l'e2e ne parcourait pas ce chemin. Les deux
journeys `useShapeQuery` lisent `{graphs: "did:ng:i"}`, donc 43/43 n'a jamais rien
prouve sur la barriere. Quatre controles ajoutes sur un scope NOMME, contre le
vrai broker : la proprietaire lit son document, un porteur de rien recoit
`isError` et jamais un succes vide, un document PUBLIC s'ouvre pour lui depuis la
seule reference via la requete de la barriere, et une cle qui tombe pour un AUTRE
document laisse le scope refuse en place.
2026-09-21 15:57:56 +02:00

1295 lines
59 KiB
TypeScript

/**
* Polyfill e2e harness entry — the MINIMAL page loaded inside the broker iframe.
*
* It imports the REAL `@ng-org/web` `ng` + this package (`@ng-eventually/polyfill`),
* 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";
// The REAL ORM. `useShapeQuery` reads through the SDK's own shape query since
// `1.0.0-dev.6`, so the harness has to hand it one: a fake Set-like is enough for the
// read FILTER (which is pure) and says nothing about a read that reaches the broker.
// `initNg` is what resolves the ORM's own session promise; nothing starts before it.
import { initNg as ormInitNg, RdfOrmSubscription } from "@ng-org/orm";
import {
configure,
docs,
init,
subscribeDoc,
subscribeDocs,
inbox,
storeRegistry,
useShape as libUseShape,
useShapeQuery,
initNg,
} from "@ng-eventually/polyfill";
// 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 {
adoptCurrentUser,
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/polyfill";
// 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";
/**
* The published reference of one of the connected identity's three stores.
*
* Placement stopped being spelled as a word on the published surface in `1.0.0-dev.5`:
* an application names a store by what `privateStore()`, `protectedStore()` or
* `publicStore()` hands it. The journeys below still walk the three, so this is the step
* from one to the other - through the published calls.
*/
/** The whole local dataset - upstream's own marker, what `""` normalises to. */
const EVERYTHING = { graphs: "did:ng:i" };
/**
* The shape the two `useShapeQuery` journeys read - built here rather than inline, since
* both of them need the same one and it is now handed to the SDK.
*
* Two predicates, both `minCardinality: 1`, because that is what
* `engine/verifier/src/orm/graph/query.rs:schema_shape_to_sparql` turns into REQUIRED
* triple patterns: a shape constraining nothing would match every subject in the dataset.
* The title is the second one, and it is what proves the real data landed rather than an
* empty object of the right type.
*/
function wsShape(cls: string) {
return {
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" }],
},
{
iri: "urn:e2e:ws:title",
readablePredicate: "title",
maxCardinality: 1,
minCardinality: 1,
dataTypes: [{ valType: "string" }],
},
],
},
},
} satisfies ShapeType<BaseType>;
}
const storeOfType = (t: "public" | "protected" | "private"): Promise<Nuri> =>
t === "public"
? storeRegistry.publicStore()
: t === "protected"
? storeRegistry.protectedStore()
: storeRegistry.privateStore();
import type { BaseType, Nuri, ShapeObservable, ShapeQuery, ShapeType } from "@ng-eventually/polyfill";
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 reserved prefix: triples whose SUBJECT falls under it are this package's own
* machinery, not the document's data. `readUnion` dropped them for the caller; an
* application composing its own read drops them itself, and the contract says so. */
const RESERVED_SUBJECT_PREFIX = "urn:ng-eventually:";
/**
* Read ONE document with the published anchored query and group its rows per subject -
* what an application composes since `readUnion` was removed in `1.0.0-dev.8`.
*
* It THROWS, exactly like `docs.sparqlQuery` under it: a document that cannot be read is
* a failure, never an empty answer. Every caller below that wants to carry on past one
* says so with a `catch` of its own, which is where upstream leaves that decision too.
*/
async function readOneDocument(
sessionId: string,
docLike: string,
): Promise<Array<{ subject: string; graph: Nuri; props: Record<string, string[]> }>> {
const doc = asNuri(docLike);
// Anchored -> default graph = this repo, so the body carries no `GRAPH <...>` wrapper.
const res: unknown = await docs.sparqlQuery(
sessionId,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined,
doc,
);
const rows = Array.isArray(res)
? (res as Array<Record<string, { value: string } | undefined>>)
: ((res as { results?: { bindings?: Array<Record<string, { value: string } | undefined>> } })
.results?.bindings ?? []);
const bySubject = new Map<string, { subject: string; graph: Nuri; props: Record<string, string[]> }>();
for (const row of rows) {
const subject = row["s"]?.value;
const p = row["p"]?.value;
const o = row["o"]?.value;
if (subject === undefined || !p || o === undefined) continue;
if (subject.startsWith(RESERVED_SUBJECT_PREFIX)) continue;
let entry = bySubject.get(subject);
if (!entry) {
entry = { subject, graph: doc, props: {} };
bySubject.set(subject, entry);
}
(entry.props[p] ??= []).push(o);
}
return [...bySubject.values()];
}
// ── 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,
// The SDK's own shape query, passed as it stands - no wrapper, no cast. This is what
// `useShapeQuery` reads through, so the journeys below run against the REAL broker
// rather than against anything this page composed.
ormSubscription: RdfOrmSubscription.getOrCreate,
init: realInit,
initNg: ormInitNg,
// 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" },
});
/**
* Who this page opens as — and why it has to open as somebody.
*
* The boot below goes through the POLYFILL's `init`, not the injected one, so that the
* 42 checks run over the ordering an application actually gets: settle the identity, THEN
* hand the page to the broker (`surface/lifecycle.ts`). A page with no identity would
* raise the barrier instead and never hand over, so the harness supplies one.
*
* Recorded with `adoptCurrentUser`, which names the identity and stops there — the
* session-free half, the same one the access gate settles with (`bootstrap.ts`). Naming it
* through `setCurrentUser` would FIRE the connection work: it costs the batch an account
* lookup and a broker round-trip for a boot identity nothing reads, and it registers a
* connection in flight for a user that does not exist. It used to fire nothing here for an
* incidental reason — the registry was still unwired at this line — and `configure` wires
* it now, so the intent is stated by the call instead of by the ordering. Every check that
* cares about identity sets its own anyway; this one is only what the page opened as.
*/
const BOOT_IDENTITY = "e2e-harness";
adoptCurrentUser(BOOT_IDENTITY);
// The harness keeps its OWN route to the session, substituted through the internal wiring
// path AFTER `configure` has pointed the registry at the package's. Not redundancy: the
// checks below tear a session down and start another (`session_stop` + `session_start`, the
// reconnection cold-start), and only this page knows about the second one — the package's
// holder is fed by `init()`'s callback, which the broker fires once per page.
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 that it stands aside once the identity is known.
*
* That last one holds HERE because this page runs inside the broker iframe, and only
* there. Top-level the barrier shows on every load — a known identifier prefills the
* field instead of skipping the screen, since knowing who someone is says nothing about
* whether their browser still holds the wallet, and the broker's answer for someone who
* does not is a dead end with no way back. That side is the applicative suite's to walk
* (`e2e/notebook.ts`), with a real page and a real Back button.
*/
async accessGateFirstVisit(raw: string) {
setCurrentUser(null);
try { window.localStorage.removeItem("ng-eventually:identity"); } catch {}
// A first visit has no `?ng-id=` either, and the URL is the branch the gate consults
// FIRST — so clearing storage alone stopped describing a first visit the moment the
// boot started settling an identity (which writes the parameter, as every settling
// path must). Leaving it there would make this check pass for the wrong reason on a
// gate that had stopped asking at all.
try {
const withoutIdentity = new URL(window.location.href);
withoutIdentity.searchParams.delete("ng-id");
window.history.replaceState(null, "", withoutIdentity.toString());
} 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,
};
},
/** Inside the iframe, the barrier must stay away once an identity is known. */
async accessGateIdentityAlreadyKnown(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 { <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,
};
},
// ── the anchored document read ───────────────────────────────────────────
/**
* Create N docs, write a marker triple into each (anchored default graph), then read
* them ONE BY ONE with the published anchored query. Optionally inject a bad NURI, to
* show where the tolerance lives now: at THIS call site, as a visible `catch`, not
* inside a primitive. `readUnion` used to swallow it and answer with the rest, which
* is a policy upstream does not have - an anchored query on a repo the verifier does
* not know REJECTS (`resolve_target_for_sparql` -> `NgError::RepoNotFound`).
*/
async readDocsOneByOne(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);
}
// Two SUBJECTS in the LAST document, so the probe can tell "one entry per document"
// from "one entry per subject". Until 2026-08-10 the read 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 { <urn:e2e:rm:extra> <urn:e2e:idx> "extra" }`,
docNuris[n - 1]!,
);
}
const toRead: Nuri[] = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris;
const subjects: Array<{ subject: string; graph: Nuri; props: Record<string, string[]> }> = [];
const skipped: string[] = [];
for (const d of toRead) {
try {
subjects.push(...(await readOneDocument(s.session_id, d)));
} catch {
// THIS harness chooses to carry on. Another caller may choose not to - that is
// the point of the decision living here.
skipped.push(d);
}
}
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),
skipped,
subjects,
};
},
/**
* The possession gate on the anchored read: create a doc as owner O (whose keyring
* gets its cap), then read it as a DIFFERENT identity, which holds nothing. The
* stranger has the document's NURI in hand throughout: naming is not reading.
*
* The stranger is now REFUSED rather than handed an empty list - `readUnion` dropped
* an unreachable document from its batch, and there is no batch to drop it from.
*/
async anchoredReadCapGate() {
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");
let strangerRefused = false;
let strangerCount = -1;
try {
strangerCount = (await readOneDocument(s.session_id, doc)).length;
} catch {
strangerRefused = true;
}
setCurrentUser("owner-O");
const asOwner = await readOneDocument(s.session_id, doc);
resetCaps();
setCurrentUser(null);
return { strangerRefused, strangerCount, 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 `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(await storeRegistry.publicStore());
const dA2 = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
setCurrentUser(idB);
const dB1 = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
// 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 registryInternals.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") {
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(await storeOfType(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++) {
registryInternals.resetRegistryCache();
listed = await registryInternals.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 read each
* with its own anchored query, 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 registryInternals.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
// `readOneDocument`'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)
}
// One anchored read per listed document, and the tolerance is HERE, visibly: a
// reconnect probe that lost one document to a broker hiccup still reports what the
// others held, because THIS caller decided so. The primitive rejects.
const subjects: Array<{ subject: string; graph: Nuri; props: Record<string, string[]> }> = [];
const unread: string[] = [];
for (const d of listed.length ? listed : [asNuri(entityNuri)]) {
try {
subjects.push(...(await readOneDocument(s.session_id, d)));
} catch {
unread.push(d);
}
}
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,
unread,
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 };
},
// `resolveScopeGraph` stopped being published in `1.0.0-dev.5` — a store's graph holds
// its containment triples and nothing else, so the value it handed out matched nothing
// as a read scope upstream. The probe keeps measuring the mapping, at its internal path:
// a harness may do that, an application may not.
async scopeResolvers() {
const priv = await registryInternals.resolveScopeGraph("private");
const prot = await registryInternals.resolveScopeGraph("protected");
const pub = await registryInternals.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) {
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: "" };
}
},
// ── useShapeQuery (reactive useQuery-shaped read) ────────────────────────
// The shape is `wsShape(cls)` above: a hand-written `ShapeType`, two required
// predicates, generic and with no application domain. The read goes through the
// SDK's own shape query now, so these two journeys measure the REAL broker.
_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 `useShapeQuery` over
* (that class shape, the whole dataset). 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 useShapeQuerySeedAndSubscribe(handle: string, cls: string) {
registryInternals.resetRegistryCache();
const id = "@ws-" + handle;
setCurrentUser(id);
const doc = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
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++) {
registryInternals.resetRegistryCache();
const listed = await registryInternals.listMyEntityDocs(id, "protected");
if (listed.includes(doc)) break;
await new Promise((r) => setTimeout(r, 1000));
}
const shape = wsShape(cls);
const obs = useShapeQuery(shape, EVERYTHING) 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 — useShapeQuery resolves
// the scope from getCurrentUser() on every (reactive) refresh, exactly as the app
// keeps a stable identity. useShapeQueryStop clears it.
return { doc, cls, initial: { isPending: initial.isPending, isSuccess: initial.isSuccess, dataLen: initial.data.length } };
},
/** Current snapshot of a useShapeQuery probe (event-driven poll target). */
useShapeQuerySnapshot(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). It is the
// shape's own readable predicate now, not a property bag: the objects come from the
// ORM.
titles: snap.data.map((d) => d.title).filter((v) => typeof v === "string"),
};
},
useShapeQueryStop(handle: string) {
const rec = (window as any).__sdk._wsProbes[handle];
if (rec) rec.unsub();
setCurrentUser(null);
},
/**
* NAMED-SCOPE seed. Under a fresh identity, create TWO entity documents carrying the
* shape's type - one PROTECTED, one PUBLIC - and seed each with a distinguishable
* title. Returns the owner and both references, which is all any of the three named
* subscriptions below is given.
*
* Why this exists at all: until `1.0.0-dev.9` every `useShapeQuery` journey in this
* suite read `{ graphs: "did:ng:i" }`, so the scope axis that NAMES a document was
* measured nowhere against the real broker - and the barrier that names introduced
* shipped with no live coverage. A suite that is green on a path it never walks is
* worse than one that is red.
*/
async useShapeQueryNamedSeed(cls: string) {
registryInternals.resetRegistryCache();
const owner = "@wsn-" + Date.now();
setCurrentUser(owner);
const s = await sessionReady;
const prot = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <${prot}> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <${cls}> ; <urn:e2e:ws:title> "named-protected" }`,
prot,
);
// PUBLIC, so its ReadCap is exposed on its Header branch at creation - the one
// channel by which a bare reference alone opens a document, and the acquisition the
// barrier now performs on this call.
const pub = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <${pub}> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <${cls}> ; <urn:e2e:ws:title> "named-public" }`,
pub,
);
setCurrentUser(null);
return { owner, prot, pub };
},
/**
* Subscribe a NAMED scope under `who` - the owner, or somebody holding nothing.
*
* The identity is set here and left set for the probe's lifetime, like the two
* journeys above: `useShapeQuery` asks the keyring about whoever is connected on every
* read, exactly as an application with one stable identity per page does.
* `useShapeQuerySnapshot` and `useShapeQueryStop` serve this probe too.
*/
useShapeQueryNamedSubscribe(handle: string, who: string, doc: string, cls: string) {
registryInternals.resetRegistryCache();
setCurrentUser(who);
const obs = useShapeQuery(wsShape(cls), { graphs: [doc] }) as ShapeObservable;
const unsub = obs.subscribe(() => {});
const initial = obs.getSnapshot();
(window as any).__sdk._wsProbes[handle] = { obs, initial, unsub };
return {
initial: {
isPending: initial.isPending,
isSuccess: initial.isSuccess,
isError: initial.isError,
},
};
},
/** The error message a named scope published, so the runner can tell the refusal from
* any other failure rather than accepting `isError` as such. */
useShapeQueryError(handle: string) {
const rec = (window as any).__sdk._wsProbes[handle];
if (!rec) return null;
const snap: ShapeQuery = rec.obs.getSnapshot();
return snap.error === undefined ? null : String((snap.error as Error)?.message ?? snap.error);
},
/**
* EMPTY-scope proof: a brand-new identity with NO entity docs at all. `useShapeQuery`
* must reach isSuccess with data:[] (synced-but-empty), NOT stay pending. Returns
* the handle; poll useShapeQuerySnapshot for the transition.
*/
useShapeQueryEmptyStart(handle: string, cls: string) {
registryInternals.resetRegistryCache();
const id = "@ws-empty-" + handle;
setCurrentUser(id);
const shape = wsShape(cls);
const obs = useShapeQuery(shape, EVERYTHING) 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 (useShapeQueryStop 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
// cap-surface 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<any>)].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<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 };
},
// 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<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.
//
// Through the POLYFILL's `init`, not the injected `realInit` — even though `realInit` is
// what ends up being called (it is what `configure` injects, above). Calling it directly
// skipped the forwarder that settles the identity before delegating, so the 42 checks ran
// over an ordering no application has, and the defect that ordering exists to prevent —
// the hand-over happening before the identity reaches the address bar — could not have
// been caught here. What an application writes is this line.
(async () => {
try {
await init(
(event: { session: BrokerSession }) => {
session = event.session;
state.status = "connected";
// The ORM reaches the SDK through a module-level promise of its own, resolved
// once. Nothing it opens starts before this, so it goes here rather than beside
// the first read - through the PACKAGE's `initNg`, which is the call an
// application makes.
//
// Since `1.0.0-dev.7` that call SUBSTITUTES the package's wrapped `ng` for the
// one passed here, so the two `useShapeQuery` journeys below are now the proof
// that `orm_start_graph` survives the wrapper - and they are the only thing that
// is. A consumer wiring exactly this path got `orm_start_graph is not a function`
// and a read pending for ever, while this suite stayed green precisely because
// the ORM had been handed the raw SDK object here instead.
initNg(realNg, session);
sessionResolve(session);
},
true,
[],
);
} catch (e) {
state.status = "error";
state.error = e instanceof Error ? e.message : String(e);
}
})();