Files
ng-eventually/packages/polyfill/src/shared-wallet/session.ts
T
Sylvain Duchesne 12eba6eea6 fix: quatre écarts entre la surface publiée et ce que NextGraph déclare
Un audit de la surface contre la source amont en a trouvé cinq ; voici les
quatre mécaniques. La cinquième — l'adresse d'inbox, qui traverse sept symboles
— relève du dessin et reste ouverte.

L'identifiant de session bloquait. Amont le déclare string | number
(sdk/js/web/src/index.ts:16) et le binding désérialise un u64 ; nous exigions
une chaîne. Une application ne pouvait donc pas passer la valeur que le SDK
venait de lui remettre. Élargi à ce qu'amont déclare, sur toute la chaîne, et
jamais converti : une chaîne échoue pour de vrai (Deserialization error of
session_id JsValue("1"), observé).

sparqlUpdate annonçait Promise<void> alors qu'il relayait DÉJÀ les commits.
C'était donc un mensonge de typage, pas un comportement — et la doublure de test
qui rendait undefined, un état que le vrai broker ne produit jamais, est ce qui
l'a laissé sans contradicteur.

ng était publié en Record<string, any>, ce qui perdait les 88 membres typés
d'amont — 88, pas 77 : le chiffre de notre propre documentation était faux.

Et materialize, second nom publié de read, sans appelant ni contrepartie amont,
est retiré.

docs/api-contract.md qualifiait docs.* de passthrough « 1:1 ». C'était faux sur
les deux premiers points. Corrigé, pas complété : un document qui se déclare
vérifié et qui ment est pire qu'un document absent, parce qu'on cesse d'aller
voir.

Une déviation assumée : amont type le retour en any, interdit ici ; on rend
unknown, comme sparqlQuery le fait déjà pour le même any amont.
2026-08-14 10:00:40 +02:00

151 lines
7.0 KiB
TypeScript

/**
* 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();
/**
* Has `init()` been called — is a session on its way at all?
*
* {@link sharedWalletSession} WAITS, and waiting is only right while a session can still
* arrive. Before `init()` has been delegated to, nothing in the system will ever open one:
* the wait is unbounded and answerless, which is the worst shape a failure can take — no
* error, no timeout, the application simply stops where it awaited. The polyfill's `init()`
* notes itself here as it goes through (`surface/lifecycle.ts`), and that one fact separates
* "not yet" from "never" with no timeout, no race and no guessing.
*/
let expected = false;
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;
}
/**
* Note that `init()` has been called — a session is on its way. See {@link expected}.
*
* @internal Called by the polyfill's `init()`, and by nothing else: it is a statement about
* that call having happened, not a switch anyone may set.
*/
export function expectSession(): void {
expected = true;
}
/**
* Can a session still arrive through this holder — because one is already here, or because
* `init()` has been called and will deliver one?
*
* A caller that awaits when this answers `false` waits forever. It is deliberately not a
* refusal by itself: the holder's job is to answer, and who should refuse is whoever is
* about to wait ({@link ./access-gate}.ensureIdentity).
*/
export function sessionIsComing(): boolean {
return current !== null || expected;
}
/**
* 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 chain below here now types it the same way 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 chain used to narrow it to `string`, which made the value the SDK hands an application
* unpassable through this library. Widened to upstream's own declared type on 2026-08-14, at
* the capture and along every hop that relays it; the id is still only ever RELAYED.
*/
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 | number;
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();
// Including the expectation: it was set by an `init()` on the config being discarded, and
// a suite that reset and then awaited must be told the truth about the NEXT one.
expected = false;
}