fix: la barrière s'affiche à chaque chargement en page de tête, comme chez Festipod

Notre version décidait d'afficher la barrière sur la présence de l'identifiant.
Festipod décidait sur la session — jamais établie en page de tête, donc l'écran
s'affichait toujours, l'identifiant servant seulement à pré-remplir le champ.

La différence n'est pas ergonomique. L'identifiant est un état qu'on observe ;
le portefeuille, lui, vit dans le stockage d'une autre origine et nous est
illisible. Un écran conditionnel doit donc DEVINER cet état invisible — et
quand il devine « déjà installé » alors que le portefeuille a disparu du
navigateur, il cache les seuls contrôles qui répareraient la situation et
précipite la personne dans une impasse.

Impasse observée sur le site réel : sans portefeuille, la page du broker affiche
un texte statique, zéro bouton, un seul lien vers nextgraph.eu qui NE TRANSPORTE
AUCUN retour vers l'application. Le retour arrière du navigateur est la seule
issue — et il ne sert à rien si la barrière ne reprend pas la personne à
l'arrivée.

Le discriminant devient le cadre, pas l'identifiant : page de tête → toujours,
iframe → on s'efface. C'est le signal que @ng-org/web utilise lui-même et que
Festipod utilisait un étage plus bas.

On ne détecte rien et on ne demande rien. Les trois étapes s'affichent toujours ;
qui possède déjà son portefeuille ignore les deux premières. Aucune case « je
l'ai déjà » : savoir si l'on a importé un portefeuille dans ce navigateur est
une question trop technique pour être posée.

Garde-fou repris de Festipod, qu'on n'avait pas : après un aller-retour vers
l'onglet NextGraph et un retour arrière, la barrière restait figée sur un bouton
mort. Elle recharge désormais sur pageshow persisted — seul moyen de rejouer le
init() qui porte la redirection.

Vérifié sur navigateur : cliquer « Import a Wallet File » ouvre un sélecteur sur
place, sans navigation ni changement d'onglet, et notre onglet ne reçoit AUCUN
signal quand l'import réussit. Détecter le retour est donc impossible, pas
seulement fragile.
This commit is contained in:
Sylvain Duchesne
2026-08-12 14:59:10 +02:00
parent c5b4703687
commit f5a3adc385
9 changed files with 621 additions and 65 deletions
+164 -7
View File
@@ -69,6 +69,11 @@ const SUITE_DEADLINE_MS = 15 * 60 * 1000;
/**
* One journey. The longest (the newcomer's) does a wallet download, an import into a cold
* profile, a broker round-trip and a first render — measured under 2 min, bounded at 6.
* The returning visitor's does that twice over and measures 36s, so it fits the same bound
* with room to spare; a bound of its own was tried and dropped, because on a healthy host
* nothing justified it and an 11-minute journey can outlast the SUITE's own deadline —
* which prints no summary at all. A journey that overruns THIS is a hang or a sick host,
* and both are worth hearing about rather than absorbing.
*/
const JOURNEY_MS = 6 * 60 * 1000;
/** Signing an actor in: broker redirect, unlock, iframe, first render. Measured ~5-10s. */
@@ -89,10 +94,10 @@ function check(name: string, ok: boolean, detail?: string): void {
* right for a journey that fails, but a journey that never RETURNS is caught by nothing —
* and that is what three killed runs looked like from the outside.
*/
async function journey(name: string, fn: () => Promise<void>): Promise<void> {
async function journey(name: string, fn: () => Promise<void>, boundMs = JOURNEY_MS): Promise<void> {
console.log(`\n── ${name} ──`);
try {
await within(`the journey "${name}"`, JOURNEY_MS, fn);
await within(`the journey "${name}"`, boundMs, fn);
} catch (e: any) {
check(name, false, "threw: " + String(e?.message ?? e));
}
@@ -179,8 +184,14 @@ async function signIn(ctx: BrowserContext, appUrl: string, id: string): Promise<
if (m.type() === "error") console.error(`[${id} console]`, m.text());
});
// `?ng-id=` is the ONE channel that survives the broker round-trip (the access gate's
// resolution order, `shared-wallet/access-gate.ts`), so a returning user never sees
// the barrier. Here it is also how the suite signs an actor in without typing.
// resolution order, `shared-wallet/access-gate.ts`). Here it is also how the suite
// signs an actor in without typing.
//
// No barrier is met on this path, and the reason is the FRAME, not the identifier:
// `setupBrokerPage` goes straight to the broker's redirect, so the application only
// ever loads inside the iframe — where the round-trip is already behind it. The two
// journeys that load the application's own address top-level do meet the barrier, and
// must: that is the side a person actually arrives on.
const frame = await setupBrokerPage(page, `${appUrl}/?ng-id=${encodeURIComponent(id)}`);
await frame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 60000 });
return { id, frame, page };
@@ -411,9 +422,10 @@ async function main(): Promise<void> {
});
// 5. The path no journey walked: somebody who holds NOTHING. No wallet in the
// profile, no identifier anywhere, and the application's own address — not the one
// `signIn()` builds, which appends `?ng-id=` and so makes the barrier resolve from
// the URL and never appear. Two defects shipped green behind that shortcut: the
// profile, no identifier anywhere, and the application's own address — not the
// broker redirect `signIn()` goes through, which loads the application already
// inside the iframe and so never meets the barrier. Two defects shipped green
// behind that shortcut: the
// application handed the page to the broker BEFORE the barrier could show (a
// first-time user landed on a login with no wallet and no way to get one), and the
// file the barrier offers was served by nobody, so its link pointed at a 404.
@@ -528,6 +540,151 @@ async function main(): Promise<void> {
try { fs.rmSync(fresh.dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
// 6. The visit AFTER the first one, on the application's own address. The barrier used
// to skip itself here — it asked only when nobody was known — and skipping is
// silent: the page goes straight to the broker, and someone whose browser no longer
// holds the wallet lands on a static dead end with no return path. The wallet is
// handed out at the barrier and nowhere else, so the barrier has to be there.
//
// Nothing is seeded. The identifier this journey expects to find prefilled is the
// one it typed itself, one visit earlier, at the real barrier, on a device that
// started with nothing — the only way a person obtains it, and the only way this
// journey may (`rule_never-shortcut-the-sign-in`). The wallet is not planted either:
// it comes off the barrier's own link and through a real import, as it does above.
//
// Its OWN browser profile, for the same reason the newcomer's journey has one: a
// device that starts with nothing is the only one on which the wallet can be
// OBTAINED rather than found already there. It also keeps this journey off the
// actors' profile, which no journey should be adding broker pages to.
await journey("a returning visitor meets the barrier again, prefilled, and keeps their space", async () => {
const returning = `returning-${t}`;
const downloaded = path.join(tmpDir, "downloaded-by-the-returning-visitor.ngw");
const fresh = await launchCleanProfileContext();
let first: Page | null = null;
let again: Page | null = null;
const startedAtJourney = Date.now();
/**
* Progress, not assertion. This journey is the longest in the suite, and when it
* overran its bound it had reported NOTHING — so there was no way to tell a slow
* broker from a genuine hang, or to know which step to look at.
*/
const at = (what: string): void =>
console.log(` · ${what} (+${((Date.now() - startedAtJourney) / 1000).toFixed(0)}s)`);
/** Open the application's OWN address, top-level, and wait for the barrier. */
const arriveAtTheBarrier = async (label: string): Promise<Page> => {
const p = await fresh.ctx.newPage();
p.on("pageerror", (e) => console.error(`[${label} pageerror]`, e.message));
p.on("console", (m) => {
if (m.type() === "error") console.error(`[${label} console]`, m.text());
});
await p.goto(url, { waitUntil: "domcontentloaded" });
await p.locator('[data-testid="ng-identity-input"]').waitFor({ state: "visible", timeout: 30000 });
return p;
};
try {
// The FIRST visit — how the identifier and the wallet come to exist at all on this
// device. Both are obtained here, neither is handed over by the test.
first = await arriveAtTheBarrier("returning-first-visit");
at("first visit: the barrier is up");
const gate = first.locator('[data-ng-eventually="access-gate"]');
const [download] = await Promise.all([
first.waitForEvent("download", { timeout: 30000 }),
gate.locator("a[download]").click(),
]);
await download.saveAs(downloaded);
at("first visit: the wallet is downloaded");
const password = ((await gate.locator("code").first().textContent()) ?? "").trim();
const [walletPage] = await Promise.all([
fresh.ctx.waitForEvent("page", { timeout: 30000 }),
gate.locator('a[target="_blank"]').click(),
]);
await importWalletViaFile(walletPage, downloaded, password);
await walletPage.close().catch(() => {});
at("first visit: the wallet is imported on this device");
await first.locator('[data-testid="ng-identity-input"]').fill(returning);
await first.locator('[data-testid="ng-identity-enter"]').click();
// The APPLICATION navigates, and it has to have DONE so before the broker login is
// driven: until then the top-level frame is still the application's own, and
// `completeBrokerLogin` would hand back that frame — which then navigates away, so
// everything waited for on it waits forever. Cost two runs to see.
await first.waitForURL(/nextgraph\./, { timeout: 60000 }).catch(() => {});
at("first visit: handed over to the broker");
const firstFrame = await completeBrokerLogin(first, url);
await firstFrame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 180000 });
// A note, so the second visit can be shown to land in the SAME space rather than
// merely displaying the same name.
at("first visit: the application is up");
await writeNote({ id: returning, frame: firstFrame, page: first }, "protected", "Carnet", "de la première visite");
at("first visit: a note is written");
// Closed here, not merely at the end: the second visit has to be a fresh page that
// finds the identifier where the FIRST one left it, not a tab still holding it —
// and one broker page at a time in this profile.
await closeQuietly("the first visit's page", () => first!.close());
first = null;
// The SECOND visit — the same address a bookmark would open, nothing appended. The
// device now holds the wallet, and the barrier still neither knows nor asks: the
// steps are offered again and this person walks past them to the field. Whether a
// wallet was imported lives in another origin's storage and is unreadable, so the
// alternative would be asking them — the question this design refuses.
again = await arriveAtTheBarrier("returning-second-visit");
at("return visit: the barrier is up again");
check(
"the barrier still hands out the wallet without asking whether they have it",
(await again.locator('[data-ng-eventually="access-gate"]').locator("a[download]").count()) === 1,
);
check(
"the barrier appears again, on a visit where the identifier is already known",
again.url().startsWith(url),
again.url(),
);
const prefilled = await again.locator('[data-testid="ng-identity-input"]').inputValue();
check(
"and it arrives prefilled — one click, nothing to retype",
prefilled === returning,
`field=${prefilled || "(vide)"}`,
);
// Confirmed, not retyped: what settles the identity here is the value the barrier
// itself put in the field.
await again.locator('[data-testid="ng-identity-enter"]').click();
await again.waitForURL(/nextgraph\./, { timeout: 60000 }).catch(() => {});
check(
"confirming the prefilled field is what hands the page over",
/nextgraph\./.test(again.url()) && again.url().includes(`ng-id%3D${returning}`),
again.url(),
);
const backFrame = await completeBrokerLogin(again, url);
at("return visit: back inside the broker iframe");
await backFrame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 180000 });
const who = ((await backFrame.locator('[data-testid="who"]').textContent()) ?? "").trim();
check("the round-trip brings them back as the same identity", who.includes(returning), who);
// The identity is one space, not two — the failure that skipping the barrier used
// to hide was precisely a SECOND virtual space that looked like a working
// application. A note written before the round-trip is what tells them apart.
const arrived: Actor = { id: returning, frame: backFrame, page: again };
await showScope(arrived, "protected", "Carnet");
// `showScope` settles on an EMPTY list too, and a page this fresh can render one
// before its repos have synchronised — so the marker gets its own bounded wait.
// Not swallowed: if it never arrives, the check below reads the list and fails on
// what is actually there.
await backFrame.locator('li:has-text("Carnet")').waitFor({ timeout: 60000 }).catch(() => {});
const list = (await backFrame.locator('[data-testid="notes"]').textContent()) ?? "";
check(
"and into the same space — the note from the first visit is still theirs",
list.includes("Carnet") && list.includes("de la première visite"),
list.replace(/\s+/g, " ").slice(0, 70),
);
} finally {
if (first) await closeQuietly("the first visit's page", () => first!.close());
if (again) await closeQuietly("the return visit's page", () => again!.close());
await closeContext("returning-visitor", fresh.ctx);
try { fs.rmSync(fresh.dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
} finally {
// Bounded, and it has to be: `BrowserContext.close()` on a browser that has already
// gone never resolves, and this `finally` is where that hang swallowed the summary.
+10 -5
View File
@@ -199,9 +199,14 @@ const identity = new IdentityStore(
*
* 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 — the one that matters most — that it does NOT
* appear when the identity is already known, since a returning user seeing the barrier
* again is the visible face of the silent bug (a second virtual space).
* 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);
@@ -237,8 +242,8 @@ const identity = new IdentityStore(
};
},
/** The barrier must stay away once an identity is known. */
async accessGateReturningVisit(known: string) {
/** Inside the iframe, the barrier must stay away once an identity is known. */
async accessGateIdentityAlreadyKnown(known: string) {
setCurrentUser(known);
await ensureIdentity();
return {
+7 -3
View File
@@ -203,9 +203,13 @@ async function main(): Promise<void> {
`shown=${r.shown} disabledWhenEmpty=${r.disabledWhenEmpty} identity=${r.identity} stillMounted=${r.stillMounted}`,
);
});
await step("the gate stays away when the identity is already known", async () => {
const r = await sdk<any>(frame, "accessGateReturningVisit", "erin");
check("no barrier for a returning user", r.shown === false && r.identity === "erin", `shown=${r.shown}`);
// This harness page runs INSIDE the broker iframe — which is the whole of what makes
// the check below true. Top-level the barrier shows on every load, known identity or
// not, because a top-level page is one redirect away from a dead end for anyone whose
// browser has no wallet. The applicative suite walks that side (`e2e/notebook.ts`).
await step("past the round-trip, a known identity stands the barrier down", async () => {
const r = await sdk<any>(frame, "accessGateIdentityAlreadyKnown", "erin");
check("no barrier inside the broker iframe once the identity is known", r.shown === false && r.identity === "erin", `shown=${r.shown}`);
});
// ── docs primitives ─────────────────────────────────────────────────────
@@ -53,6 +53,30 @@
* on every path, whatever settled the identity. Hence {@link rememberIdentity} on all
* three, and hence {@link settleIdentity}, which the polyfill's own `init()` awaits before
* it delegates (`surface/lifecycle.ts`) — so no application has to know that order.
*
* ── The barrier belongs to the TOP-LEVEL page, on every load ──────────────
* That same frontier decides WHEN the barrier shows, and the rule is not "when we do not
* know who this is". It is {@link insideBroker}:
*
* - **top-level** — the page has no session and is on its way to the hand-over. The
* barrier ALWAYS shows, prefilled with whatever identifier is already known. It is the
* last screen anyone sees before the redirect, and the only screen a person who holds
* no wallet will ever be offered: the broker sends a walletless visitor to a static
* dead-end with no return path, so a barrier that skipped itself would strand them.
* - **inside the broker iframe** — the round-trip has happened, the URL carries the
* identifier, a session is coming. Resolve and stand aside.
*
* Keying on identity PRESENCE instead is what this replaced, and it failed silently in the
* direction that matters: a returning visitor was recognised from storage, the barrier
* never showed, and someone who had cleared the wallet from this browser — or never
* imported it, having arrived on a device the identifier had reached by URL — was handed
* straight to a dead end with no way back but the browser's own Back button.
*
* Nothing here detects whether the wallet is already imported, and nothing may: that lives
* in the wallet application's own origin, which this page cannot read. So the steps are
* always all shown, and someone who already holds the wallet simply walks past them. The
* alternative — asking the person whether they have imported a wallet in this browser — is
* a question about a technical act they have no way to answer reliably.
*/
import {
@@ -84,6 +108,27 @@ function normalizeIdentity(raw: string): string {
}
}
/**
* Is this document running INSIDE the broker's iframe, rather than top-level?
*
* The one signal that separates the two contexts this flow runs in, and it is deliberately
* the SAME one the level below uses: `@ng-org/web`'s `init()` compares `window.self` to
* `window.top` to decide whether to hand the page over. Asking the question the same way
* means the barrier shows exactly on the pages that are about to be redirected, and never
* on the one that came back — no second source of truth to drift from.
*
* A session would be the more obvious signal — it exists only inside the iframe — but this
* half of signing in runs BEFORE any session can exist ({@link settleIdentity}), so asking
* for one would deadlock. The frame check answers with nothing but the page.
*
* No `window` at all (server-side, a unit test) is not a frame: nothing there is going to
* be redirected, so the caller is treated as top-level and owns the identity itself.
*/
function insideBroker(): boolean {
if (typeof window === "undefined") return false;
return window.self !== window.top;
}
/** Where the gate stashes the identifier so a plain reload prefills it. */
const STORAGE_KEY = "ng-eventually:identity";
/** The URL parameter — the only channel that survives the broker round-trip. */
@@ -162,19 +207,47 @@ function rememberIdentity(id: string): void {
}
/**
* Show the gate and resolve with the identifier the user entered.
* Un-stick a top-level page that was handed to the broker and came BACK.
*
* No prefill parameter, deliberately: the gate is shown ONLY when no identity is known,
* so there is never a value to prefill. The consumer this was moved from did prefill,
* because its screen reappeared after the broker round-trip — here the URL carries the
* identity across that round-trip, so a returning user does not see the barrier at all.
* The need is met one level up rather than papered over in the form.
* The hand-over is a navigation, so the browser may keep this document alive in its
* back/forward cache. Someone who reaches the broker without a wallet lands on a static
* dead end with no return path — the Back button is their only way out — and what Back
* restores is this page EXACTLY as it left: the barrier frozen on "Accès en cours…", its
* button dead, and an `init()` that has already delegated and will never redirect again.
* Nothing on that page can be clicked back to life.
*
* A reload is the reset, and it is the whole of it: `init()` runs again, settles again,
* and puts the barrier back up — prefilled from the address bar the hand-over left behind
* — with its button live. Only a restore from the cache does this (`persisted`), and a
* reload is not one, so it cannot loop.
*
* Armed on the way OUT rather than at load, so it exists exactly on the pages that have
* been handed over: someone still typing at the barrier who wanders off and comes back
* keeps what they typed instead of having it reloaded away.
*/
function armReturnFromHandOver(): void {
if (typeof window === "undefined") return;
window.addEventListener("pageshow", (event: PageTransitionEvent) => {
if (!event.persisted) return;
globalThis.location?.reload();
});
}
/**
* Show the barrier and resolve with the identifier the person confirmed.
*
* `prefill` is what is already known about them — from the URL, from storage — and it goes
* in the field, never around the screen. The barrier still shows: a known identifier says
* nothing about whether this browser holds the wallet, and skipping the screen on the
* strength of it strands anyone who does not (see the header). So a returning visitor
* confirms a filled field, which is one click and no typing, and a first-time one gets the
* same screen with an empty field.
*
* Deliberately plain DOM: this is a technical barrier shown before an application
* renders, like a password prompt on a closed beta. Binding it to a UI framework would
* make every consumer adopt that framework for a screen that is going away.
*/
function askForIdentity(cfg: SharedWalletConfig): Promise<string> {
function askForIdentity(cfg: SharedWalletConfig, prefill: string): Promise<string> {
const importUrl = cfg.importUrl ?? DEFAULT_IMPORT_URL;
return new Promise((resolve) => {
const host = document.createElement("div");
@@ -229,11 +302,32 @@ function askForIdentity(cfg: SharedWalletConfig): Promise<string> {
const input = root.querySelector("input") as HTMLInputElement;
const go = root.querySelector("button.go") as HTMLButtonElement;
// Set on the live element rather than into the markup above: the value is a person's
// identifier and the markup is a template string, so interpolating it there would put
// whatever a link carried into the page as HTML.
input.value = prefill;
const sync = (): void => { go.disabled = input.value.trim().length === 0; };
// Answered once, and once only. A disabled button dispatches no click, but the field
// still takes an Enter key — so without this a second press would arm a second return
// listener and resolve a promise that has already been answered.
let entered = false;
const enter = (): void => {
const value = input.value.trim();
if (!value) return;
host.remove();
if (!value || entered) return;
entered = true;
if (insideBroker()) {
// The application renders behind the barrier: the session is already coming, and
// this screen has nothing left to say.
host.remove();
} else {
// Top-level, settling is followed by the hand-over — `init()` awaits this and then
// navigates. Leaving the page bare for that moment would show a blank application;
// worse, coming BACK to a bare page would leave nothing to press. So the barrier
// stays up and says what is happening, and the return path is armed.
go.disabled = true;
go.textContent = "Accès en cours…";
armReturnFromHandOver();
}
resolve(value);
};
input.addEventListener("input", sync);
@@ -311,21 +405,35 @@ export function settleIdentity(): Promise<PrincipalId> {
/**
* Resolve the identifier the three ways it can arrive — already set by the caller, read
* from the page (URL, then storage), or typed at the barrier — and remember it on every
* from the page (URL, then storage), or confirmed at the barrier — and remember it on every
* one of them ({@link rememberIdentity}), which is the path-independent part that the
* round-trip depends on.
*
* What a KNOWN identifier does depends on which side of the frontier is asking, and that is
* the whole shape of this function ({@link insideBroker}): inside the iframe it settles the
* question, top-level it only fills the field in. Reading it is never enough top-level,
* because it says nothing about whether this browser holds the wallet.
*/
async function resolveIdentity(): Promise<PrincipalId> {
const already = getCurrentUser();
if (already !== null) {
// The caller took charge of the identity itself — the escape hatch the "no shared
// wallet configured" error below names. It is not a person arriving at a page, so
// there is nobody to show a barrier to, and no hand-over to protect: honour it and
// just put it where the round-trip will find it. Nothing an application ships does
// this; the harnesses that drive the surface directly do.
rememberIdentity(already);
return already;
}
const known = storedIdentity();
if (known) {
// Even though `storedIdentity()` just read it: what it read may have come from THIS
// partition's storage, which the round-trip does not carry. The address bar does.
if (known !== null && insideBroker()) {
// Inside the iframe the round-trip has already happened: the identifier came across in
// the URL and a session is on its way. Stand aside.
//
// Remembered again even though `storedIdentity()` just read it: what it read may have
// come from THIS partition's storage, which the round-trip does not carry. The address
// bar does.
rememberIdentity(known);
adoptCurrentUser(known);
return known;
@@ -347,7 +455,7 @@ async function resolveIdentity(): Promise<PrincipalId> {
);
}
const chosen = await askForIdentity(cfg);
const chosen = await askForIdentity(cfg, known ?? "");
const normalized = normalizeIdentity(chosen);
rememberIdentity(normalized);
adoptCurrentUser(normalized);
@@ -362,8 +470,11 @@ async function resolveIdentity(): Promise<PrincipalId> {
* the day the wallet supplies the identity, this resolves without showing anything and
* the caller's code is unchanged.
*
* A returning user never sees the gate: the identifier survives the broker round-trip in
* the URL, and a plain reload finds it in storage.
* A returning user sees the barrier again, with their identifier already in the field —
* one click, no typing. It is not shown because they are unknown (they are not); it is
* shown because a top-level page is one redirect away from a dead end for anyone whose
* browser has lost the wallet, and this screen is the only place that hands it back out.
* Past the round-trip, inside the iframe, it never appears.
*
* **It no longer has to be called before `init()`** — the order is structural now. Whatever
* settles the identity leaves `?ng-id=` in the address bar, and `init()` hands the broker
+249 -22
View File
@@ -25,17 +25,43 @@ function fakeStorage(initial: Record<string, string> = {}) {
};
}
/** Put the page in a given URL + storage state, as the browser would. */
function inPage(search: string, storage: ReturnType<typeof fakeStorage>) {
/**
* 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<typeof fakeStorage>, side: Side) {
const location = { search, href: "https://app.example" + search };
(globalThis as any).location = location;
(globalThis as any).localStorage = storage;
(globalThis as any).history = { replaceState: () => {} };
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"] as const;
const PAGE_GLOBALS = ["location", "localStorage", "history", "document", "window"] as const;
afterEach(() => {
setCurrentUser(null);
@@ -57,8 +83,10 @@ function configured() {
}
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());
inPage("", fakeStorage(), "top-level");
setCurrentUser("alice");
await ensureIdentity();
expect(getCurrentUser()).toBe("alice");
@@ -70,7 +98,7 @@ test("the URL parameter WINS over storage — it is the only thing that crosses
// 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" }));
inPage("?ng-id=fromurl", fakeStorage({ [KEY]: "fromstorage" }), "in the broker iframe");
await ensureIdentity();
expect(getCurrentUser()).toBe("fromurl");
});
@@ -78,14 +106,16 @@ test("the URL parameter WINS over storage — it is the only thing that crosses
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);
inPage("?ng-id=carol", storage, "in the broker iframe");
await ensureIdentity();
expect(storage.getItem(KEY)).toBe("carol");
});
test("with no parameter, storage answers — a reload does not re-ask", async () => {
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" }));
inPage("", fakeStorage({ [KEY]: "dana" }), "in the broker iframe");
await ensureIdentity();
expect(getCurrentUser()).toBe("dana");
});
@@ -94,13 +124,13 @@ 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());
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());
inPage("", fakeStorage(), "top-level");
await expect(ensureIdentity()).rejects.toThrow(/no shared wallet configured/i);
});
@@ -110,14 +140,14 @@ test("the URL value is NORMALIZED on the way in — `@Erin` and `erin` are one s
// 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());
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" }));
inPage("", fakeStorage({ [KEY]: "@Frank" }), "in the broker iframe");
await ensureIdentity();
expect(getCurrentUser()).toBe("frank");
});
@@ -147,12 +177,13 @@ function fakeElement() {
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): void {
for (const fn of handlers.get(type) ?? []) fn({});
fire(type: string, event: unknown = {}): void {
for (const fn of handlers.get(type) ?? []) fn(event);
},
};
}
@@ -186,12 +217,28 @@ function fakeDom() {
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" });
},
};
}
@@ -203,26 +250,48 @@ function fakeDom() {
* `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<typeof fakeStorage> }) {
function inBrowser(opts: { url: string; storage?: ReturnType<typeof fakeStorage>; 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<string, ((e: unknown) => 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 };
return {
location,
storage,
dom,
/** How many times the page asked the browser to reload it. */
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; },
/**
* 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 () => {
@@ -231,6 +300,9 @@ test("settled at the GATE: the address bar carries the identifier `init()` will
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");
@@ -243,18 +315,30 @@ test("settled from STORAGE: the address bar gains the identifier — the silent
// 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" }) });
expect(await ensureIdentity()).toBe("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" });
expect(await ensureIdentity()).toBe("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");
});
@@ -266,6 +350,147 @@ test("settled by the CALLER: an identity set before the call still reaches the a
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.
test("a top-level page that has been handed over holds its barrier, frozen", async () => {
configured();
const page = inBrowser({ url: APP });
const settled = ensureIdentity();
page.dom.submit("Gina");
await settled;
// 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 reset 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, it reloads — the button comes back to life", async () => {
configured();
const page = inBrowser({ url: APP });
const settled = ensureIdentity();
page.dom.submit("Gina");
await settled;
page.pageshow(true);
// A reload re-runs `init()`, which settles again and puts the barrier back up prefilled
// from the address bar — the only reset available, since the redirect belongs to the
// level below and has already happened.
expect(page.reloads).toBe(1);
});
test("a barrier already answered ignores a second Enter — one return listener, one reload", async () => {
// The button is dead by then, and a dead button dispatches no click — but the field
// still takes the key. Twice armed would be twice reloaded on the way back.
configured();
const page = inBrowser({ url: APP });
const settled = ensureIdentity();
page.dom.submit("Gina");
await settled;
page.dom.pressEnter();
page.pageshow(true);
expect(page.reloads).toBe(1);
});
test("an ordinary load is not a return — it must not reload", async () => {
// `pageshow` fires on every load, cached or not. Reloading on the plain one would loop
// the page forever, which is a worse failure than the one being fixed.
configured();
const page = inBrowser({ url: APP });
const settled = ensureIdentity();
page.dom.submit("Gina");
await settled;
page.pageshow(false);
expect(page.reloads).toBe(0);
});
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, not to a reloaded one that lost their identifier. So the
// arming has to straddle the confirmation, and this pins both sides of it.
configured();
const page = inBrowser({ url: APP });
const settled = ensureIdentity();
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 settled;
expect(page.armed).toBe(true);
});
test("inside the iframe nothing is armed — there was no hand-over to come back from", async () => {
configured();
const page = inBrowser({ url: APP, side: "in the broker iframe" });
const settled = ensureIdentity();
page.dom.submit("Hana");
await settled;
// 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
@@ -300,7 +525,9 @@ test("an address bar that cannot be rewritten fails safely — the gate asks aga
},
},
});
expect(await ensureIdentity()).toBe("dana");
const settled = ensureIdentity();
page.dom.confirm();
expect(await settled).toBe("dana");
expect(page.storage.getItem(KEY)).toBe("dana");
expect(page.location.href).toBe(APP);
});
+34 -8
View File
@@ -33,22 +33,32 @@ function fakeStorage(initial: Record<string, string> = {}) {
};
}
/**
* Which side of the broker frontier the page is on. It decides whether the barrier shows
* at all — top-level it always does, past the round-trip it never does — so no page here
* may leave it unsaid: `window.self !== window.top` is what the gate reads, and a double
* without a `window` would silently be top-level.
*/
type Side = "top-level" | "in the broker iframe";
/**
* A browser page, faithful in the one way these assertions depend on: `location.search`
* tracks `href`, and `history.replaceState` MOVES `href` — as a real browser does, and as
* the real `init()` relies on when it reads the address bar. A double whose `replaceState`
* did nothing would let a broken `init()` pass.
*/
function inBrowser(url: string, storage: ReturnType<typeof fakeStorage>) {
function inBrowser(url: string, storage: ReturnType<typeof fakeStorage>, side: Side) {
let href = url;
const location = {
get href(): string { return href; },
get search(): string { return new URL(href).search; },
};
const self = {};
Object.assign(globalThis, {
location,
history: { replaceState: (_s: unknown, _t: string, next: string): void => void (href = next) },
localStorage: storage,
window: { self, top: side === "top-level" ? self : {}, addEventListener: (): void => {} },
});
return { location };
}
@@ -99,10 +109,14 @@ function fakeDom() {
input.fire("input");
go.fire("click");
},
/** What a returning user does: press Entrer on the field as they found it, prefilled. */
confirm(): void {
go.fire("click");
},
};
}
const PAGE_GLOBALS = ["location", "localStorage", "history", "document"] as const;
const PAGE_GLOBALS = ["location", "localStorage", "history", "document", "window"] as const;
afterEach(() => {
setCurrentUser(null);
@@ -170,11 +184,20 @@ test("the injected `init` finds the identifier ALREADY in the address bar", asyn
// wrapper that delegated first and settled after would satisfy any ordering check and
// still send the round-trip off bare. Identity from storage is the case that bites —
// storage does not cross the broker's partition, the URL does.
//
// Top-level, which is the only side that hands over at all, so Hana meets the barrier
// with her identifier already in the field and confirms it. What must be true when the
// delegate is finally called is unchanged: the address bar already carries her.
const wiring = consumerWiring();
configured(wiring);
inBrowser(APP, fakeStorage({ [KEY]: "hana" }));
inBrowser(APP, fakeStorage({ [KEY]: "hana" }), "top-level");
const dom = fakeDom();
Object.assign(globalThis, { document: dom.document });
await within(init(() => {}, true, []));
const delegated = init(() => {}, true, []);
await Promise.resolve();
dom.confirm();
await within(delegated);
expect(wiring.calls.length).toBe(1);
expect(wiring.calls[0]!.href).toBe(APP + "?ng-id=hana");
@@ -186,9 +209,12 @@ test("it resolves though the session exists only AFTER it delegates — the cycl
// `session()` → the consumer's thunk → a promise only `init()`'s callback resolves. Await
// that here and nothing ever runs: `init` is never called, so the session never arrives,
// so the wait never ends. `init()` therefore awaits the SESSION-FREE half only.
//
// Inside the iframe, which is where the cycle actually closes: the identifier arrived in
// the URL, the barrier stands aside, and delegating is what opens the session.
const wiring = consumerWiring();
configured(wiring);
inBrowser(APP + "?ng-id=iris", fakeStorage());
inBrowser(APP + "?ng-id=iris", fakeStorage(), "in the broker iframe");
await within(init(() => {}, true, []));
@@ -204,7 +230,7 @@ test("a settling failure rejects — the page is not handed to the broker withou
// is still visible.
const wiring = consumerWiring();
configured(wiring, { sharedWallet: false });
inBrowser(APP, fakeStorage());
inBrowser(APP, fakeStorage(), "top-level");
await expect(init(() => {}, true, [])).rejects.toThrow(/no shared wallet configured/i);
expect(wiring.calls.length).toBe(0);
@@ -218,7 +244,7 @@ test("`init` and `ensureIdentity` in the same tick raise ONE barrier, not two",
// it never hands the page to the broker — the application hangs before it ever loads.
const wiring = consumerWiring();
configured(wiring);
inBrowser(APP, fakeStorage());
inBrowser(APP, fakeStorage(), "top-level");
const dom = fakeDom();
Object.assign(globalThis, { document: dom.document });
@@ -239,7 +265,7 @@ test("arguments and return value pass through untouched — it is still a forwar
// would be a difference the application has to unlearn at migration.
const wiring = consumerWiring();
configured(wiring);
inBrowser(APP, fakeStorage());
inBrowser(APP, fakeStorage(), "top-level");
setCurrentUser("juno");
const callback = (): void => {};
@@ -40,8 +40,16 @@ function fakeStorage() {
};
}
/** A page whose address bar MOVES on `replaceState`, as the gate and `init()` both rely on. */
function inBrowser(url: string): void {
/**
* A page whose address bar MOVES on `replaceState`, as the gate and `init()` both rely on.
*
* INSIDE THE BROKER IFRAME (`window.self !== window.top`), and that is not decoration: it
* is the side of the frontier this whole file describes. The identifier has already crossed
* in the URL, so the barrier stands aside; and the session exists only here, opened by the
* callback `init()` is given. Top-level, `init()` navigates away and no session is ever
* established, so the deadlock pinned below could not even be reached.
*/
function inBrokerIframe(url: string): void {
let href = url;
Object.assign(globalThis, {
location: {
@@ -50,10 +58,11 @@ function inBrowser(url: string): void {
},
history: { replaceState: (_s: unknown, _t: string, next: string): void => void (href = next) },
localStorage: fakeStorage(),
window: { self: {}, top: {}, addEventListener: (): void => {} },
});
}
const PAGE_GLOBALS = ["location", "localStorage", "history", "document"] as const;
const PAGE_GLOBALS = ["location", "localStorage", "history", "document", "window"] as const;
afterEach(() => {
setCurrentUser(null);
@@ -108,7 +117,7 @@ function bootTheApplication(identifier: string) {
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
});
inBrowser(`${APP}?ng-id=${encodeURIComponent(identifier)}`);
inBrokerIframe(`${APP}?ng-id=${encodeURIComponent(identifier)}`);
let delegated!: Promise<unknown>;
sessionReady = new Promise<RegistrySession>((resolve) => {