feat: le polyfill possède la session et la normalisation des identités

Pour démarrer, une application devait écrire une promesse autour du callback
d'init(), attraper l'événement loggedin, puis fournir un thunk getSession qui
dépiaute session_id et les trois identifiants de store dans notre forme. Plus un
normalizeId. C'est précisément la plomberie que ce paquet existe pour absorber :
chaque application la réécrirait à l'identique, et c'est elle qui a produit deux
défauts aujourd'hui — un blocage et un partage cassé en silence.

En amont, une session est RENDUE ; une application n'en assemble jamais une à
partir de champs bruts. Et les identités virtuelles sont une invention du
polyfill, donc leur normalisation lui appartient.

Le wrapper init() enveloppe désormais le callback de l'appelant : il capture
l'événement, en dérive la session, puis appelle le callback avec le même
événement. Le paquet n'appelle jamais init de sa propre initiative — il
l'enveloppe. Sans callback, il capture quand même.

getSession et normalizeId quittent la surface publiée. Le chemin d'injection
reste pour les harnais, mais inatteignable depuis l'entrée : vérifié par un
import à l'exécution et par un configure() refusé à la compilation.

Défaut trouvé et corrigé en route : le broker envoie session_id en NOMBRE, et le
convertir en chaîne faisait refuser tous les appels par le binding wasm. La
valeur ne fait que transiter, elle est relayée telle quelle. Reste que toute la
chaîne la type string — inexactitude antérieure à ce commit, à traiter à part.

Une application écrit maintenant : configure({ ng, useShape, init, sharedWallet }).
This commit is contained in:
Sylvain Duchesne
2026-08-12 17:39:12 +02:00
parent 7a4d9b492f
commit cc8a95d303
20 changed files with 501 additions and 141 deletions
+81 -14
View File
@@ -17,7 +17,13 @@
import { test, expect, afterEach } from "bun:test";
import { configure, ensureIdentity } from "../src/index";
import { init } from "../src/surface/lifecycle";
import { resetConfig, resetStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import {
configureStoreRegistry,
resetConfig,
resetStoreRegistry,
setCurrentUser,
} from "../src/shared-wallet/bootstrap";
import { sharedWalletSession } from "../src/shared-wallet/session";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
const KEY = "ng-eventually:identity";
@@ -125,26 +131,39 @@ afterEach(() => {
for (const name of PAGE_GLOBALS) Reflect.deleteProperty(globalThis, name);
});
/** The event the real `init` sends its callback once the broker answers — `ngweb.js:124`. */
const BROKER_SESSION = { session_id: "s", private_store_id: "did:ng:o:p" };
const loggedIn = (session: Record<string, unknown> = BROKER_SESSION) => ({
status: "loggedin",
session,
});
/**
* The consumer's real wiring, reduced to the cycle it creates.
*
* An application resolves its session FROM `init()`'s callback and hands the library a
* thunk that waits for it (`examples/notebook/app.ts`). So before `init()` runs the session
* does not exist and cannot: nothing else resolves it. That is why the injected `init`
* here resolves it — a `getSession` that answered straight away would be a state the real
* system never reaches, and it is precisely the state under which the deadlock below is
* invisible.
* The session exists only from `init()`'s callback: nothing else in the system opens one,
* so before `init()` runs there is none and there cannot be. That is why the injected
* `init` here is what produces it — a session that answered straight away would be a state
* the real system never reaches, and it is precisely the state under which the deadlock
* below is invisible.
*
* It produces it the way the real one does: by calling the callback it was HANDED, once,
* with `{ status: "loggedin", session }`. Since 2026-08-12 that callback is the library's
* wrapper, so this also exercises the capture; `sessionReady` here is this fixture's own
* view of the same instant, kept so the assertions can name it.
*
* The spy records what the real `init()` reads at the moment it is called — the address
* bar — and returns a promise, as the real one does.
*/
function consumerWiring() {
function consumerWiring(session: Record<string, unknown> = BROKER_SESSION) {
let arrived!: (s: RegistrySession) => void;
const sessionReady = new Promise<RegistrySession>((resolve) => { arrived = resolve; });
const calls: { href: string; args: unknown[] }[] = [];
const returned = { itsOwnReturnValue: true };
const injectedInit = (...args: unknown[]): Promise<unknown> => {
calls.push({ href: String((globalThis as { location?: { href: string } }).location?.href), args });
const callback = args[0];
if (typeof callback === "function") void (callback as (e: unknown) => unknown)(loggedIn(session));
arrived({ sessionId: "s", privateStoreId: "did:ng:o:p" });
return Promise.resolve(returned);
};
@@ -156,9 +175,14 @@ function configured(wiring: ReturnType<typeof consumerWiring>, opts: { sharedWal
ng: {} as never,
useShape: (() => {}) as never,
init: wiring.injectedInit,
...(opts.sharedWallet === false ? {} : { sharedWallet: { fileUrl: "/w.ngw", password: "pw" } }),
});
// The registry reaches the session through the internal wiring path, pointed at THIS
// fixture's promise — which, like the package's own, only `init()` can resolve. Without
// that the deadlock test below would be measuring a session that arrives by itself.
configureStoreRegistry({
getSession: wiring.getSession,
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
...(opts.sharedWallet === false ? {} : { sharedWallet: { fileUrl: "/w.ngw", password: "pw" } }),
});
}
@@ -260,17 +284,60 @@ test("`init` and `ensureIdentity` in the same tick raise ONE barrier, not two",
});
test("arguments and return value pass through untouched — it is still a forwarder", async () => {
// Settling is added BEFORE the delegate, never around it: `init` takes a callback and
// upstream returns a promise, so anything this wrapper altered on the way in or out
// would be a difference the application has to unlearn at migration.
// Settling is added BEFORE the delegate, never around it, and the return value comes back
// as it left: anything this wrapper altered on the way in or out is a difference the
// application has to unlearn at migration.
//
// The ONE exception is the callback, wrapped since 2026-08-12 so the package can keep the
// session the SDK delivers through it. What must therefore hold is not that the same
// function object arrives — it does not — but that the caller's callback still sees
// exactly the event the SDK sent, unchanged and un-narrowed. That is the property an
// application depends on, and the only one that survives migration.
const wiring = consumerWiring();
configured(wiring);
inBrowser(APP, fakeStorage(), "top-level");
setCurrentUser("juno");
const callback = (): void => {};
const seen: unknown[] = [];
const callback = (event: unknown): void => void seen.push(event);
const result = await within(init(callback, true, ["a-broker"]));
expect(wiring.calls[0]!.args).toEqual([callback, true, ["a-broker"]]);
expect(wiring.calls[0]!.args.slice(1)).toEqual([true, ["a-broker"]]);
expect(seen).toEqual([loggedIn()]);
expect(result).toBe(wiring.returned);
});
test("the session id is RELAYED, not rebuilt — what the broker sent is what is kept", async () => {
// The real broker answers `session_id: 1` — a NUMBER (upstream types it `string | number`,
// `index.d.ts:266`) — and the wasm binding deserializes it by that type. Normalizing it to
// a string was written into the capture first, and the applicative e2e refused every call
// in the batch: `Deserialization error of session_id JsValue("1")`. Nothing downstream
// reads this value, it only travels; so the capture must relay it untouched.
const wiring = consumerWiring({ session_id: 1, private_store_id: "did:ng:o:p" });
configured(wiring);
inBrowser(APP + "?ng-id=otto", fakeStorage(), "in the broker iframe");
await within(init(() => {}, true, []));
const relayed: unknown = (await within(sharedWalletSession())).sessionId;
expect(relayed).toBe(1);
});
test("the package holds the session even when the caller passes NO callback", async () => {
// Upstream the callback is optional (`callback: Function | null`), and an application
// that wants nothing from the lifecycle channel legitimately passes none. The session
// still has to reach the library, or every read that follows waits on a session that
// was delivered to nobody — the same silence, from the opposite direction.
//
// Inside the iframe: the only side where a session is ever opened.
const wiring = consumerWiring();
configured(wiring);
inBrowser(APP + "?ng-id=nell", fakeStorage(), "in the broker iframe");
await within(init(undefined, true, []));
await expect(within(sharedWalletSession())).resolves.toEqual({
sessionId: "s",
privateStoreId: "did:ng:o:p",
});
});