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
+16 -1
View File
@@ -100,7 +100,11 @@ export type { NG } from "@ng-org/web";
*/
export { configure } from "./shared-wallet/bootstrap";
export type { EventuallyConfig } from "./shared-wallet/bootstrap";
export type { RegistrySession } from "./shared-wallet/account-registry";
// `RegistrySession` went with `getSession` (2026-08-12): it was published for exactly one
// reason — an application typed the session thunk it injected with it — and a published
// type whose signature is gone is a promise about the target that nothing keeps. Upstream a
// session is RETURNED, never assembled, so no consumer has a session shape to declare. It
// stays DEFINED in `shared-wallet/account-registry.ts`, where the library uses it.
// --- what this block deliberately does NOT contain --------------------------
//
@@ -116,6 +120,17 @@ export type { RegistrySession } from "./shared-wallet/account-registry";
// harness is allowed to do and an application is not.
// - `connectedUser` — `ensureIdentity` awaits it. Upstream, opening the session IS the
// connection; no application awaits a second call, so ours should not either.
//
// And two FIELDS of `EventuallyConfig` on 2026-08-12, for the same reason one call up:
//
// - `getSession` — the session is `init()`'s to deliver, and this package's to keep. An
// application that assembles one out of `session_id` / `private_store_id` / … is
// building a shape the target never asks for, and every consumer built the same one.
// - `normalizeId` — the identities being normalized are this package's own invention, so
// there was never a decision here for a consumer to make.
//
// Both are still substitutable through `shared-wallet/bootstrap`'s `configureStoreRegistry`
// — the suites and the e2e harness need it, and nothing published reaches it.
// ── the access gate — polyfill-era in substance, one line in the app ────────
// One call before the app renders. It shows a technical barrier only while the shared
@@ -84,6 +84,7 @@ import {
getConfig,
getCurrentUser,
getStoreRegistryDeps,
normalizeIdentityId,
} from "./bootstrap";
import { connectedUser } from "../emulated-verifier/connect";
import type { PrincipalId } from "../model/types";
@@ -94,17 +95,19 @@ import type { PrincipalId } from "../model/types";
* Not a detail: the identifier arrives from three places — typed at the gate, read from
* the URL after the broker round-trip, read from storage — and if any of them normalizes
* differently, that path keys onto a DIFFERENT virtual user. `@Erin` from the URL and
* `erin` typed at the gate must be one space, not two. So there is one normalizer, the
* injected one, and the gate borrows it rather than keeping its own `toLowerCase()`.
* `erin` typed at the gate must be one space, not two. So there is ONE normalizer the
* package's {@link normalizeIdentityId} — and the gate borrows whatever the registry is
* keying on rather than keeping its own `toLowerCase()`.
*
* Falls back to the library's own default when the registry is not configured yet, which
* is possible since the gate can run before anything else.
* Falls back to that same rule when the registry is not configured yet, which is possible
* since the gate can run before anything else. Not a second copy of it: the fallback and
* the default are the same function, so the two can no longer drift apart.
*/
function normalizeIdentity(raw: string): string {
try {
return getStoreRegistryDeps().normalizeId(raw);
} catch {
return raw.trim().replace(/^@/, "").toLowerCase();
return normalizeIdentityId(raw);
}
}
@@ -262,12 +262,13 @@ export interface RegistrySession {
function normalize(id: string): string {
const key = getStoreRegistryDeps().normalizeId(id);
// The reserved namespace's whole guarantee is that no user id can land in it, and
// that guarantee is NOT ours to make: `normalizeId` is injected by the consumer
// application, and the library's own default only trims — nothing stops a caller
// from passing an id that already starts with the sentinel. A collision here is not
// a cosmetic clash: a user would key onto an infrastructure account and read or
// write documents that are not theirs. So it is checked rather than assumed.
// The reserved namespace's whole guarantee is that no user id can land in it, and the
// rule that produces the key does not enforce it: the package's own `normalizeIdentityId`
// trims, strips a leading `@` and lowercases — nothing stops an id that already starts
// with the sentinel — and the internal wiring path lets a suite or the e2e harness
// substitute another rule entirely. A collision here is not a cosmetic clash: a user
// would key onto an infrastructure account and read or write documents that are not
// theirs. So it is checked rather than assumed.
if (isReserved(key)) {
throw new Error(
"[ng-eventually] account-registry: `normalizeId` produced a key inside the " +
@@ -24,17 +24,43 @@ import { resetPublicStoreFetches } from "../emulated-verifier/public-store";
import { setAccessLog } from "./access-log";
import { inspectOutbox } from "./outbox-log";
import { startConnect } from "../emulated-verifier/connect";
import { resetSharedWalletSession, sharedWalletSession } from "./session";
/**
* Consumer-injected dependencies of the storeRegistry (polyfill-era). The
* registry itself is generic (it knows only native scopes); the consumer wires
* up how to reach the shared-wallet session and how to normalize an identity id
* used as the shim key. Removed at migration along with the whole shim.
* How an identity id becomes the key a virtual user is filed under.
*
* The package's rule, not a consumer's choice (2026-08-12): `@Alice`, `alice ` and `ALICE`
* are ONE person's space. It has to be one rule, because the identifier arrives from three
* places — typed at the barrier, read back from the URL after the broker round-trip, read
* from storage — and any of them keying differently silently opens a SECOND space whose
* documents the first one cannot see.
*
* **NO COUNTERPART.** Upstream there is nothing to normalize: a wallet holds one user, and
* `session_start` takes the id the wallet gives. This exists only because one wallet here
* hosts everybody, and it disappears with them.
*/
export function normalizeIdentityId(id: string): string {
return id.trim().replace(/^@/, "").toLowerCase();
}
/**
* Dependencies of the storeRegistry (polyfill-era) — INTERNAL, and no longer an
* application's business.
*
* The registry itself is generic (it knows only native scopes); these two say how to reach
* the shared-wallet session and how to key an identity in the shim. An application used to
* supply both through {@link EventuallyConfig}; the package owns them now (2026-08-12), and
* this interface is the substitution path its OWN suites use — the unit fakes need a
* synchronous session, and the e2e harness holds one the broker gave it directly.
*
* Internal is carried by the module, not by a comment: nothing here is re-exported from
* `src/index.ts`, so the published entry cannot reach it. Removed at migration with the
* whole shim.
*/
export interface StoreRegistryDeps {
/** Resolve the current shared-wallet session (id + private-store anchor). */
getSession: () => Promise<RegistrySession>;
/** Normalize an identity id for shim keying. Default: trim (identity-ish). */
/** Normalize an identity id for shim keying. Default: {@link normalizeIdentityId}. */
normalizeId?: (id: string) => string;
/**
* POINTER micro-guard budget. The account records now live in a subscribable
@@ -60,20 +86,29 @@ export interface StoreRegistryDeps {
* "here is what you need to run", and two bootstrap calls is one more thing to delete
* at migration than there needs to be. Merged 2026-08-07; the registry's own wiring
* function stays internal.
*
* ── Two fields left on 2026-08-12, and what left with them ────────────────
* `getSession` and `normalizeId` were published, and both made an application build
* something the target never asks it for:
*
* - `getSession` — upstream a session is RETURNED (`init()`'s callback delivers it,
* `session_start` hands one back). An application that has to ASSEMBLE one out of
* `session_id` / `private_store_id` / … is coding against a shape with no successor.
* The package captures the event instead ({@link ../surface/lifecycle}.init) and holds
* the session ({@link ./session}).
* - `normalizeId` — the identities it normalizes are this package's own invention (one
* wallet, many virtual users). There is nothing upstream to normalize, so there was
* nothing for a consumer to decide; see {@link normalizeIdentityId}.
*
* Every consumer wrote the same plumbing for both, and the reference integration's copy
* carried two defects at once. Both remain substitutable through {@link StoreRegistryDeps},
* which the published entry cannot reach.
*/
export interface EventuallyConfig {
/** The REAL `@ng-org/web` `ng` (injected to avoid a hard import / alias loop). */
ng: NgLike;
/** The REAL `@ng-org/orm` `useShape`. */
useShape: UseShapeLike;
/**
* Resolve the wallet session. Shared-wallet only: upstream the session IS the user, so
* there is nothing to inject — an application opens its wallet and the SDK knows.
* A thunk, so it may be given before the session exists.
*/
getSession?: () => Promise<RegistrySession>;
/** Normalize an identity id for shim keying. Default: trim. */
normalizeId?: (id: string) => string;
/**
* POINTER micro-guard budget — see {@link StoreRegistryDeps.pointerGuard}. Left unset
* → a single read, which keeps the synchronous unit fakes fast.
@@ -138,16 +173,14 @@ export function configure(c: EventuallyConfig): void {
// skip the barrier on a top-level page, which is the one thing it exists to prevent.
currentUser = null;
setAccessLog(c.debugAccessLog ?? false);
// The session wiring is part of the same act — see {@link EventuallyConfig}. Omitted
// only by unit suites that never touch the registry; those get the same
// "must be configured" error they got before, from `getStoreRegistryDeps`.
if (c.getSession) {
configureStoreRegistry({
getSession: c.getSession,
...(c.normalizeId ? { normalizeId: c.normalizeId } : {}),
...(c.pointerGuard ? { pointerGuard: c.pointerGuard } : {}),
});
}
// Wire the registry onto what the PACKAGE owns. Unconditional since 2026-08-12: there is
// no longer anything for a caller to supply here, so there is no longer a case where
// configuring the library leaves the registry half-wired. A suite that needs its own
// session or key rule calls `configureStoreRegistry` AFTER this, and overrides it.
configureStoreRegistry({
getSession: sharedWalletSession,
...(c.pointerGuard ? { pointerGuard: c.pointerGuard } : {}),
});
}
/** @internal — used by the SDK-shaped wrappers to reach the injected real SDK. */
@@ -157,10 +190,13 @@ export function getConfig(): EventuallyConfig {
}
/** Reset the injected config back to un-configured (mainly for tests, so a
* suite that calls configure() can restore the not-configured guard state). */
* suite that calls configure() can restore the not-configured guard state).
* The captured session goes with it: it arrived through the config's `init`, so leaving
* it behind would hand the next `configure()` the previous one's session. */
export function resetConfig(): void {
cfg = null;
currentUser = null;
resetSharedWalletSession();
}
/**
@@ -189,7 +225,7 @@ export function configureStoreRegistry(deps: StoreRegistryDeps): void {
};
registryDeps = {
getSession,
normalizeId: deps.normalizeId ?? ((id: string) => id.trim()),
normalizeId: deps.normalizeId ?? normalizeIdentityId,
// Default: single read (no re-read). Only the real-broker consumers (app + e2e)
// opt into the bounded pointer micro-guard; unit fakes stay synchronous.
pointerGuard: deps.pointerGuard ?? { attempts: 1 },
@@ -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();
}
+25 -1
View File
@@ -22,6 +22,7 @@
import { getConfig } from "../shared-wallet/bootstrap";
import { settleIdentity } from "../shared-wallet/access-gate";
import { captureSession } from "../shared-wallet/session";
/**
* Forwards to the real `@ng-org/web` `init`, once the identifier is in the address bar.
@@ -38,11 +39,34 @@ import { settleIdentity } from "../shared-wallet/access-gate";
*
* The "not injected" error stays SYNCHRONOUS: it is a wiring mistake rather than a runtime
* one, and it threw synchronously before this forwarder had anything to await.
*
* ── It also LISTENS on the way through, and that is the one argument it touches ──
* The real `init` delivers the session by calling its callback with
* `{ status: "loggedin", session }` — once, and it is the only channel that ever produces
* one (`@ng-org/web` `dist/ngweb.js:113-137`, VERIFIED). Until 2026-08-12 every application
* had to catch that event itself and hand the library a thunk unwrapping it, which is a
* shape the target never asks anyone to build and which two consumers in a row got wrong.
*
* So the callback in position 0 is WRAPPED: the wrapper reads the event, keeps the session
* (`shared-wallet/session.ts`), and then calls the caller's callback with that same event,
* unchanged and un-narrowed. Nothing else about the call moves — the remaining arguments and
* the return value pass straight through, and the caller's callback still sees exactly what
* the real `init` sent it. It is the one place a wrapper can be, because it is the one place
* that knows both what the caller asked and what the SDK will answer.
*
* A caller that passes NO callback is the same act with nobody listening — upstream accepts
* it (`callback: Function | null`, and the call site is guarded). The wrapper still goes in,
* so the package gets its session either way, and calls nothing afterwards.
*/
export function init(...args: any[]): any {
const f = getConfig().init;
if (!f) throw new Error("[ng-eventually] init() not injected — pass it to configure()");
return settleIdentity().then(() => f(...args));
const [callback, ...rest] = args;
const listen = (event: unknown): unknown => {
captureSession(event);
return typeof callback === "function" ? callback(event) : undefined;
};
return settleIdentity().then(() => f(listen, ...rest));
}
/** Forwards to the real `@ng-org/orm` `initNg` (ORM signals). */