Files
ng-eventually/packages/polyfill/src/shared-wallet/access-gate.ts
T
Sylvain Duchesne 737729c9ce refactor: le paquet s'appelle polyfill, « SDK » désigne celui de NextGraph
Le nom @ng-eventually/sdk entrait en collision avec le SDK de NextGraph, dont
ce paquet est justement un polyfill. Impossible d'écrire « le SDK » sans lever
l'ambiguïté à chaque phrase — et le contrat publié, lu par une application,
était le pire endroit pour laisser traîner ça.

packages/sdk → packages/polyfill, @ng-eventually/sdk → @ng-eventually/polyfill,
contract_sdk-surface → contract_polyfill-surface, e2e/sdk-entry.ts →
e2e/polyfill-entry.ts, docs/sdk-reference.md → docs/polyfill-reference.md.

Les occurrences de « SDK » qui désignent celui de NextGraph restent intactes,
y compris les chemins dans nextgraph-rs (sdk/js/orm, sdk/js/web). Le tri s'est
fait occurrence par occurrence, pas par substitution.

Le contrat énonce désormais son identité en une phrase : « This package is a
polyfill of NextGraph's SDK. »
2026-08-10 17:14:25 +02:00

289 lines
14 KiB
TypeScript

/**
* 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<string> {
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 = `
<style>
:host { all: initial; }
.veil { position: fixed; inset: 0; z-index: 2147483647; display: flex;
align-items: center; justify-content: center; background: #fff;
font: 15px/1.5 system-ui, sans-serif; color: #222; padding: 24px; }
.card { width: 100%; max-width: 420px; }
h1 { font-size: 26px; margin: 0 0 2px; text-align: center; }
.sub { text-align: center; color: #888; margin: 0 0 22px; }
.step { display: flex; gap: 12px; margin-bottom: 16px; }
.n { flex: 0 0 24px; height: 24px; border-radius: 50%; background: #444; color: #fff;
display: flex; align-items: center; justify-content: center; font-size: 13px; }
.t { font-weight: 600; font-size: 14px; margin: 1px 0 6px; }
a, button, input { font: inherit; }
a { color: #0b5ed7; }
code { background: #f2f2f2; padding: 2px 6px; border-radius: 4px; user-select: all; }
input { width: 100%; padding: 9px 10px; border: 1px solid #bbb; border-radius: 6px; box-sizing: border-box; }
button.go { width: 100%; margin-top: 10px; padding: 10px; border: 0; border-radius: 6px;
background: #222; color: #fff; cursor: pointer; }
button.go[disabled] { opacity: .45; cursor: default; }
.hint { color: #999; font-size: 12px; margin: 6px 0 0; }
</style>
<div class="veil"><div class="card">
<h1>Accès</h1>
<p class="sub">Environnement de test</p>
<div class="step"><div class="n">1</div><div>
<div class="t">Télécharger le portefeuille</div>
<a href="${cfg.fileUrl}" download>Télécharger le fichier</a>
</div></div>
<div class="step"><div class="n">2</div><div>
<div class="t">Mot de passe</div>
<code>${cfg.password}</code>
</div></div>
<div class="step"><div class="n">3</div><div>
<div class="t">Importer une fois</div>
<a href="${importUrl}" target="_blank" rel="noreferrer">Ouvrir l'application portefeuille</a>
</div></div>
<div class="step"><div class="n">4</div><div>
<div class="t">Votre identifiant</div>
<input data-testid="ng-identity-input" placeholder="votre identifiant" />
<p class="hint">Il identifie votre espace (mis en minuscules).</p>
<button class="go" data-testid="ng-identity-enter" disabled>Entrer</button>
</div></div>
</div></div>`;
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<PrincipalId> {
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<void> {
await connectedUser();
}