737729c9ce
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. »
289 lines
14 KiB
TypeScript
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();
|
|
}
|