/** * The access gate's identity resolution. * * This is the piece whose failure is SILENT: get the order wrong and the broker iframe * reads an empty identity, provisions a second virtual user, and the returning user * lands in an empty space with no error anywhere. So the order is pinned, not trusted. */ import { getCurrentUser } from "../src/shared-wallet/bootstrap"; import { test, expect, afterEach } from "bun:test"; import { configure } from "../src/index"; import { init } from "../src/surface/lifecycle"; import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap"; import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap"; import { ensureIdentity } from "../src/shared-wallet/access-gate"; const KEY = "ng-eventually:identity"; /** A localStorage double — the real one is absent in `bun test`. */ function fakeStorage(initial: Record = {}) { const map = new Map(Object.entries(initial)); return { getItem: (k: string) => map.get(k) ?? null, setItem: (k: string, v: string) => void map.set(k, v), removeItem: (k: string) => void map.delete(k), get size() { return map.size; }, }; } /** * Which side of the broker frontier a page is on — the thing that decides whether the * barrier shows at all, so no page double may leave it unsaid. */ type Side = "top-level" | "in the broker iframe"; /** * The `window` a page of that side presents. * * `window.self !== window.top` is the signal the gate reads, and it is the one * `@ng-org/web`'s `init()` reads to decide whether to hand the page over. A double that * omitted `window` would silently be a top-level page — and every framed assertion here * would be quietly testing the other context. */ function fakeWindow(side: Side, onEvent?: (type: string, fn: (e: unknown) => void) => void) { const self = {}; return { self, top: side === "top-level" ? self : {}, addEventListener: (type: string, fn: (e: unknown) => void): void => onEvent?.(type, fn), }; } /** Put the page in a given URL + storage state, on a given side, as the browser would. */ function inPage(search: string, storage: ReturnType, side: Side) { const location = { search, href: "https://app.example" + search }; Object.assign(globalThis, { location, localStorage: storage, history: { replaceState: (): void => {} }, window: fakeWindow(side), }); return { location }; } /** Everything a page installs on the global object, so nothing leaks into the next test. */ const PAGE_GLOBALS = ["location", "localStorage", "history", "document", "window"] as const; afterEach(() => { setCurrentUser(null); resetConfig(); resetStoreRegistry(); for (const name of PAGE_GLOBALS) Reflect.deleteProperty(globalThis, name); }); /** * A broker over an EMPTY wallet: every read answers zero bindings, every write lands * nowhere, and `doc_create` mints a distinct NURI. * * These tests are about the gate, not about storage — but `ensureIdentity()` goes on to * connect the identity it settled, and connecting reaches the broker. It was handed `{}` * here, which no real platform ever presents: `sparql_query` was simply missing, and the * `TypeError` that produced was swallowed by the connection work. Now that a failed * connection is reported (`emulated-verifier/connect.ts`), a fake that cannot answer would * fail every test in this file for a reason none of them is about. An empty wallet — a page * where nobody has signed in yet — is a state the real system produces constantly, and it * changes nothing this file asserts: connecting finds no account and returns. */ function emptyWallet() { let created = 0; return { sparql_query: async (): Promise => ({ results: { bindings: [] } }), sparql_update: async (): Promise => undefined, doc_create: async (): Promise => `did:ng:o:gate${++created}`, }; } function configured(injectedInit?: (...args: unknown[]) => unknown) { configure({ ng: emptyWallet() as never, useShape: (() => {}) as never, sharedWallet: { fileUrl: "/w.ngw", password: "pw" }, // Only where a test needs a hand-over to come back FROM: the delegation is what the // polyfill's `init()` registers, and a page that never delegated was never handed over. ...(injectedInit ? { init: injectedInit } : {}), }); // AFTER `configure`, which wires the registry onto the package's own session — a session // that only `init()` can open, and no page here calls it. The substitution is what lets // these tests reach the gate without a broker, and it must be the last word. configureStoreRegistry({ getSession: async () => ({ sessionId: "s", privateStoreId: "did:ng:o:p" }), normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(), }); } test("an identity already set is left alone — the gate never re-asks", async () => { // The escape hatch: a caller that took charge of the identity itself is not a person // arriving at a page, so there is nobody to show a barrier to — top-level or not. configured(); inPage("", fakeStorage(), "top-level"); setCurrentUser("alice"); await ensureIdentity(); expect(getCurrentUser()).toBe("alice"); }); test("the URL parameter WINS over storage — it is the only thing that crosses the frontier", async () => { // The top-level page and the broker iframe have separate localStorage partitions, so a // value written on one side is not the value the other reads. The URL survives the // round-trip; storage does not. If storage won here, a user entering a second // identifier would keep being sent back to the first one's space. configured(); inPage("?ng-id=fromurl", fakeStorage({ [KEY]: "fromstorage" }), "in the broker iframe"); await ensureIdentity(); expect(getCurrentUser()).toBe("fromurl"); }); test("the URL parameter is copied into THIS partition, so a plain reload still knows", async () => { configured(); const storage = fakeStorage(); inPage("?ng-id=carol", storage, "in the broker iframe"); await ensureIdentity(); expect(storage.getItem(KEY)).toBe("carol"); }); test("inside the iframe, with no parameter, storage answers — the barrier stays away", async () => { // The round-trip is behind us here, so a known identifier settles the question outright. // Top-level it does not, and the pair below says so. configured(); inPage("", fakeStorage({ [KEY]: "dana" }), "in the broker iframe"); await ensureIdentity(); expect(getCurrentUser()).toBe("dana"); }); test("nothing known and no DOM to ask on → it refuses loudly", async () => { // Continuing silently would provision an anonymous virtual space, which is the failure // this module exists to prevent. The error names what the caller must do. configured(); inPage("", fakeStorage(), "top-level"); await expect(ensureIdentity()).rejects.toThrow(/no DOM to ask on/i); }); test("no shared wallet configured → it refuses, rather than inventing a space", async () => { configure({ ng: {} as never, useShape: (() => {}) as never }); inPage("", fakeStorage(), "top-level"); await expect(ensureIdentity()).rejects.toThrow(/no shared wallet configured/i); }); test("the URL value is NORMALIZED on the way in — `@Erin` and `erin` are one space", async () => { // Ported from the consumer's `identifiant-resolution` feature, and it caught a real // defect here: the gate normalized what a user TYPED but not what the URL carried, so // a link with `?ng-id=@Erin` keyed onto a different virtual user than the same person // typing `erin`. One normalizer — the injected one — for all three entry paths. configured(); inPage("?ng-id=@Erin", fakeStorage(), "in the broker iframe"); await ensureIdentity(); expect(getCurrentUser()).toBe("erin"); }); test("a stored value is normalized too — an old entry cannot key onto a second space", async () => { configured(); inPage("", fakeStorage({ [KEY]: "@Frank" }), "in the broker iframe"); await ensureIdentity(); expect(getCurrentUser()).toBe("frank"); }); // ── The parameter must be in the address bar before `init()` reads it ────── // // The round-trip to the broker is `ng.init()`'s job and stays there: it navigates to // NextGraph's login with `window.location.href` AS IT FINDS IT. It knows nothing of // `ng-id`, so the one thing the gate owes the round-trip is that the parameter is already // in the address bar when `init()` runs — whatever settled the identity. Miss one path and // the iframe reads no identity, provisions a second virtual user, and the returning user // lands in an empty space with no error anywhere. // // So every test below asserts on `location.href`: that IS what `init()` would hand over. const APP = "https://app.example/"; /** Let pending microtasks and timers run — the gate resolves on a click, then continues. */ function flush(): Promise { return new Promise((r) => setTimeout(r, 0)); } /** One element of the gate, holding the listeners the gate attaches to it. */ function fakeElement() { const handlers = new Map void)[]>(); return { value: "", disabled: false, textContent: "", addEventListener(type: string, fn: (e: unknown) => void): void { handlers.set(type, [...(handlers.get(type) ?? []), fn]); }, focus(): void {}, fire(type: string, event: unknown = {}): void { for (const fn of handlers.get(type) ?? []) fn(event); }, }; } /** * A DOM for the gate, honest about its one limitation: it does not parse markup, so * `querySelector` hands back the input and the button it was built with rather than ones * found in the HTML. What that markup contains is the e2e's job; what these tests assert * is what happens AFTER a user submits. Everything else is a real element's behaviour: a * value, a disabled flag, listeners that fire. */ function fakeDom() { const input = fakeElement(); const go = fakeElement(); let mounted = 0; const root = { innerHTML: "", querySelector: (sel: string) => (sel === "input" ? input : sel === "button.go" ? go : null), }; const host = { setAttribute: (): void => {}, attachShadow: () => root, remove: (): void => void (mounted -= 1), }; return { document: { createElement: () => host, body: { appendChild: (): void => void (mounted += 1) }, }, /** Is the barrier on screen? */ get shown(): boolean { return mounted > 0; }, /** What the field holds before anyone touches it — the prefill, seen as a user sees it. */ get prefilled(): string { return input.value; }, /** The button's label and whether it can be pressed — the frozen state, once entered. */ get button(): { label: string; disabled: boolean } { return { label: go.textContent, disabled: go.disabled }; }, /** What a user does at the gate: type an identifier, then press Entrer. */ submit(id: string): void { input.value = id; input.fire("input"); go.fire("click"); }, /** What a returning user does: press Entrer on the field as they found it. */ confirm(): void { go.fire("click"); }, /** A press of Enter in the field — which a real one still takes once the button is dead. */ pressEnter(): void { input.fire("keydown", { key: "Enter" }); }, }; } /** * Put the code in a real browser page. * * Faithful in the ways these assertions depend on, because a double that got them wrong * would prove nothing: `location.search` tracks `href`, and `history.replaceState` MOVES * `href` — as a real browser does, and as `init()` relies on when it reads the address bar * afterwards. A fake whose `replaceState` did nothing would let a broken gate pass. */ function inBrowser(opts: { url: string; storage?: ReturnType; side?: Side }) { const storage = opts.storage ?? fakeStorage(); const dom = fakeDom(); const side = opts.side ?? "top-level"; let href = opts.url; let reloads = 0; const location = { get href(): string { return href; }, set href(next: string) { href = next; }, get search(): string { return new URL(href).search; }, reload: (): void => void (reloads += 1), }; const history = { replaceState: (_state: unknown, _title: string, next: string): void => void (href = next), }; // The page-level listeners the gate installs, kept so a test can play the browser event // that fires them. `pageshow` is the only one, and it cannot be simulated any other way. const listeners = new Map void)[]>(); Object.assign(globalThis, { location, history, localStorage: storage, document: dom.document, window: fakeWindow(side, (type, fn) => listeners.set(type, [...(listeners.get(type) ?? []), fn])), }); return { location, storage, dom, /** * How many times the page asked the browser to reload it — which must stay at zero. * A reload throws away everything the application holds in memory, and it cannot opt * out, observe it, or clean up first; the barrier is this package's screen, so putting * it back is this package's business and nothing else on the page may be disturbed. */ get reloads(): number { return reloads; }, /** Is anything listening for the browser restoring this page from its cache? */ get armed(): boolean { return (listeners.get("pageshow") ?? []).length > 0; }, /** How many such listeners — one hand-over or ten, the page needs exactly one. */ get armings(): number { return (listeners.get("pageshow") ?? []).length; }, /** * The browser restoring this page — from its back/forward cache (`persisted`) or * loading it afresh. Only the first is a return from a hand-over that did not complete. */ pageshow(persisted: boolean): void { for (const fn of listeners.get("pageshow") ?? []) fn({ persisted }); }, }; } test("settled at the GATE: the address bar carries the identifier `init()` will hand over", async () => { configured(); const page = inBrowser({ url: APP }); const settled = ensureIdentity(); expect(page.dom.shown).toBe(true); // A first access knows nothing, so the field is bare — the prefill below has to be able // to fail, and it cannot if the field is filled whatever happened. expect(page.dom.prefilled).toBe(""); page.dom.submit("Gina"); expect(await settled).toBe("gina"); expect(page.storage.getItem(KEY)).toBe("gina"); expect(page.location.href).toBe(APP + "?ng-id=gina"); }); test("settled from STORAGE: the address bar gains the identifier — the silent case", async () => { // The one that bites. Storage does not cross the frontier: the top-level page and the // broker iframe read different localStorage partitions. A returning user is recognised // from storage and the URL stays bare, so unless the parameter is written HERE the // round-trip leaves without it and the user lands in a second, empty virtual space. // // Top-level, being recognised is not the same as being let through: the barrier shows, // with the field already filled, and the identifier reaches the address bar when the // person confirms it. configured(); const page = inBrowser({ url: APP, storage: fakeStorage({ [KEY]: "dana" }) }); const settled = ensureIdentity(); expect(page.dom.prefilled).toBe("dana"); page.dom.confirm(); expect(await settled).toBe("dana"); expect(page.location.href).toBe(APP + "?ng-id=dana"); }); test("settled from the URL: the parameter is rewritten NORMALIZED, not as the link spelled it", async () => { // `@Erin` and `erin` must be one virtual space, and it is the address bar that survives // the round-trip — so it is the normalized form that has to be in it, not the raw one. // The field shows the normalized form too: it is the identifier, not the spelling of the // link, that names the space they are about to enter. configured(); const page = inBrowser({ url: APP + "?ng-id=@Erin" }); const settled = ensureIdentity(); expect(page.dom.prefilled).toBe("erin"); page.dom.confirm(); expect(await settled).toBe("erin"); expect(page.location.href).toBe(APP + "?ng-id=erin"); }); test("settled by the CALLER: an identity set before the call still reaches the address bar", async () => { configured(); const page = inBrowser({ url: APP }); setCurrentUser("alice"); expect(await ensureIdentity()).toBe("alice"); expect(page.location.href).toBe(APP + "?ng-id=alice"); }); // ── Which side of the frontier decides whether the barrier shows ─────────── // // Not whether the identity is known. A top-level page is one redirect away from the // broker, and someone whose browser has lost the wallet — or never had it, the identifier // having reached them by link — lands there on a static dead end with no way back but the // browser's own Back button. The barrier is the only screen that hands the wallet out, so // it shows on every top-level load and stands aside only past the round-trip, inside the // iframe. Keying it on identity presence is what made it skip itself, silently, for // exactly the people who needed it. test("TOP-LEVEL, a known identifier fills the field — it does not skip the barrier", async () => { // The regression this pair exists for. `dana` is known from storage, and the barrier // shows anyway, prefilled: one click, no typing, and the wallet still on offer above it. configured(); const page = inBrowser({ url: APP, storage: fakeStorage({ [KEY]: "dana" }) }); const settled = ensureIdentity(); expect(page.dom.shown).toBe(true); expect(page.dom.prefilled).toBe("dana"); page.dom.confirm(); expect(await settled).toBe("dana"); }); test("INSIDE THE IFRAME, the same known identifier stands the barrier down", async () => { // The other half, and what makes the one above an assertion rather than a tautology: // same identity, same storage, a DOM perfectly able to show a barrier — and no barrier, // because the round-trip is behind us. A returning user asked to confirm again on the // far side would be answering the same question twice for nothing. configured(); const page = inBrowser({ url: APP + "?ng-id=dana", storage: fakeStorage({ [KEY]: "dana" }), side: "in the broker iframe", }); expect(await ensureIdentity()).toBe("dana"); expect(page.dom.shown).toBe(false); }); test("inside the iframe, a page that knows NOBODY still asks — the safe failure", async () => { // The last resort, and it must stay: an iframe reached without the parameter has no // identity to act as, and continuing would provision an anonymous virtual space that // looks like a working application. Asking is the honest outcome. configured(); const page = inBrowser({ url: APP, side: "in the broker iframe" }); const settled = ensureIdentity(); expect(page.dom.shown).toBe(true); page.dom.submit("Hana"); expect(await settled).toBe("hana"); }); // ── Coming BACK from a hand-over that dead-ended ────────────────────────── // // The broker sends a walletless visitor to a static page with no return path. Back is // their only way out, and it restores this document from the browser's cache exactly as // it left — barrier frozen, button dead, `init()` already delegated and never redirecting // again. Without a reset there is nothing on that page left to press. // // The reset used to be `location.reload()`, and it was this package charging its own // scaffolding to the application: a reload destroys whatever the application held in // memory, and the application cannot opt out of it, observe it, or clean up first. So the // barrier — which is entirely ours — is what comes back, and the page is left alone. // // Every test below therefore goes through the polyfill's `init()`: the hand-over IS that // delegation, so a page that never delegated has nothing to come back from, and a fixture // that played `pageshow` at it would be replaying a state no browser produces. /** * A page wired the way an application wires one, with the injected `init` recording what * the address bar said each time it was handed over — which is what the broker would carry. * * It records rather than navigates: a delegation that really navigated would end the test * (and the document), and what is being judged here is that the SECOND one happens at all. */ function handingOverFrom(opts: { url: string; storage?: ReturnType; side?: Side }) { const handOvers: string[] = []; configured((..._args: unknown[]) => { handOvers.push(String((globalThis as { location?: { href: string } }).location?.href)); return Promise.resolve(); }); const page = inBrowser(opts); return { page, handOvers }; } test("a top-level page that has been handed over holds its barrier, frozen", async () => { const { page, handOvers } = handingOverFrom({ url: APP }); const delegated = init(() => {}, true, []); page.dom.submit("Gina"); await delegated; expect(handOvers.length).toBe(1); // Not removed: the hand-over is a navigation, and a bare page is what the person would // come back to. This is also the state the revival below un-sticks. expect(page.dom.shown).toBe(true); expect(page.dom.button).toEqual({ label: "Accès en cours…", disabled: true }); expect(page.armed).toBe(true); }); test("restored from the browser's cache, the BARRIER comes back to life — the page is not reloaded", async () => { const { page, handOvers } = handingOverFrom({ url: APP }); const delegated = init(() => {}, true, []); page.dom.submit("Gina"); await delegated; expect(handOvers.length).toBe(1); page.pageshow(true); // Nothing outside the barrier is touched: no reload, so whatever the application held in // memory is still there, and it never had to be told to hold nothing. expect(page.reloads).toBe(0); // And the barrier is usable again — the same screen, the field as they left it, the // button live and labelled to be pressed rather than frozen on what already failed. expect(page.dom.shown).toBe(true); expect(page.dom.prefilled).toBe("Gina"); expect(page.dom.button).toEqual({ label: "Entrer", disabled: false }); }); test("confirming the revived barrier hands the page over AGAIN — which is the way out", async () => { // The point of reviving it. A live button that led nowhere would be the same dead end // with a friendlier face: what the person needs is the hand-over to happen again, and // `init()` cannot do it — it delegated once and its promise is long answered. const { page, handOvers } = handingOverFrom({ url: APP }); const delegated = init(() => {}, true, []); page.dom.submit("Gina"); await delegated; page.pageshow(true); page.dom.confirm(); expect(handOvers).toEqual([APP + "?ng-id=gina", APP + "?ng-id=gina"]); expect(page.reloads).toBe(0); // Handing over again freezes it again — a second round-trip is in flight, and the same // listener is still there for a second return. expect(page.dom.button).toEqual({ label: "Accès en cours…", disabled: true }); }); test("someone who comes back to change their identifier leaves with the NEW one", async () => { // Not a hypothetical: the dead end they came back from is exactly where a person // discovers they entered the wrong identifier. So the second confirmation is a full // settling — normalized, in the address bar, and the identity the page now acts as — // and not a replay of the value that left. const { page, handOvers } = handingOverFrom({ url: APP }); const delegated = init(() => {}, true, []); page.dom.submit("Gina"); await delegated; page.pageshow(true); page.dom.submit("Hank"); expect(page.location.href).toBe(APP + "?ng-id=hank"); expect(page.storage.getItem(KEY)).toBe("hank"); expect(getCurrentUser()).toBe("hank"); expect(handOvers[1]).toBe(APP + "?ng-id=hank"); expect(page.reloads).toBe(0); }); test("a barrier handing over ignores a second Enter — one listener, one hand-over", async () => { // The button is dead by then, and a dead button dispatches no click — but the field // still takes the key. Twice acted on would be twice handed over, and twice armed would // leave a listener behind on every round-trip. const { page, handOvers } = handingOverFrom({ url: APP }); const delegated = init(() => {}, true, []); page.dom.submit("Gina"); await delegated; page.dom.pressEnter(); expect(handOvers.length).toBe(1); expect(page.armings).toBe(1); }); test("an ordinary load is not a return — the barrier is left frozen", async () => { // `pageshow` fires on every load, cached or not. Reviving on the plain one would arm the // button of a page whose hand-over is still on its way out, and offer a second one. const { page, handOvers } = handingOverFrom({ url: APP }); const delegated = init(() => {}, true, []); page.dom.submit("Gina"); await delegated; page.pageshow(false); expect(page.reloads).toBe(0); expect(page.dom.button).toEqual({ label: "Accès en cours…", disabled: true }); page.dom.confirm(); expect(handOvers.length).toBe(1); }); test("someone still AT the barrier is not reset — what they typed survives", async () => { // Armed on the way OUT, not at load: a person who wandered off before confirming comes // back to the page they left, with the field as they left it and the button still live. // So the arming has to straddle the confirmation, and this pins both sides of it. const { page } = handingOverFrom({ url: APP }); const delegated = init(() => {}, true, []); expect(page.armed).toBe(false); // A barrier left unanswered would hold the settling in flight and every later test would // JOIN it instead of raising its own — so answer it, always. page.dom.submit("Gina"); await delegated; expect(page.armed).toBe(true); }); test("inside the iframe nothing is armed — there was no hand-over to come back from", async () => { const { page } = handingOverFrom({ url: APP, side: "in the broker iframe" }); const delegated = init(() => {}, true, []); page.dom.submit("Hana"); await delegated; // And the barrier IS removed here: the application renders behind it. expect(page.dom.shown).toBe(false); expect(page.armed).toBe(false); }); test("a storage that refuses writes does not cost the round-trip its parameter", async () => { // Private mode, quota, a sandboxed document: `setItem` throws. Storage is only // same-partition convenience — the address bar is what crosses the frontier — so the two // writes must fail independently. Sharing one `try` would let the lesser failure take // the greater one down, silently. configured(); const refuses = { getItem: () => null, setItem: (): never => { throw new Error("QuotaExceededError: the storage is full or write-protected"); }, removeItem: (): void => {}, }; const page = inBrowser({ url: APP }); Object.assign(globalThis, { localStorage: refuses }); setCurrentUser("alice"); expect(await ensureIdentity()).toBe("alice"); expect(page.location.href).toBe(APP + "?ng-id=alice"); }); test("an address bar that cannot be rewritten fails safely — the gate asks again", async () => { // A sandboxed document without `allow-same-origin` throws on `replaceState`. Nothing // here can substitute for it: the URL `init()` hands over is the browser's, not ours. So // signing in must still succeed locally, and the identity must still be remembered — // the round-trip loses it and the gate asks again, which is the safe failure. configured(); const page = inBrowser({ url: APP, storage: fakeStorage({ [KEY]: "dana" }) }); Object.assign(globalThis, { history: { replaceState: (): never => { throw new Error("SecurityError: the document is sandboxed and lacks allow-same-origin"); }, }, }); const settled = ensureIdentity(); page.dom.confirm(); expect(await settled).toBe("dana"); expect(page.storage.getItem(KEY)).toBe("dana"); expect(page.location.href).toBe(APP); });