/** * The access gate — the whole shared-wallet sign-in, moved out of consumer applications. * * ── Why this lives in the library ───────────────────────────────────────── * Every step below exists ONLY because one wallet hosts several identities. An * application that implements them is writing code it will have to delete, and worse, * code that teaches its authors a model NextGraph does not have: *"I name my identity"*. * The first consumer had ~300 lines of it (a gate component, a screen, a wallet module, * an identity context, three BDD features). That is the library's work, not theirs. * * Upstream, none of this exists. A user opens THEIR wallet, it contains THEIR site * (`SensitiveWalletV0.personal_identity()`, `engine/wallet/src/types.rs:576-579`), and * `session_start(wallet_name, user_id)` takes an id that came FROM the wallet. There is * nothing to name and nothing to choose. So this module is pure scaffolding: it * evaporates whole, and the one call it exposes becomes a plain "open the session". * * ── The three steps, and why each is here ───────────────────────────────── * 1. **Hand over the wallet file.** A hosted broker cannot import a wallet inline during * web-app auth — a first-time device has no wallet, so the redirect dead-ends. So the * user downloads the `.ngw` and imports it once on the wallet app. The FILE is the * right primitive: a TextCode is a transient 5-minute device-to-device transfer, * unusable to embed. * 2. **Show the shared password**, for that import. * 3. **Take an identifier**, which names the virtual space. This is the step that * inverts the model, and the reason the whole gate is scaffolding. * * ── The identifier crosses a storage boundary, and that is not incidental ── * The flow runs in TWO contexts with SEPARATE localStorage partitions: the top-level * page and the broker iframe (browsers partition storage by top-level site). A value * written top-level is NOT the value the iframe reads. What DOES cross is the URL: the * redirect embeds the full app URL, query included, and reloads it in the iframe. Hence * the resolution order, which must not be "simplified": * * 1. `?ng-id=` in the URL — wins whenever present, because it is the only thing that * crosses the frontier; * 2. otherwise localStorage — same-partition convenience, and prefill on reload. * * 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. */ import { getConfig, getCurrentUser, getStoreRegistryDeps, setCurrentUser, } from "./bootstrap"; import { connectedUser } from "../emulated-verifier/connect"; import type { PrincipalId } from "../model/types"; /** * Normalize an identifier the SAME way the shim keys accounts on. * * Not a detail: the identifier arrives from three places — typed at the gate, read from * the URL after the broker round-trip, read from storage — and if any of them normalizes * differently, that path keys onto a DIFFERENT virtual user. `@Erin` from the URL and * `erin` typed at the gate must be one space, not two. So there is one normalizer, the * injected one, and the gate borrows it rather than keeping its own `toLowerCase()`. * * Falls back to the library's own default when the registry is not configured yet, which * is possible since the gate can run before anything else. */ function normalizeIdentity(raw: string): string { try { return getStoreRegistryDeps().normalizeId(raw); } catch { return raw.trim().replace(/^@/, "").toLowerCase(); } } /** 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. */ const URL_PARAM = "ng-id"; /** * What a deployment must supply for the gate to run. These are not settings a user * tunes: they are the shared wallet this deployment hands out, so they belong to * whoever deploys, and they disappear with the gate. * * **The library reads no environment variable, ever.** The application resolves these at * its own build — copying the `.ngw` into its bundle, injecting the password — and * passes the VALUES here. A library that read `process.env` would impose its build * system on every consumer, and would be untestable with other values. */ export interface SharedWalletConfig { /** URL of the `.ngw` file served by the application's own bundle. */ fileUrl: string; /** The shared password, shown for the one-time import. Zero-security by design. */ password: string; /** The wallet app where the import happens. Defaults to the public one. */ importUrl?: string; } const DEFAULT_IMPORT_URL = "https://nextgraph.eu/#/wallet/login"; /** The identifier this device already used, from the URL first, then storage. */ function storedIdentity(): string | null { try { const fromUrl = new URLSearchParams(globalThis.location?.search ?? "").get(URL_PARAM); if (fromUrl && fromUrl.trim()) { // Normalized on the way IN: the URL carries whatever a user or a link put there // (`@Erin`), and an un-normalized value keys onto a different virtual user than the // same identifier typed at the gate. const normalized = normalizeIdentity(fromUrl); globalThis.localStorage?.setItem(STORAGE_KEY, normalized); return normalized; } const stored = globalThis.localStorage?.getItem(STORAGE_KEY); return stored ? normalizeIdentity(stored) : null; } catch { return null; // storage blocked (private mode, sandboxed iframe) — the gate asks again } } /** Put the identifier where the round-trip can find it, then remember it locally. */ function rememberIdentity(id: string): void { try { globalThis.localStorage?.setItem(STORAGE_KEY, id); 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. } } /** * Show the gate and resolve with the identifier the user entered. * * 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. * * 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 { const importUrl = cfg.importUrl ?? DEFAULT_IMPORT_URL; return new Promise((resolve) => { const host = document.createElement("div"); host.setAttribute("data-ng-eventually", "access-gate"); // A shadow root so the application's stylesheet cannot reshape the barrier, and the // barrier's cannot leak into the application. const root = host.attachShadow({ mode: "open" }); root.innerHTML = `

Accès

Environnement de test

1
Télécharger le portefeuille
Télécharger le fichier
2
Mot de passe
${cfg.password}
4
Votre identifiant

Il identifie votre espace (mis en minuscules).

`; const input = root.querySelector("input") as HTMLInputElement; const go = root.querySelector("button.go") as HTMLButtonElement; const sync = (): void => { go.disabled = input.value.trim().length === 0; }; const enter = (): void => { const value = input.value.trim(); if (!value) return; host.remove(); resolve(value); }; input.addEventListener("input", sync); input.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter") enter(); }); go.addEventListener("click", enter); sync(); document.body.appendChild(host); input.focus(); }); } /** * Ensure an identity is set for this session, showing the gate only if one is missing. * * The application calls this once, before it renders. It does NOT pass an identifier: * naming one is the step that will disappear, so it must not appear in the signature — * 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. * * **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 * from the wallet it opened, so it holds its identity before the session exists. Here the * GATE chooses it, so the gate is what hands it back. Without this the example * application had to read the gate's own private storage key — a boundary no consumer * should be able to see, let alone depend on. */ export async function ensureIdentity(): Promise { const already = getCurrentUser(); if (already !== null) { await connected(); return already; } const known = storedIdentity(); if (known) { setCurrentUser(known); await connected(); return known; } const cfg = getConfig().sharedWallet; if (!cfg) { // Not a misconfiguration to paper over: without a shared wallet there is nothing to // hand the user, and silently continuing would provision an anonymous space. throw new Error( "[ng-eventually] access gate: no shared wallet configured. Pass `sharedWallet` to " + "`configure()` — the wallet file URL and its password — or set the identity yourself.", ); } if (typeof document === "undefined") { throw new Error( "[ng-eventually] access gate: no identity set and no DOM to ask on (server-side or " + "test context). Set one explicitly before calling.", ); } const chosen = await askForIdentity(cfg); const normalized = normalizeIdentity(chosen); rememberIdentity(normalized); setCurrentUser(normalized); await connected(); return normalized; } /** * Wait for the connection work `setCurrentUser` fires — restoring what others shared * with this user, draining its inboxes — before this call resolves. * * **Not a convenience: a correctness fix, found by the applicative e2e.** Setting an * identity FIRES that work and does not wait for it. An application that rendered on * `ensureIdentity()` alone could read a note someone had just shared with it as * unreadable — which looks like a permission problem and is a timing one, in the one * place where the difference is invisible (nothing throws; a read is simply empty). * * Doing it here rather than exposing `connectedUser()` is the point: the awaited thing * has NO counterpart upstream — there, opening the session IS the connection, and no * application awaits a second call. So the polyfill absorbs it, and an application's * bootstrap keeps the shape it will still have after migration. */ async function connected(): Promise { await connectedUser(); }