d98777e6e3
Les tests du polyfill doivent PROUVER son contrat contre le VRAI broker (pas un fake). Ajout de deux tests e2e broker réel (wallet dédié), avec reconnexion FIDÈLE : page fraîche sur le MÊME profil persistant + nouveau login broker (helper `faithfulReconnect`), PAS export/réimport dans un profil vide (qui resynchronise et masque le cold-open). - Contrat reconnexion/cold-read : session 1 crée un doc (public + protected) ; session fraîche fidèle ; relit → données présentes, en POLLANT une borne généreuse (l'attente de récupération après souscription est le fonctionnement normal). - Contrat non-fork : re-résoudre le même identifiant après reconnexion rend les MÊMES NURIs de docs de scope, pas un second provisioning. Temps de sync réels observés (le SIGNAL) : protected relu en ~195ms (repo déjà ouvert) ; PUBLIC relu en ~105s (ouverture repo + attente push + union ancrée à l'échelle de la donnée publique) ; login de reconnexion ~2.5s. Le ~105s public est un signal de PERFORMANCE À INVESTIGUER (probable scale/bloat de l'anchorless union scan, cf. suivi bloat) — le contrat est REMPLI mais lent côté public. Honnêteté : le « échoue-sans-le-fix » n'a PAS pu être montré via ce chemin de login (le bootstrap du login broker ouvre déjà les repos → firstRawNoOpen=1). Contrat prouvé REMPLI avec les fix ; nécessité non isolable via cette voie. gate : test:e2e 33 passed (baseline 27 vert) ; bun test 117 ; tsc propre. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
630 lines
27 KiB
TypeScript
630 lines
27 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, getCaps, resetCaps } from "@ng-eventually/client/polyfill";
|
|
import {
|
|
docs,
|
|
subscribeDoc,
|
|
subscribeDocs,
|
|
readModel,
|
|
inbox,
|
|
discovery,
|
|
storeRegistry,
|
|
useShape as libUseShape,
|
|
accounts,
|
|
} from "@ng-eventually/client";
|
|
|
|
const { IdentityStore } = accounts;
|
|
|
|
// ── The broker session, resolved once the iframe connects ──────────────────
|
|
interface BrokerSession {
|
|
session_id: string;
|
|
private_store_id: string;
|
|
protected_store_id: string;
|
|
public_store_id: string;
|
|
[k: string]: unknown;
|
|
}
|
|
|
|
let session: BrokerSession | null = null;
|
|
let sessionResolve!: (s: BrokerSession) => void;
|
|
const sessionReady = new Promise<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/discovery/read-model) reach the session
|
|
// through this. It resolves once the broker connects.
|
|
getSession: async () => {
|
|
// Read the CURRENT session (mutable): a fresh session (session_stop+session_start
|
|
// for the reconnection cold-start test) swaps `session` in place, and the SDK must
|
|
// route reads/opens through the NEW session_id. Fall back to the first-connect
|
|
// promise until the initial session lands.
|
|
const s = session ?? (await sessionReady);
|
|
return {
|
|
sessionId: s.session_id,
|
|
privateStoreId: s.private_store_id,
|
|
protectedStoreId: s.protected_store_id,
|
|
publicStoreId: s.public_store_id,
|
|
};
|
|
},
|
|
// Identity normalization used as the shim key (lowercase, strip leading `@`).
|
|
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
|
|
});
|
|
|
|
// ── Bridge marshaling note ─────────────────────────────────────────────────
|
|
// Everything crossing frame.evaluate must be structured-cloneable. NURIs/strings/
|
|
// numbers/plain objects are fine. We never return functions or the raw `ng`.
|
|
|
|
const state: { status: string; error?: string } = { status: "connecting" };
|
|
|
|
// Identity store over the iframe's localStorage (the real AccountStorage).
|
|
const identity = new IdentityStore(
|
|
typeof window !== "undefined" && window.localStorage ? window.localStorage : null,
|
|
);
|
|
|
|
// Expose the bridge immediately (status reflects connection progress).
|
|
(window as any).__sdk = {
|
|
status: () => state.status,
|
|
error: () => state.error ?? null,
|
|
sessionInfo: () =>
|
|
session
|
|
? {
|
|
session_id: session.session_id,
|
|
private_store_id: session.private_store_id,
|
|
protected_store_id: session.protected_store_id,
|
|
public_store_id: session.public_store_id,
|
|
}
|
|
: null,
|
|
|
|
/**
|
|
* Export the CURRENT wallet as a `.ngw` file, base64-encoded so it can cross the
|
|
* frame.evaluate bridge back to Node. Used by the reconnection cold-start test to
|
|
* re-import the SAME wallet into a CLEAN browser profile (empty local repo storage)
|
|
* — the only faithful way to force the broker-only cold-start (a fresh profile has
|
|
* no local IndexedDB copy of the repos to eagerly rehydrate).
|
|
*/
|
|
async exportWalletFile() {
|
|
const ws = await (realNg as any).get_wallets();
|
|
const walletName = Object.keys(ws ?? {})[0];
|
|
const file = await (realNg as any).wallet_get_file(walletName);
|
|
const bytes = file instanceof Uint8Array ? file : new Uint8Array(file);
|
|
let bin = "";
|
|
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
|
|
return { walletName, b64: btoa(bin), len: bytes.length };
|
|
},
|
|
|
|
// ── docs primitives ──────────────────────────────────────────────────────
|
|
async docCreate() {
|
|
const s = await sessionReady;
|
|
return docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
|
},
|
|
async sparqlUpdate(query: string, anchor?: string) {
|
|
const s = await sessionReady;
|
|
return docs.sparqlUpdate(s.session_id, query, anchor);
|
|
},
|
|
async sparqlQuery(query: string, anchor?: string) {
|
|
const s = await sessionReady;
|
|
return docs.sparqlQuery(s.session_id, query, undefined, anchor);
|
|
},
|
|
/**
|
|
* The load-bearing graph-behavior characterization against the REAL broker.
|
|
* fake-ng cannot verify any of this — it needs the broker's repo_graph_name
|
|
* overlay. This is the ground truth the lib's write/read comments cite.
|
|
*
|
|
* Anchored to doc D, it measures each write shape:
|
|
* (a) `INSERT DATA { <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: string[] = [];
|
|
for (let i = 0; i < n; i++) {
|
|
const d = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
|
await docs.sparqlUpdate(
|
|
s.session_id,
|
|
`INSERT DATA { <urn:e2e:rm:${i}> <urn:e2e:idx> "${i}" }`,
|
|
d,
|
|
);
|
|
docNuris.push(d);
|
|
}
|
|
const toRead = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris;
|
|
const subjects = await readModel.readUnion(toRead);
|
|
return { docNuris, subjectCount: subjects.length, subjects };
|
|
},
|
|
/**
|
|
* readUnion cap gate: create a doc, mark it protected for owner O, set the
|
|
* current user to a DIFFERENT identity, and readUnion → the doc is dropped.
|
|
*/
|
|
async readUnionCapGate() {
|
|
const s = await sessionReady;
|
|
resetCaps();
|
|
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
|
await docs.sparqlUpdate(s.session_id, `INSERT DATA { <urn:e2e:cg> <urn:e2e:p> "x" }`, doc);
|
|
getCaps().open(doc, "protected", "owner-O");
|
|
setCurrentUser("someone-else");
|
|
const asStranger = await readModel.readUnion([doc]);
|
|
setCurrentUser("owner-O");
|
|
const asOwner = await readModel.readUnion([doc]);
|
|
resetCaps();
|
|
setCurrentUser(null);
|
|
return { strangerCount: asStranger.length, ownerCount: asOwner.length };
|
|
},
|
|
|
|
// ── reactivity (doc_subscribe) ───────────────────────────────────────────
|
|
// The test installs a counter; subscribeDoc pushes an initial state, then a
|
|
// push per subsequent write. We record every push and expose the count/log so
|
|
// the test can wait event-driven (waitForFunction on the counter), never timed.
|
|
_subs: {} as Record<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}" }`,
|
|
doc,
|
|
);
|
|
},
|
|
subscribeStop(handle: string) {
|
|
const rec = (window as any).__sdk._subs[handle];
|
|
if (rec) rec.unsub();
|
|
},
|
|
/**
|
|
* subscribeDocs isolation: subscribe to [goodDoc, deadDoc]. The dead doc's
|
|
* subscription fails in isolation; the good doc still fires on its write.
|
|
*/
|
|
_multiSub: { good: 0, bad: 0, unsub: () => {} },
|
|
async subscribeDocsStart() {
|
|
const s = await sessionReady;
|
|
const good = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
|
const bad = "did:ng:o:absent-doc-never-created";
|
|
const rec = { good: 0, bad: 0, unsub: () => {} };
|
|
(window as any).__sdk._multiSub = rec;
|
|
rec.unsub = subscribeDocs([good, bad], (nuri) => {
|
|
if (nuri === good) rec.good += 1;
|
|
else rec.bad += 1;
|
|
});
|
|
return { good, bad };
|
|
},
|
|
multiSubCounts() {
|
|
const r = (window as any).__sdk._multiSub;
|
|
return { good: r.good, bad: r.bad };
|
|
},
|
|
multiSubStop() {
|
|
(window as any).__sdk._multiSub.unsub();
|
|
},
|
|
|
|
// ── inbox ────────────────────────────────────────────────────────────────
|
|
async inboxPostRead(payloadA: unknown, payloadB: unknown) {
|
|
const s = await sessionReady;
|
|
const target = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
|
setCurrentUser("inbox-user");
|
|
await inbox.post(target, { payload: payloadA, from: null, ts: 1000 });
|
|
await inbox.post(target, { payload: payloadB, from: null, ts: 2000 });
|
|
const deposits = await inbox.read(target);
|
|
setCurrentUser(null);
|
|
return { target, deposits };
|
|
},
|
|
// watch (doc_subscribe-based) fires when a deposit lands.
|
|
_inboxWatch: { fires: 0, lastLen: -1, unsub: () => {}, target: "" },
|
|
async inboxWatchStart() {
|
|
const s = await sessionReady;
|
|
const target = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
|
const rec = { fires: 0, lastLen: -1, unsub: () => {}, target };
|
|
(window as any).__sdk._inboxWatch = rec;
|
|
rec.unsub = inbox.watch(target, (deposits) => {
|
|
rec.fires += 1;
|
|
rec.lastLen = deposits.length;
|
|
});
|
|
return { target };
|
|
},
|
|
async inboxWatchDeposit(payload: unknown) {
|
|
const rec = (window as any).__sdk._inboxWatch;
|
|
setCurrentUser("watcher");
|
|
await inbox.post(rec.target, { payload, from: null });
|
|
setCurrentUser(null);
|
|
},
|
|
inboxWatchState() {
|
|
const r = (window as any).__sdk._inboxWatch;
|
|
return { fires: r.fires, lastLen: r.lastLen };
|
|
},
|
|
inboxWatchStop() {
|
|
(window as any).__sdk._inboxWatch.unsub();
|
|
},
|
|
// spoof guard: depositing as another principal throws.
|
|
async inboxSpoofGuard() {
|
|
const s = await sessionReady;
|
|
const target = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
|
setCurrentUser("alice");
|
|
let threw = false;
|
|
try {
|
|
await inbox.post(target, { payload: { x: 1 }, from: "bob" });
|
|
} catch {
|
|
threw = true;
|
|
}
|
|
// self + anonymous both allowed
|
|
let selfOk = true, anonOk = true;
|
|
try { await inbox.post(target, { payload: { x: 2 }, from: "alice" }); } catch { selfOk = false; }
|
|
try { await inbox.post(target, { payload: { x: 3 }, from: null }); } catch { anonOk = false; }
|
|
setCurrentUser(null);
|
|
return { spoofRejected: threw, selfOk, anonOk };
|
|
},
|
|
|
|
// ── discovery index ──────────────────────────────────────────────────────
|
|
async discoverySubmitRead(ref: unknown) {
|
|
setCurrentUser("publisher");
|
|
await discovery.submitToIndex(ref);
|
|
setCurrentUser(null);
|
|
const entries = await discovery.readIndex();
|
|
return { entries };
|
|
},
|
|
_discWatch: { fires: 0, lastLen: -1, unsub: () => {} },
|
|
discoveryWatchStart() {
|
|
const rec = { fires: 0, lastLen: -1, unsub: () => {} };
|
|
(window as any).__sdk._discWatch = rec;
|
|
rec.unsub = discovery.watchIndex((entries) => {
|
|
rec.fires += 1;
|
|
rec.lastLen = entries.length;
|
|
});
|
|
},
|
|
async discoverySubmit(ref: unknown) {
|
|
setCurrentUser("publisher2");
|
|
await discovery.submitToIndex(ref);
|
|
setCurrentUser(null);
|
|
},
|
|
discoveryWatchState() {
|
|
const r = (window as any).__sdk._discWatch;
|
|
return { fires: r.fires, lastLen: r.lastLen };
|
|
},
|
|
discoveryWatchStop() {
|
|
(window as any).__sdk._discWatch.unsub();
|
|
},
|
|
// reserved @index account isolation: a real user named "index"/"@index" resolves
|
|
// to a DIFFERENT account than the reserved index owner.
|
|
async discoveryIndexIsolation() {
|
|
const userIndex = await storeRegistry.ensureAccount("@index");
|
|
const reserved = await storeRegistry.ensureAccount(discovery.INDEX_ACCOUNT);
|
|
return {
|
|
userIndexDoc: userIndex.docPublic,
|
|
reservedDoc: reserved.docPublic,
|
|
disjoint: userIndex.docPublic !== reserved.docPublic,
|
|
};
|
|
},
|
|
|
|
// ── store-registry ───────────────────────────────────────────────────────
|
|
async ensureAccountIdempotent(id: string) {
|
|
storeRegistry.resetRegistryCache();
|
|
const first = await storeRegistry.ensureAccount(id);
|
|
storeRegistry.resetRegistryCache();
|
|
const second = await storeRegistry.ensureAccount(id);
|
|
return {
|
|
firstDocs: [first.docPublic, first.docProtected, first.docPrivate],
|
|
secondDocs: [second.docPublic, second.docProtected, second.docPrivate],
|
|
same:
|
|
first.docPublic === second.docPublic &&
|
|
first.docProtected === second.docProtected &&
|
|
first.docPrivate === second.docPrivate,
|
|
};
|
|
},
|
|
async entityDocsBounded(idA: string, idB: string) {
|
|
storeRegistry.resetRegistryCache();
|
|
const dA1 = await storeRegistry.createEntityDoc(idA, "public");
|
|
const dA2 = await storeRegistry.createEntityDoc(idA, "public");
|
|
const dB1 = await storeRegistry.createEntityDoc(idB, "public");
|
|
// listMyEntityDocs(A) → only A's docs (poll: the index append can lag).
|
|
let listA: string[] = [];
|
|
for (let i = 0; i < 12; i++) {
|
|
storeRegistry.resetRegistryCache();
|
|
listA = await storeRegistry.listMyEntityDocs(idA, "public");
|
|
if (listA.includes(dA1) && listA.includes(dA2)) break;
|
|
await new Promise((r) => setTimeout(r, 1000));
|
|
}
|
|
return {
|
|
dA1, dA2, dB1,
|
|
listA,
|
|
hasA1: listA.includes(dA1),
|
|
hasA2: listA.includes(dA2),
|
|
leaksB: listA.includes(dB1),
|
|
};
|
|
},
|
|
/**
|
|
* RECONNECTION cold-start seed (phase 1, run in the FIRST session).
|
|
*
|
|
* Create a per-entity document under (id, scope) — which also appends its NURI to
|
|
* the account's scope-index document — and write a marker triple INTO the entity
|
|
* doc (anchored default graph, the canonical shape). Poll until listMyEntityDocs
|
|
* sees it, so the index append has actually landed on the broker before we tear
|
|
* the session down. Returns everything the FRESH session needs to re-find it
|
|
* purely from the persistent wallet: only `id` + `scope` are load-bearing (the
|
|
* fresh session re-resolves the account from the shim); entityNuri/marker are the
|
|
* expected values to assert against.
|
|
*/
|
|
async reconnectSeed(id: string, scope: "public" | "protected" | "private") {
|
|
storeRegistry.resetRegistryCache();
|
|
const s = await sessionReady;
|
|
const entityNuri = await storeRegistry.createEntityDoc(id, scope);
|
|
const marker = "recon-" + Date.now();
|
|
await docs.sparqlUpdate(
|
|
s.session_id,
|
|
`INSERT DATA { <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 → readScopeIndex) and readUnion
|
|
* them, purely from the persistent wallet — nothing from phase 1's session state
|
|
* carries over. The SDK's open-before-read heal (open-repo.ts) opens each repo via
|
|
* doc_subscribe before the anchored reads. NB: on the SDK/broker version tested here
|
|
* the broker-login bootstrap already opens the user's repos, so rawRowCount is
|
|
* non-zero even without the heal — this is a reconnection REGRESSION guard, not a
|
|
* fail-without-the-fix proof (see run.ts's reconnection step comment).
|
|
*/
|
|
async reconnectRead(id: string, scope: "public" | "protected" | "private", entityNuri: string, marker: string) {
|
|
// DIAGNOSTIC: a RAW anchored read of the entity doc with NO open at all, first
|
|
// thing in the fresh session — reports how many rows the bare anchored query
|
|
// resolves for a not-yet-opened repo (the premise: 0 until opened). Uses the
|
|
// low-level docs primitive directly, bypassing readUnion's open step.
|
|
const s = session ?? (await sessionReady);
|
|
let rawRowCount = -1;
|
|
try {
|
|
const raw: any = await docs.sparqlQuery(s.session_id, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, entityNuri);
|
|
rawRowCount = Array.isArray(raw) ? raw.length : (raw?.results?.bindings?.length ?? 0);
|
|
} catch (e: any) {
|
|
rawRowCount = -2; // threw (e.g. RepoNotFound / InvalidNuri)
|
|
}
|
|
|
|
storeRegistry.resetRegistryCache();
|
|
const listed = await storeRegistry.listMyEntityDocs(id, scope);
|
|
const subjects = await readModel.readUnion(listed.length ? listed : [entityNuri]);
|
|
const markers: string[] = [];
|
|
for (const subj of subjects) {
|
|
for (const vals of Object.values(subj.props)) {
|
|
for (const v of vals) markers.push(v);
|
|
}
|
|
}
|
|
return {
|
|
rawRowCount,
|
|
listed,
|
|
listedCount: listed.length,
|
|
foundEntity: listed.includes(entityNuri),
|
|
subjectCount: subjects.length,
|
|
markerPresent: markers.includes(marker),
|
|
markers,
|
|
};
|
|
},
|
|
/**
|
|
* 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 };
|
|
},
|
|
|
|
// ── caps / read-filter (in-memory cap model) ─────────────────────────────
|
|
// The read-filter over the injected useShape Set-like. Boundary note: the
|
|
// caps/read-filter are EMULATED in-memory (CapRegistry) — the real broker does
|
|
// NOT yet enforce per-doc read caps here (one shared wallet reads everything).
|
|
// We test what the SDK enforces: the in-memory read-filtered VIEW.
|
|
capsReadFilter() {
|
|
resetCaps();
|
|
injectedSetItems = [
|
|
{ "@graph": "did:ng:o:protdoc", "@id": "1", v: "protected-item" },
|
|
{ "@graph": "did:ng:o:pubdoc", "@id": "2", v: "public-item" },
|
|
{ "@graph": "did:ng:o:ungoverned", "@id": "3", v: "ungoverned-item" },
|
|
];
|
|
getCaps().open("did:ng:o:protdoc", "protected", "owner-O");
|
|
getCaps().makePublic("did:ng:o:pubdoc");
|
|
// as owner-O
|
|
setCurrentUser("owner-O");
|
|
const ownerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
|
|
// as a stranger
|
|
setCurrentUser("stranger");
|
|
const strangerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
|
|
resetCaps();
|
|
injectedSetItems = [];
|
|
setCurrentUser(null);
|
|
return { ownerView, strangerView };
|
|
},
|
|
capsDirectedGrant() {
|
|
resetCaps();
|
|
injectedSetItems = [{ "@graph": "did:ng:o:sharedoc", "@id": "1", v: "shared-item" }];
|
|
getCaps().open("did:ng:o:sharedoc", "protected", "owner-O");
|
|
setCurrentUser("friend");
|
|
const before = [...(libUseShape(null, null) as Iterable<any>)].length;
|
|
getCaps().grantRead("did:ng:o:sharedoc", "friend");
|
|
const after = [...(libUseShape(null, null) as Iterable<any>)].length;
|
|
resetCaps();
|
|
injectedSetItems = [];
|
|
setCurrentUser(null);
|
|
return { before, after };
|
|
},
|
|
|
|
// ── accounts (IdentityStore) ─────────────────────────────────────────────
|
|
identitySet(id: string) { return identity.set(id); },
|
|
identityGet() { return identity.get(); },
|
|
identityClear() { identity.clear(); return identity.get(); },
|
|
};
|
|
|
|
// ── Connect to the real broker ─────────────────────────────────────────────
|
|
// Mirrors ngSession.ts: register the init callback; the broker (this iframe is
|
|
// loaded by it) drives the connection and calls back with the session.
|
|
(async () => {
|
|
try {
|
|
await (realInit as any)(
|
|
(event: any) => {
|
|
session = event.session as BrokerSession;
|
|
state.status = "connected";
|
|
sessionResolve(session);
|
|
},
|
|
true,
|
|
[],
|
|
);
|
|
} catch (e: any) {
|
|
state.status = "error";
|
|
state.error = String(e?.message ?? e);
|
|
}
|
|
})();
|