fix: l'identifiant est dans l'URL avant qu'init() ne l'emporte

@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.
This commit is contained in:
Sylvain Duchesne
2026-08-11 11:59:57 +02:00
parent 9c487b59f3
commit fc3c129bd3
3 changed files with 238 additions and 8 deletions
@@ -16,7 +16,6 @@ This package covers placement (creating and listing an application's documents b
An application using this package must: 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 }`; - 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=<the app's url>` — outside it there is no session;
- call `ensureIdentity()` in a browser context before rendering its interface; it mounts a barrier in the document. - call `ensureIdentity()` in a browser context before rendering its interface; it mounts a barrier in the document.
## Surface ## Surface
@@ -37,6 +37,21 @@
* *
* Getting this wrong does not fail loudly: the iframe reads an empty identity, provisions * 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. * 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 { 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 { function rememberIdentity(id: string): void {
try { try {
globalThis.localStorage?.setItem(STORAGE_KEY, id); 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); const url = new URL(globalThis.location!.href);
url.searchParams.set(URL_PARAM, id); url.searchParams.set(URL_PARAM, id);
globalThis.history?.replaceState(null, "", url.toString()); globalThis.history?.replaceState(null, "", url.toString());
} catch { } catch {
// Nothing to do: without the param the round-trip loses the identity and the gate // No location, or a document forbidden to rewrite its URL (sandboxed): the round-trip
// will ask again, which is the safe failure. // 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<string> {
* A returning user never sees the gate: the identifier survives the broker round-trip in * 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. * 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 * **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 * 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 * 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<string> {
export async function ensureIdentity(): Promise<PrincipalId> { export async function ensureIdentity(): Promise<PrincipalId> {
const already = getCurrentUser(); const already = getCurrentUser();
if (already !== null) { if (already !== null) {
rememberIdentity(already);
await connected(); await connected();
return already; return already;
} }
const known = storedIdentity(); const known = storedIdentity();
if (known) { 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); setCurrentUser(known);
await connected(); await connected();
return known; return known;
+190 -4
View File
@@ -27,18 +27,21 @@ function fakeStorage(initial: Record<string, string> = {}) {
/** Put the page in a given URL + storage state, as the browser would. */ /** Put the page in a given URL + storage state, as the browser would. */
function inPage(search: string, storage: ReturnType<typeof fakeStorage>) { function inPage(search: string, storage: ReturnType<typeof fakeStorage>) {
(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).localStorage = storage;
(globalThis as any).history = { replaceState: () => {} }; (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(() => { afterEach(() => {
setCurrentUser(null); setCurrentUser(null);
resetConfig(); resetConfig();
resetStoreRegistry(); resetStoreRegistry();
delete (globalThis as any).location; for (const name of PAGE_GLOBALS) Reflect.deleteProperty(globalThis, name);
delete (globalThis as any).localStorage;
delete (globalThis as any).history;
}); });
function configured() { function configured() {
@@ -118,3 +121,186 @@ test("a stored value is normalized too — an old entry cannot key onto a second
await ensureIdentity(); await ensureIdentity();
expect(getCurrentUser()).toBe("frank"); 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<void> {
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<string, ((e: unknown) => 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<typeof fakeStorage> }) {
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);
});