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
+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 => {};