From fc3c129bd3c6adf4b5b7312e5d5d60070e95af50 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Tue, 11 Aug 2026 11:59:57 +0200 Subject: [PATCH] fix: l'identifiant est dans l'URL avant qu'init() ne l'emporte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @ng-org/web fait déjà la redirection vers le broker — même hôte, même forme, même test de cadre (dist/ngweb.js). Le polyfill n'a donc rien à réimplémenter là : ce qui lui revient, c'est la seule chose qu'init() ne peut pas faire, à savoir mettre ?ng-id= dans window.location.href avant qu'il ne le lise. rememberIdentity() s'exécute désormais sur les trois chemins — identité posée par l'appelant, venue du stockage, ou saisie à la barrière. Le cas du stockage était le silencieux : le paramètre restait absent, l'iframe lisait une identité vide et provisionnait un second espace virtuel, sans erreur. L'écriture dans le stockage et celle dans la barre d'adresse sont deux try indépendants : un stockage qui refuse d'écrire ne doit pas emporter avec lui le paramètre, qui est le seul à franchir la frontière de partition. Le contrat retire l'obligation « être ouverte via la redirection du broker » : elle n'a jamais été celle de l'application. Ni broker, ni iframe, ni redirection n'y sont plus nommés. --- .../app-contract/contract_polyfill-surface.md | 1 - .../polyfill/src/shared-wallet/access-gate.ts | 51 ++++- packages/polyfill/test/access-gate.test.ts | 194 +++++++++++++++++- 3 files changed, 238 insertions(+), 8 deletions(-) diff --git a/.project/concepts/app-contract/contract_polyfill-surface.md b/.project/concepts/app-contract/contract_polyfill-surface.md index 5dbe552..28729f0 100644 --- a/.project/concepts/app-contract/contract_polyfill-surface.md +++ b/.project/concepts/app-contract/contract_polyfill-surface.md @@ -16,7 +16,6 @@ This package covers placement (creating and listing an application's documents b An application using this package must: - serve a wallet file (`.ngw`) from its own bundle, and pass its URL and password to `configure` as `sharedWallet: { fileUrl, password }`; -- be opened through the broker redirect, `https://nextgraph.net/redir/#/?o=` — outside it there is no session; - call `ensureIdentity()` in a browser context before rendering its interface; it mounts a barrier in the document. ## Surface diff --git a/packages/polyfill/src/shared-wallet/access-gate.ts b/packages/polyfill/src/shared-wallet/access-gate.ts index a3956e5..fdb1601 100644 --- a/packages/polyfill/src/shared-wallet/access-gate.ts +++ b/packages/polyfill/src/shared-wallet/access-gate.ts @@ -37,6 +37,21 @@ * * Getting this wrong does not fail loudly: the iframe reads an empty identity, provisions * a second virtual user, and the returning user silently lands in an empty space. + * + * ── The hand-over to the broker is `init()`'s, and stays there ──────────── + * The round-trip is a redirect to NextGraph's login, which opens the wallet and loads the + * application back INSIDE an iframe it hosts. **`ng.init()` already performs it** — it + * compares `window.self` to `window.top` and, when top-level, navigates to its own + * redirect URL built from `window.location.href` (`@ng-org/web`, `dist/ngweb.js`). The + * level below covers the need, so this module implements NOTHING of it: doubling it would + * be code to delete at migration that diverges in the meantime (it did, briefly — a + * `sessionStorage` loop guard here made the hand-over not happen where upstream's does). + * + * What upstream cannot do is the ORDER, because it does not know this parameter exists: + * it hands over `window.location.href` AS IT FINDS IT. So the one thing this module owes + * the round-trip is that `?ng-id=` is already in the address bar when `init()` reads it — + * on every path, whatever settled the identity. Hence {@link rememberIdentity} on all + * three, and hence `ensureIdentity()` before `init()` in an application's bootstrap. */ import { @@ -113,16 +128,35 @@ function storedIdentity(): string | null { } } -/** Put the identifier where the round-trip can find it, then remember it locally. */ +/** + * Put the identifier where the round-trip can find it, and remember it locally. + * + * Called on EVERY path that settles an identity — typed at the gate, read from storage, + * or already set by the caller — and that is the whole point. `init()` hands the broker + * `window.location.href` as it finds it, so a path that settles an identity without + * writing the param sends the round-trip off without it: the iframe reads no identity, + * provisions a second virtual user, and the returning user lands in an empty space with + * no error anywhere. The identity coming from storage is the case that bites, since + * storage is precisely what does NOT cross the partition. + * + * Two independent effects, deliberately not one `try`: the address bar is what crosses + * the frontier and storage is only same-partition convenience, so a storage that refuses + * writes (private mode, quota) must not cost the round-trip its parameter. + */ function rememberIdentity(id: string): void { try { globalThis.localStorage?.setItem(STORAGE_KEY, id); + } catch { + // Same-partition convenience only: a reload will ask again, the round-trip still works. + } + try { const url = new URL(globalThis.location!.href); url.searchParams.set(URL_PARAM, id); globalThis.history?.replaceState(null, "", url.toString()); } catch { - // Nothing to do: without the param the round-trip loses the identity and the gate - // will ask again, which is the safe failure. + // No location, or a document forbidden to rewrite its URL (sandboxed): the round-trip + // loses the identity and the gate asks again, which is the safe failure. Nothing here + // can substitute for it — the URL `init()` hands over is the browser's, not ours. } } @@ -222,6 +256,13 @@ function askForIdentity(cfg: SharedWalletConfig): Promise { * 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. * + * **Call it before `init()`.** Whatever settled the identity — typed, stored, or set by + * the caller — this leaves `?ng-id=` in the address bar, and `init()` hands the broker the + * address bar as it finds it. The other order signs the user in and then sends the + * round-trip off without the identifier, which fails silently (see + * {@link rememberIdentity}). It cannot be enforced from inside `init()`: this call awaits + * the connection work, which awaits the session, which `init()` is what establishes. + * * **It RETURNS the identity it settled**, and that is not a convenience — it is the only * way an application can know who it is. Upstream the question does not arise: an app * passes `user_id` to `session_start(wallet_name, user_id)` (`@ng-org/web`), having got it @@ -233,12 +274,16 @@ function askForIdentity(cfg: SharedWalletConfig): Promise { export async function ensureIdentity(): Promise { const already = getCurrentUser(); if (already !== null) { + rememberIdentity(already); await connected(); 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. + rememberIdentity(known); setCurrentUser(known); await connected(); return known; diff --git a/packages/polyfill/test/access-gate.test.ts b/packages/polyfill/test/access-gate.test.ts index 31a013b..ea93a70 100644 --- a/packages/polyfill/test/access-gate.test.ts +++ b/packages/polyfill/test/access-gate.test.ts @@ -27,18 +27,21 @@ function fakeStorage(initial: Record = {}) { /** Put the page in a given URL + storage state, as the browser would. */ function inPage(search: string, storage: ReturnType) { - (globalThis as any).location = { search, href: "https://app.example" + search }; + const location = { search, href: "https://app.example" + search }; + (globalThis as any).location = location; (globalThis as any).localStorage = storage; (globalThis as any).history = { replaceState: () => {} }; + 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; + afterEach(() => { setCurrentUser(null); resetConfig(); resetStoreRegistry(); - delete (globalThis as any).location; - delete (globalThis as any).localStorage; - delete (globalThis as any).history; + for (const name of PAGE_GLOBALS) Reflect.deleteProperty(globalThis, name); }); function configured() { @@ -118,3 +121,186 @@ test("a stored value is normalized too — an old entry cannot key onto a second 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, + 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({}); + }, + }; +} + +/** + * 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 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"); + }, + }; +} + +/** + * 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 }) { + const storage = opts.storage ?? fakeStorage(); + const dom = fakeDom(); + + let href = opts.url; + const location = { + get href(): string { return href; }, + set href(next: string) { href = next; }, + get search(): string { return new URL(href).search; }, + }; + const history = { + replaceState: (_state: unknown, _title: string, next: string): void => void (href = next), + }; + Object.assign(globalThis, { + location, + history, + localStorage: storage, + document: dom.document, + }); + return { location, storage, dom }; +} + +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); + 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. + configured(); + const page = inBrowser({ url: APP, storage: fakeStorage({ [KEY]: "dana" }) }); + expect(await ensureIdentity()).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. + configured(); + const page = inBrowser({ url: APP + "?ng-id=@Erin" }); + expect(await ensureIdentity()).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"); +}); + +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"); + }, + }, + }); + expect(await ensureIdentity()).toBe("dana"); + expect(page.storage.getItem(KEY)).toBe("dana"); + expect(page.location.href).toBe(APP); +});