fix: régler l'identité ne demande pas de session, se connecter oui
init() de @ng-org/web redirige vers le broker en première instruction, dès qu'on est en tête. L'application appelait donc init() au chargement du module, la page partait, et ensureIdentity() ne s'exécutait jamais : la barrière n'apparaissait pas, ?ng-id= restait absent de l'URL remise au broker, et un primo-arrivant se retrouvait devant la page de connexion sans portefeuille et sans moyen d'en obtenir un — sans la moindre erreur. Appeler ensureIdentity() avant init() ne marchait pas non plus : il attend la session, que seul le callback d'init() résout. Cycle vérifié empiriquement. La cause n'était ni l'ordre ni la redirection, mais une confusion dans ensureIdentity() entre deux actes de nature différente — régler qui est l'utilisateur (barrière, URL, stockage : aucune session) et se connecter (session requise). settleIdentity() porte le premier ; le wrapper init() du polyfill l'attend avant de déléguer. L'invariant d'ordre est ainsi porté par la composition, pas par une consigne d'ordre d'appel que personne ne lit. Piège trouvé et épinglé en écrivant les tests : init() et ensureIdentity() dans le même tick montaient deux barrières, l'utilisateur répondait à l'une et l'autre ne se résolvait jamais. Le règlement en vol est désormais partagé.
This commit is contained in:
@@ -51,7 +51,8 @@
|
||||
* it hands over `window.location.href` AS IT FINDS IT. So the one thing this module owes
|
||||
* the round-trip is that `?ng-id=` is already in the address bar when `init()` reads it —
|
||||
* on every path, whatever settled the identity. Hence {@link rememberIdentity} on all
|
||||
* three, and hence `ensureIdentity()` before `init()` in an application's bootstrap.
|
||||
* three, and hence {@link settleIdentity}, which the polyfill's own `init()` awaits before
|
||||
* it delegates (`surface/lifecycle.ts`) — so no application has to know that order.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -245,37 +246,68 @@ function askForIdentity(cfg: SharedWalletConfig): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
/** The settling in flight, shared by every caller — see {@link settleIdentity}. */
|
||||
let settling: Promise<PrincipalId> | null = null;
|
||||
|
||||
/**
|
||||
* Ensure an identity is set for this session, showing the gate only if one is missing.
|
||||
* Settle WHO the user is — and touch nothing else.
|
||||
*
|
||||
* The application calls this once, before it renders. It does NOT pass an identifier:
|
||||
* naming one is the step that will disappear, so it must not appear in the signature —
|
||||
* the day the wallet supplies the identity, this resolves without showing anything and
|
||||
* the caller's code is unchanged.
|
||||
* Resolve the identifier (URL, then storage, then the barrier), normalize it, and leave it
|
||||
* where the round-trip will find it. It needs a URL, a storage and a DOM; it needs **no
|
||||
* session**, and that absence is the entire reason this half exists on its own.
|
||||
*
|
||||
* A returning user never sees the gate: the identifier survives the broker round-trip in
|
||||
* the URL, and a plain reload finds it in storage.
|
||||
* ── Why signing in had to be cut in two ───────────────────────────────────
|
||||
* The two things `ensureIdentity()` does have opposite needs. Settling needs the page;
|
||||
* connecting needs the SESSION, which only `init()`'s callback establishes. Fused, they
|
||||
* made the ordering unsolvable: the identifier must reach the address bar BEFORE `init()`
|
||||
* reads it, yet calling `ensureIdentity()` first hangs — the connection half awaits
|
||||
* `getSession()`, and the session is what `init()` is on its way to open
|
||||
* (`emulated-verifier/connect.ts:84` → `account-registry.ts:592` → `session()` →
|
||||
* the consumer's thunk → the promise `init()`'s callback resolves).
|
||||
*
|
||||
* **Call it before `init()`.** Whatever settled the identity — typed, stored, or set by
|
||||
* the caller — this leaves `?ng-id=` in the address bar, and `init()` hands the broker the
|
||||
* address bar as it finds it. The other order signs the user in and then sends the
|
||||
* round-trip off without the identifier, which fails silently (see
|
||||
* {@link rememberIdentity}). It cannot be enforced from inside `init()`: this call awaits
|
||||
* the connection work, which awaits the session, which `init()` is what establishes.
|
||||
* Split, the order stops being an instruction a caller can get wrong: `init()` awaits THIS
|
||||
* half (`surface/lifecycle.ts`), which completes with no session in existence.
|
||||
*
|
||||
* **It RETURNS the identity it settled**, and that is not a convenience — it is the only
|
||||
* way an application can know who it is. Upstream the question does not arise: an app
|
||||
* passes `user_id` to `session_start(wallet_name, user_id)` (`@ng-org/web`), having got it
|
||||
* from the wallet it opened, so it holds its identity before the session exists. Here the
|
||||
* GATE chooses it, so the gate is what hands it back. Without this the example
|
||||
* application had to read the gate's own private storage key — a boundary no consumer
|
||||
* should be able to see, let alone depend on.
|
||||
* So nothing reachable from here may await `getSession()`. `setCurrentUser` is safe on
|
||||
* that count by construction — it FIRES the connection work without awaiting it
|
||||
* (`bootstrap.ts`), which is the property that keeps this half session-free.
|
||||
*
|
||||
* ── One barrier, however many callers ────────────────────────────────────
|
||||
* Settling now has TWO entry points — an application's `init()` and its
|
||||
* `ensureIdentity()` — and an application calls both, in the same tick
|
||||
* (`examples/notebook/app.ts`). Un-shared, each would mount its own barrier: the user
|
||||
* answers whichever is on top, the other stays pending forever, and the `init()` waiting on
|
||||
* it never hands the page to the broker. So a second caller JOINS the settling in flight
|
||||
* instead of asking again — the same reason `connectedUser()` keeps its work in flight
|
||||
* (`emulated-verifier/connect.ts`).
|
||||
*
|
||||
* @internal Not published: an application calls {@link ensureIdentity}, which is this plus
|
||||
* the connection work. Publishing the halves would invite a caller to sequence them, which
|
||||
* is the obligation this split removes.
|
||||
*/
|
||||
export async function ensureIdentity(): Promise<PrincipalId> {
|
||||
export function settleIdentity(): Promise<PrincipalId> {
|
||||
if (settling !== null) return settling;
|
||||
const run = resolveIdentity();
|
||||
settling = run;
|
||||
// Cleared on BOTH outcomes: a failure must stay retryable — a barrier nobody has answered
|
||||
// is not a settled identity — and clearing it here is why nothing has to reset it.
|
||||
const done = (): void => {
|
||||
if (settling === run) settling = null;
|
||||
};
|
||||
run.then(done, done);
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the identifier the three ways it can arrive — already set by the caller, read
|
||||
* from the page (URL, then storage), or typed at the barrier — and remember it on every
|
||||
* one of them ({@link rememberIdentity}), which is the path-independent part that the
|
||||
* round-trip depends on.
|
||||
*/
|
||||
async function resolveIdentity(): Promise<PrincipalId> {
|
||||
const already = getCurrentUser();
|
||||
if (already !== null) {
|
||||
rememberIdentity(already);
|
||||
await connected();
|
||||
return already;
|
||||
}
|
||||
|
||||
@@ -285,7 +317,6 @@ export async function ensureIdentity(): Promise<PrincipalId> {
|
||||
// partition's storage, which the round-trip does not carry. The address bar does.
|
||||
rememberIdentity(known);
|
||||
setCurrentUser(known);
|
||||
await connected();
|
||||
return known;
|
||||
}
|
||||
|
||||
@@ -309,10 +340,44 @@ export async function ensureIdentity(): Promise<PrincipalId> {
|
||||
const normalized = normalizeIdentity(chosen);
|
||||
rememberIdentity(normalized);
|
||||
setCurrentUser(normalized);
|
||||
await connected();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an identity is set for this session, showing the gate only if one is missing.
|
||||
*
|
||||
* The application calls this once, before it renders. It does NOT pass an identifier:
|
||||
* naming one is the step that will disappear, so it must not appear in the signature —
|
||||
* the day the wallet supplies the identity, this resolves without showing anything and
|
||||
* the caller's code is unchanged.
|
||||
*
|
||||
* A returning user never sees the gate: the identifier survives the broker round-trip in
|
||||
* the URL, and a plain reload finds it in storage.
|
||||
*
|
||||
* **It no longer has to be called before `init()`** — the order is structural now. Whatever
|
||||
* settles the identity leaves `?ng-id=` in the address bar, and `init()` hands the broker
|
||||
* the address bar as it finds it; settling after the hand-over sends the round-trip off
|
||||
* without the identifier, which fails silently (see {@link rememberIdentity}). That used to
|
||||
* be a rule an application had to follow, and following it hung — so the polyfill's `init()`
|
||||
* awaits {@link settleIdentity} itself. This call is safe in any position: after `init()` it
|
||||
* finds the identity already set, and alongside it — which is what an application's
|
||||
* bootstrap actually does — it JOINS the settling in flight rather than raising a second
|
||||
* barrier. Either way it goes on to the connection work, which is what it adds.
|
||||
*
|
||||
* **It RETURNS the identity it settled**, and that is not a convenience — it is the only
|
||||
* way an application can know who it is. Upstream the question does not arise: an app
|
||||
* passes `user_id` to `session_start(wallet_name, user_id)` (`@ng-org/web`), having got it
|
||||
* from the wallet it opened, so it holds its identity before the session exists. Here the
|
||||
* GATE chooses it, so the gate is what hands it back. Without this the example
|
||||
* application had to read the gate's own private storage key — a boundary no consumer
|
||||
* should be able to see, let alone depend on.
|
||||
*/
|
||||
export async function ensureIdentity(): Promise<PrincipalId> {
|
||||
const settled = await settleIdentity();
|
||||
await connected();
|
||||
return settled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the connection work `setCurrentUser` fires — restoring what others shared
|
||||
* with this user, draining its inboxes — before this call resolves.
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
/**
|
||||
* Lifecycle re-exports — SDK-shaped forwarders so the app imports `init` /
|
||||
* `initNg` from `@ng-eventually/polyfill` rather than from `@ng-org/*`. They
|
||||
* delegate to the REAL functions injected at `configure()`. Passthrough today;
|
||||
* a hook point later (e.g. opening the shared wallet on `init`).
|
||||
* delegate to the REAL functions injected at `configure()`.
|
||||
*
|
||||
* ── `init` is not a bare passthrough, and that is the point ────────────────
|
||||
* The real `init()` hands the page to the broker as its FIRST statement — a top-level
|
||||
* document is navigated to NextGraph's login, carrying `window.location.href` AS IT FINDS
|
||||
* IT. It knows nothing of `?ng-id=`, and it does not come back: everything the application
|
||||
* would have run after that line runs in a document that no longer exists. So an
|
||||
* application that signed in on the next line never signed in at all — the barrier never
|
||||
* showed, the identifier never reached the URL that crossed, and a first-time user landed
|
||||
* on a login with no wallet and no error anywhere.
|
||||
*
|
||||
* The obvious remedy — "call the gate first" — is an ordering rule written in prose, which
|
||||
* every consumer gets to get wrong once; and it deadlocks besides, because signing in used
|
||||
* to include waiting for the session `init()` is what opens. So the invariant is carried
|
||||
* HERE, by composition: this forwarder settles the identity, then delegates. A caller
|
||||
* cannot get the order wrong because a caller no longer takes part in it
|
||||
* (`shared-wallet/access-gate.ts`, {@link settleIdentity}).
|
||||
*/
|
||||
|
||||
import { getConfig } from "../shared-wallet/bootstrap";
|
||||
import { settleIdentity } from "../shared-wallet/access-gate";
|
||||
|
||||
/** Forwards to the real `@ng-org/web` `init`. */
|
||||
/**
|
||||
* Forwards to the real `@ng-org/web` `init`, once the identifier is in the address bar.
|
||||
*
|
||||
* Awaits {@link settleIdentity} — the session-free half of signing in — and NOT
|
||||
* `ensureIdentity()`, which also awaits the connection work, which awaits `getSession()`,
|
||||
* which resolves only from the session `init()` has not opened yet. That wait is the
|
||||
* deadlock, and avoiding it is what the split in `access-gate.ts` is for.
|
||||
*
|
||||
* A settling failure REJECTS rather than delegating. No shared wallet configured, or no DOM
|
||||
* to ask on, means there is no identifier to hand over — and handing the page to the broker
|
||||
* anyway IS the defect, a navigation the user cannot come back from. It fails at the call
|
||||
* the application made, where the cause is.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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 f(...args);
|
||||
return settleIdentity().then(() => f(...args));
|
||||
}
|
||||
|
||||
/** Forwards to the real `@ng-org/orm` `initNg` (ORM signals). */
|
||||
|
||||
Reference in New Issue
Block a user