feat(access-gate): le parcours de connexion passe dans le polyfill

Chaque étape de cet écran n'existe que parce qu'un wallet héberge plusieurs
identités. Une app qui l'implémente écrit du code qu'elle devra supprimer, et
pire, du code qui enseigne à ses auteurs un modèle que NextGraph n'a pas :
« je nomme mon identité ». Le premier consommateur en avait ~300 lignes — un
gate, un écran, un module wallet, un contexte d'identité, trois features. C'est
le travail de la bibliothèque, pas le sien.

`ensureIdentity()` : un appel, avant que l'app s'affiche. Il ne prend PAS
d'identifiant — nommer une identité est l'étape qui disparaîtra, donc elle ne
doit pas figurer dans la signature. Le jour où le wallet fournit l'identité,
l'appel se résout sans rien afficher et le code de l'appelant ne bouge pas.

L'écran est en DOM natif, sous shadow root : c'est une barrière technique
montrée avant qu'une application s'affiche, comme une demande de mot de passe
sur une bêta fermée. La lier à un framework obligerait chaque consommateur à
adopter ce framework pour un écran voué à disparaître.

L'ordre de résolution de l'identité est pinné par des tests, parce que s'y
tromper échoue en SILENCE : le parcours traverse deux partitions localStorage
distinctes — la page et l'iframe du broker — et seul l'URL franchit la
frontière. Si le stockage l'emportait, l'iframe lirait une identité vide,
provisionnerait un second utilisateur virtuel, et l'utilisateur reviendrait dans
un espace vide sans la moindre erreur.

Les identifiants du wallet partagé (fichier, mot de passe) passent par
`configure()` : ce sont des données de déploiement, et cet appel est déjà celui
qui devient inerte à la migration. Au passage, l'ancien champ `sharedWallet:
{ name, secret }` — inutilisé nulle part — est remplacé.

168 tests unitaires, typecheck vert.
This commit is contained in:
Sylvain Duchesne
2026-08-05 17:06:10 +02:00
parent c42236bc00
commit 66a40fbb89
6 changed files with 322 additions and 3 deletions
+5
View File
@@ -25,6 +25,11 @@ export { useShape } from "./surface/use-shape";
export { watchShape } from "./surface/watch-shape";
export type { ShapeQuery, ShapeObservable } from "./surface/watch-shape";
export { init, initNg } from "./surface/lifecycle";
// The access gate: one call, before the app renders. It shows a technical barrier only
// while the shared wallet needs one — the day the wallet supplies the identity it
// resolves silently, and this line stays as it is (`shared-wallet/access-gate.ts`).
export { ensureIdentity } from "./shared-wallet/access-gate";
export type { SharedWalletConfig } from "./shared-wallet/access-gate";
export * as inbox from "./surface/inbox";
export * as docs from "./surface/docs";
export { subscribeDoc, subscribeDocs, docChangeType } from "./surface/subscribe";
@@ -0,0 +1,215 @@
/**
* 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, setCurrentUser } from "./bootstrap";
/** 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.
*/
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;
/** Shown as the gate's heading. The deployment's name, not a domain concept. */
appName?: 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()) {
// Persist it in THIS partition too, so a later reload without the param prefills.
globalThis.localStorage?.setItem(STORAGE_KEY, fromUrl.trim());
return fromUrl.trim();
}
return globalThis.localStorage?.getItem(STORAGE_KEY) ?? 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.
*
* 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, prefill: string | null): 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>${cfg.appName ?? "Accès"}</h1>
<p class="sub">Espace 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);
if (prefill) { input.value = prefill; }
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.
*/
export async function ensureIdentity(): Promise<void> {
if (getCurrentUser() !== null) return;
const known = storedIdentity();
if (known) {
setCurrentUser(known);
return;
}
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, null);
const normalized = chosen.trim().toLowerCase();
rememberIdentity(normalized);
setCurrentUser(normalized);
}
@@ -16,6 +16,7 @@
*/
import type { NgLike, UseShapeLike, Nuri, PrincipalId, ReadCap } from "../model/types";
import type { SharedWalletConfig } from "./access-gate";
import type { RegistrySession } from "./account-registry";
import { CapRegistry } from "../emulated-verifier/caps";
import { setAccessLog } from "./access-log";
@@ -49,12 +50,16 @@ export interface StoreRegistryDeps {
}
export interface EventuallyConfig {
/**
* The shared wallet this deployment hands out, and what the access gate needs to do
* it (`shared-wallet/access-gate.ts`). Absent → no gate; the caller sets the identity
* itself. Disappears with the gate: upstream a user opens their own wallet.
*/
sharedWallet?: SharedWalletConfig;
/** The REAL `@ng-org/web` `ng` (injected to avoid a hard import / alias loop). */
ng: NgLike;
/** The REAL `@ng-org/orm` `useShape`. */
useShape: UseShapeLike;
/** Shared-wallet credentials — polyfill only (one wallet for everyone). */
sharedWallet?: { name: string; secret: string };
/** Initial current user; may also be set later via {@link setCurrentUser}. */
currentUser?: PrincipalId;
/**
+94
View File
@@ -0,0 +1,94 @@
/**
* The access gate's identity resolution.
*
* This is the piece whose failure is SILENT: get the order wrong and the broker iframe
* reads an empty identity, provisions a second virtual user, and the returning user
* lands in an empty space with no error anywhere. So the order is pinned, not trusted.
*/
import { test, expect, afterEach } from "bun:test";
import { configure, resetConfig, setCurrentUser, getCurrentUser } from "../src/polyfill";
import { ensureIdentity } from "../src/shared-wallet/access-gate";
const KEY = "ng-eventually:identity";
/** A localStorage double — the real one is absent in `bun test`. */
function fakeStorage(initial: Record<string, string> = {}) {
const map = new Map(Object.entries(initial));
return {
getItem: (k: string) => map.get(k) ?? null,
setItem: (k: string, v: string) => void map.set(k, v),
removeItem: (k: string) => void map.delete(k),
get size() { return map.size; },
};
}
/** Put the page in a given URL + storage state, as the browser would. */
function inPage(search: string, storage: ReturnType<typeof fakeStorage>) {
(globalThis as any).location = { search, href: "https://app.example" + search };
(globalThis as any).localStorage = storage;
(globalThis as any).history = { replaceState: () => {} };
}
afterEach(() => {
setCurrentUser(null);
resetConfig();
delete (globalThis as any).location;
delete (globalThis as any).localStorage;
delete (globalThis as any).history;
});
function configured() {
configure({
ng: {} as never,
useShape: (() => {}) as never,
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
});
}
test("an identity already set is left alone — the gate never re-asks", async () => {
configured();
inPage("", fakeStorage());
setCurrentUser("alice");
await ensureIdentity();
expect(getCurrentUser()).toBe("alice");
});
test("the URL parameter WINS over storage — it is the only thing that crosses the frontier", async () => {
// The top-level page and the broker iframe have separate localStorage partitions, so a
// value written on one side is not the value the other reads. The URL survives the
// round-trip; storage does not. If storage won here, a user entering a second
// identifier would keep being sent back to the first one's space.
configured();
inPage("?ng-id=fromurl", fakeStorage({ [KEY]: "fromstorage" }));
await ensureIdentity();
expect(getCurrentUser()).toBe("fromurl");
});
test("the URL parameter is copied into THIS partition, so a plain reload still knows", async () => {
configured();
const storage = fakeStorage();
inPage("?ng-id=carol", storage);
await ensureIdentity();
expect(storage.getItem(KEY)).toBe("carol");
});
test("with no parameter, storage answers — a reload does not re-ask", async () => {
configured();
inPage("", fakeStorage({ [KEY]: "dana" }));
await ensureIdentity();
expect(getCurrentUser()).toBe("dana");
});
test("nothing known and no DOM to ask on → it refuses loudly", async () => {
// Continuing silently would provision an anonymous virtual space, which is the failure
// this module exists to prevent. The error names what the caller must do.
configured();
inPage("", fakeStorage());
await expect(ensureIdentity()).rejects.toThrow(/no DOM to ask on/i);
});
test("no shared wallet configured → it refuses, rather than inventing a space", async () => {
configure({ ng: {} as never, useShape: (() => {}) as never });
inPage("", fakeStorage());
await expect(ensureIdentity()).rejects.toThrow(/no shared wallet configured/i);
});
Binary file not shown.