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
@@ -0,0 +1,114 @@
/**
* The wallet session, held by the PACKAGE — never assembled by an application.
*
* ── Why this is not the application's business ────────────────────────────
* Upstream a session is RETURNED: `session_start` hands one back, and `init()`'s callback
* delivers `{ status: "loggedin", session }` (`@ng-org/web` `dist/ngweb.js:124`, VERIFIED —
* the callback is invoked once, with the `Session` the broker just opened). Nowhere does an
* application build a session out of raw fields.
*
* It did here, and only here: every consumer had to wrap `init()` in a promise, catch that
* event, and hand the library back a thunk unwrapping `session_id` / `private_store_id` /
* `protected_store_id` / `public_store_id`. Identical plumbing in every application, with
* nothing to migrate to — and it went wrong twice in the reference integration alone (a
* deadlock, and a share that silently reached nobody). So the package captures the event
* itself (`surface/lifecycle.ts`) and keeps the session here.
*
* ── What survives migration ───────────────────────────────────────────────
* NO COUNTERPART as a module: upstream nothing relays the session, because the SDK holds
* the one the wallet opened. What survives is the application-side gesture this removes —
* which is none at all.
*/
import type { RegistrySession } from "./account-registry";
/**
* The session as it stands, and a promise for the first one to arrive.
*
* Both, because the two questions differ: a caller after the fact wants the CURRENT session
* (a reconnection opens a new one, and reads must route through it), while a caller during
* startup has to wait for the first. Answering the first question with a settled promise
* would pin the very first session forever.
*/
let current: RegistrySession | null = null;
let announce!: (s: RegistrySession) => void;
let arrival = openArrival();
function openArrival(): Promise<RegistrySession> {
return new Promise<RegistrySession>((resolve) => {
announce = resolve;
});
}
/**
* The session this package holds — the current one, or the first to arrive.
*
* It WAITS rather than refusing: before `init()` has been delegated to, no session can
* exist and nothing else will make one. A thunk that threw there is what shipped the
* silent-abandon defect (`emulated-verifier/connect.ts` swallows the throw, the run
* abandons, and the caller that joins it resolves having restored nothing).
*/
export function sharedWalletSession(): Promise<RegistrySession> {
return current !== null ? Promise.resolve(current) : arrival;
}
/**
* Read a lifecycle event, and keep the session if it carries one.
*
* Answers whether it did, so a caller can tell "the session landed" from "some other
* event went by" without reaching in. Every event that is not a `loggedin` carrying a
* session is ignored — the callback is a general lifecycle channel, and inventing a
* session out of a partial event would be worse than having none.
*
* ── The session id is RELAYED, never rebuilt — and that is load-bearing ────
* Upstream declares it `string | number` (`Session`, `index.d.ts:266`) and the broker
* returns a NUMBER; the whole chain below here types it `string` and hands it to `ng.*`,
* whose binding takes it as-is. So this reads the field and passes it on untouched. It is
* not a detail: normalizing it to a string was written here first, and the applicative e2e
* refused every call in the batch with `Deserialization error of session_id JsValue("1")`
* — the wasm side deserializes the id by its own type, and a stringified number is not it.
*
* The `string` in the declared shape is therefore inherited, not asserted: the inaccuracy
* is the chain's and predates this module (every consumer's thunk declared it the same way
* and relayed the same value). Widening it belongs to the chain, not to the capture.
*/
export function captureSession(event: unknown): boolean {
if (typeof event !== "object" || event === null) return false;
const { status, session } = event as { status?: unknown; session?: unknown };
if (status !== "loggedin") return false;
if (typeof session !== "object" || session === null) return false;
const {
session_id: sessionId,
private_store_id: privateStoreId,
protected_store_id: protectedStoreId,
public_store_id: publicStoreId,
} = session as {
session_id?: string;
private_store_id?: string;
protected_store_id?: string;
public_store_id?: string;
};
// An event missing either anchor is not a session; the id is checked for PRESENCE only,
// since its runtime type is the broker's to choose and ours to relay.
if (sessionId === undefined || sessionId === null) return false;
if (typeof privateStoreId !== "string") return false;
current = {
sessionId,
privateStoreId,
...(typeof protectedStoreId === "string" ? { protectedStoreId } : {}),
...(typeof publicStoreId === "string" ? { publicStoreId } : {}),
};
announce(current);
return true;
}
/**
* Forget the captured session (a fresh `configure()`, or a test).
*
* The pending promise is REPLACED rather than left resolved: a suite that reset and then
* awaited again must wait for the next session, not be handed the previous one's.
*/
export function resetSharedWalletSession(): void {
current = null;
arrival = openArrival();
}