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. »
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
Reference in New Issue
Block a user