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:
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user