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
+7 -4
View File
@@ -71,15 +71,18 @@ afterEach(() => {
});
function configured() {
configureStoreRegistry({
getSession: async () => ({ sessionId: "s", privateStoreId: "did:ng:o:p" }),
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
});
configure({
ng: {} as never,
useShape: (() => {}) as never,
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
});
// AFTER `configure`, which wires the registry onto the package's own session — a session
// that only `init()` can open, and no page here calls it. The substitution is what lets
// these tests reach the gate without a broker, and it must be the last word.
configureStoreRegistry({
getSession: async () => ({ sessionId: "s", privateStoreId: "did:ng:o:p" }),
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
});
}
test("an identity already set is left alone — the gate never re-asks", async () => {
+5 -1
View File
@@ -9,6 +9,7 @@
import { test, expect, mock, afterEach } from "bun:test";
import { configure, ensureIdentity, storeRegistry } from "../src/index";
import {
configureStoreRegistry,
resetCaps,
resetConfig,
resetStoreRegistry,
@@ -89,8 +90,11 @@ function inject(failWriteMatching?: RegExp) {
configure({
ng: { doc_create, sparql_update, sparql_query } as never,
useShape: (() => {}) as never,
getSession: async () => SESSION,
});
// The session comes from `init()` upstream and from the package's capture here, so a
// suite with no browser and no broker substitutes one through the internal wiring path —
// the same one the e2e harness uses. AFTER `configure`, which wires the package's own.
configureStoreRegistry({ getSession: async () => SESSION });
resetRegistryCache();
resetCaps();
setCurrentUser(null);
+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",
});
});
+59 -42
View File
@@ -9,23 +9,39 @@
* ── The mechanism, so a future reader can judge a change against it ────────
* Settling the identity called `setCurrentUser`, which FIRES the connection work. Firing is
* not awaiting, but it is still running: the work's first act is `resolveAccount`, which
* awaits the consumer's `getSession` thunk. Settling happens before `init()` has been
* delegated to — and the reference application builds its `sessionReady` promise AROUND
* that very `init()` call, so at that instant the promise it would wait on does not exist.
* The thunk could not answer. It threw, `resolveAccount` answered null, and the run
* abandoned before restoring a single capability — after registering itself as the
* connection in flight. The `connectedUser()` that `ensureIdentity()` awaits then JOINED
* that abandoned run instead of doing the work, and resolved having done nothing. The
* application rendered, and Bob held no key to a note that had been shared with him.
* awaits the session. Settling happens before `init()` has been delegated to — and the
* reference application built its `sessionReady` promise AROUND that very `init()` call, so
* at that instant the promise it would wait on did not exist. The session could not be
* answered. It threw, `resolveAccount` answered null, and the run abandoned before
* restoring a single capability — after registering itself as the connection in flight. The
* `connectedUser()` that `ensureIdentity()` awaits then JOINED that abandoned run instead of
* doing the work, and resolved having done nothing. The application rendered, and Bob held
* no key to a note that had been shared with him.
*
* So what is pinned is not "the calls happen in this order" — a regression would still call
* them in order. It is what each half TOUCHES: settling must not call the session thunk at
* all, and signing in must not come back until the thunk has actually answered.
* them in order. It is what each half TOUCHES: settling must not reach for the session at
* all, and signing in must not come back until the session has actually answered.
*
* ── What moved on 2026-08-12, and what it does not excuse ─────────────────
* The session is no longer the application's to supply: the package captures it from
* `init()`'s callback and holds it (`shared-wallet/session.ts`), so the promise the
* connection work waits on is now the library's own. That closes the *shape* of the failure
* above — a holder that WAITS cannot throw, so a run can no longer abandon for want of an
* answer. It closes nothing about the ordering: the session still arrives only through
* `init()`, so a half that reached for it too early would still be waiting on something
* that does not exist yet. Both properties below are therefore still worth their assertion,
* and the fixture instruments the package's own holder rather than a consumer's thunk.
*/
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 APP = "https://app.example/";
@@ -73,39 +89,46 @@ afterEach(() => {
/**
* The reference application's bootstrap, in its real order (`examples/notebook/app.ts`):
* `configure()` first, then `init()` called from INSIDE the executor that builds the very
* promise the session thunk waits on.
* `configure()`, then `init()` — and nothing else, since the session stopped being
* something an application assembles.
*
* That shape is not a curiosity of this fixture, it is what the e2e serves: in the bundle,
* `sessionReady` is a hoisted `var`, so while the executor runs it is still `undefined` and
* the thunk has nothing to wait on. It therefore **refuses** rather than blocking — and an
* application is entitled to refuse, since at that moment there is genuinely nothing to
* return. A thunk that merely blocked would hide the whole defect, which is why the double
* here refuses exactly as the application's does.
* The injected `init` produces the session the way the real one does: by calling the
* callback it was handed, with `{ status: "loggedin", session }` (`ngweb.js:124`). Before
* it is called, nothing in the system can make the session exist — which is the whole
* cycle these two tests measure.
*
* The injected `init` resolves the session, as the real one does through its callback:
* before it is called, nothing in the system can make the session exist.
* The counters sit on the REGISTRY's route to the session, substituted through the internal
* wiring path. That is where the connection work actually asks, so it is where "was it
* reached, and did it answer" can be told apart. `refused` is kept though the package's
* holder cannot throw: a future change that put a refusing thunk back on this route is
* exactly the regression the file exists to catch, and a counter nobody kept would let it
* back in silently.
*/
function bootTheApplication(identifier: string) {
/** What the consumer's thunk was asked, and what it was able to say. */
/** What the registry's route to the session was asked, and what it was able to say. */
const thunk = { asked: 0, answered: 0, refused: 0 };
let session: RegistrySession | null = null;
// Deliberately assigned AFTER `init()` runs — see above. `undefined` until then.
let sessionReady: Promise<RegistrySession> | undefined;
let arrive!: (s: RegistrySession) => void;
configure({
ng: {} as never, // no store behind it: the assertions are about what is REACHED
useShape: (() => {}) as never,
init: (..._args: unknown[]): Promise<string> => {
arrive({ sessionId: "s", privateStoreId: "did:ng:o:private" });
init: (...args: unknown[]): Promise<string> => {
const callback = args[0];
if (typeof callback === "function") {
void (callback as (e: unknown) => unknown)({
status: "loggedin",
session: { session_id: "s", private_store_id: "did:ng:o:private" },
});
}
return Promise.resolve("delegated");
},
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
});
configureStoreRegistry({
getSession: async (): Promise<RegistrySession> => {
thunk.asked += 1;
try {
const s = session ?? (await sessionReady!);
const answer = { sessionId: s.sessionId, privateStoreId: s.privateStoreId };
const answer = await sharedWalletSession();
thunk.answered += 1;
return answer;
} catch (refusal) {
@@ -114,26 +137,20 @@ function bootTheApplication(identifier: string) {
}
},
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
});
inBrokerIframe(`${APP}?ng-id=${encodeURIComponent(identifier)}`);
let delegated!: Promise<unknown>;
sessionReady = new Promise<RegistrySession>((resolve) => {
arrive = resolve;
delegated = init(() => {}, true, []) as Promise<unknown>;
});
void sessionReady.then((s) => { session = s; });
const delegated = init(() => {}, true, []) as Promise<unknown>;
return { thunk, delegated };
}
test("settling the identity never asks the application for a session", async () => {
test("settling the identity never reaches for a session", async () => {
// The session-free half, taken at its word. `init()` awaits settling and nothing else, so
// by the time it has delegated, the consumer's thunk must not have been called ONCE —
// not called-and-blocked, not called-and-refused, not called at all. Anything the gate
// reaches that ends up at `getSession` is outside the half it claims to be.
// by the time it has delegated, the session must not have been asked for ONCE — not
// asked-and-blocked, not asked-and-refused, not asked at all. Anything the gate reaches
// that ends up at the session is outside the half it claims to be.
const app = bootTheApplication("bob");
await app.delegated;
@@ -146,7 +163,7 @@ test("signing in does not come back until the connection work has reached a live
// The consequence, from the application's side. `ensureIdentity()` promises that what was
// shared with you is readable when it resolves; it can only keep that promise by having
// restored and drained, and both begin by resolving the account — which needs the
// session. So a run that resolved without the thunk ever ANSWERING did no such work,
// session. So a run that resolved without the session ever ANSWERING did no such work,
// whatever it reported. That is exactly the state Bob's page was in.
const app = bootTheApplication("bob");
await app.delegated;
@@ -365,7 +365,9 @@ test("injection: a malicious id still round-trips through the shim", async () =>
expect(back?.docPublic).toBe(rec.docPublic);
});
test("normalizeId defaults to trim when not provided", async () => {
// The package's own key rule applies when nothing substitutes one — it is the default of
// the internal wiring path, not something a consumer chooses (2026-08-12).
test("the identity key rule defaults to the package's when not provided", async () => {
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
+4 -3
View File
@@ -184,10 +184,11 @@ test("no published name says `wallet` where the target says `user`", () => {
// --- the invariant the internal contract flagged as a migration risk -------
test("a reserved-namespace key cannot be produced by a consumer's normalizeId", async () => {
test("a reserved-namespace key cannot be produced by the identity key rule", async () => {
// The reserved namespace hosts infrastructure accounts, and its guarantee is that no
// user id lands there. That guarantee is not the library's to make — `normalizeId` is
// injected by the consumer — so a careless one must be refused, not trusted. A
// user id lands there. The rule that produces the key does not enforce it — the
// package's own only trims, strips a leading `@` and lowercases, and the internal wiring
// path lets a suite substitute another — so a careless one must be refused, not trusted. A
// collision would key a user onto an infrastructure account: reads and writes on
// documents that are not theirs.
const { configureStoreRegistry, resetStoreRegistry } = await import("../src/shared-wallet/bootstrap");