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:
@@ -12,3 +12,5 @@ The clause was written from an absent implementation: nothing in `src/` navigate
|
|||||||
**files** — the clause is in the contract; the fix is in the package: `ensureIdentity()` triggers the redirect itself once the identity is settled and `?ng-id=` is written into the URL, and does nothing when already inside the iframe.
|
**files** — the clause is in the contract; the fix is in the package: `ensureIdentity()` triggers the redirect itself once the identity is settled and `?ng-id=` is written into the URL, and does nothing when already inside the iframe.
|
||||||
|
|
||||||
**verify** — `contract_polyfill-surface.md` (`### Deployment requirements` keeps only the wallet file/password and the `ensureIdentity()` await), `knowledge_what-an-app-deletes-at-migration.md` (the redirect is one more thing that evaporates), `_overview.md` if the surface list changes.
|
**verify** — `contract_polyfill-surface.md` (`### Deployment requirements` keeps only the wallet file/password and the `ensureIdentity()` await), `knowledge_what-an-app-deletes-at-migration.md` (the redirect is one more thing that evaporates), `_overview.md` if the surface list changes.
|
||||||
|
- TOUCHED packages/polyfill/src/surface/lifecycle.ts @2026-08-11 (session f93872b5-293a-4916-a353-181409a96d42)
|
||||||
|
- TOUCHED examples/notebook/app.ts @2026-08-11 (session f93872b5-293a-4916-a353-181409a96d42)
|
||||||
|
|||||||
+15
-10
@@ -33,6 +33,7 @@ import {
|
|||||||
docs,
|
docs,
|
||||||
ensureIdentity,
|
ensureIdentity,
|
||||||
inbox,
|
inbox,
|
||||||
|
init,
|
||||||
readUnion,
|
readUnion,
|
||||||
storeRegistry,
|
storeRegistry,
|
||||||
subscribeDoc,
|
subscribeDoc,
|
||||||
@@ -60,16 +61,6 @@ interface Note {
|
|||||||
// is the scaffolding, and at migration it goes: the app imports the real SDK, and the
|
// is the scaffolding, and at migration it goes: the app imports the real SDK, and the
|
||||||
// identity comes from the wallet instead of a barrier.
|
// identity comes from the wallet instead of a barrier.
|
||||||
|
|
||||||
let session: { session_id: string } | null = null;
|
|
||||||
const sessionReady = new Promise<{ session_id: string }>((resolve) => {
|
|
||||||
realInit((event: { status: string; session?: { session_id: string } }) => {
|
|
||||||
if (event.status === "loggedin" && event.session) {
|
|
||||||
session = event.session;
|
|
||||||
resolve(event.session);
|
|
||||||
}
|
|
||||||
}, true, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
configure({
|
configure({
|
||||||
ng: realNg,
|
ng: realNg,
|
||||||
useShape: (() => {}) as never, // this example reads through `readUnion`, not the ORM
|
useShape: (() => {}) as never, // this example reads through `readUnion`, not the ORM
|
||||||
@@ -90,6 +81,20 @@ configure({
|
|||||||
normalizeId: (id) => id.trim().replace(/^@/, "").toLowerCase(),
|
normalizeId: (id) => id.trim().replace(/^@/, "").toLowerCase(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The library's `init`, not the injected one — and this line is SDK-shaped, kept at
|
||||||
|
// migration. It settles the identity before handing the page to the broker, so the
|
||||||
|
// round-trip leaves with the identifier in the URL it carries. `realInit` called here
|
||||||
|
// would navigate away first, and the barrier would never show.
|
||||||
|
let session: { session_id: string } | null = null;
|
||||||
|
const sessionReady = new Promise<{ session_id: string }>((resolve) => {
|
||||||
|
init((event: { status: string; session?: { session_id: string } }) => {
|
||||||
|
if (event.status === "loggedin" && event.session) {
|
||||||
|
session = event.session;
|
||||||
|
resolve(event.session);
|
||||||
|
}
|
||||||
|
}, true, []);
|
||||||
|
});
|
||||||
|
|
||||||
// --- the acts ---------------------------------------------------------------
|
// --- the acts ---------------------------------------------------------------
|
||||||
|
|
||||||
/** Write a new note in `scope`. The document is created, then filled. */
|
/** Write a new note in `scope`. The document is created, then filled. */
|
||||||
|
|||||||
@@ -51,7 +51,8 @@
|
|||||||
* it hands over `window.location.href` AS IT FINDS IT. So the one thing this module owes
|
* 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 —
|
* 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
|
* 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 {
|
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:
|
* Resolve the identifier (URL, then storage, then the barrier), normalize it, and leave it
|
||||||
* naming one is the step that will disappear, so it must not appear in the signature —
|
* where the round-trip will find it. It needs a URL, a storage and a DOM; it needs **no
|
||||||
* the day the wallet supplies the identity, this resolves without showing anything and
|
* session**, and that absence is the entire reason this half exists on its own.
|
||||||
* the caller's code is unchanged.
|
|
||||||
*
|
*
|
||||||
* A returning user never sees the gate: the identifier survives the broker round-trip in
|
* ── Why signing in had to be cut in two ───────────────────────────────────
|
||||||
* the URL, and a plain reload finds it in storage.
|
* 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
|
* Split, the order stops being an instruction a caller can get wrong: `init()` awaits THIS
|
||||||
* the caller — this leaves `?ng-id=` in the address bar, and `init()` hands the broker the
|
* half (`surface/lifecycle.ts`), which completes with no session in existence.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* **It RETURNS the identity it settled**, and that is not a convenience — it is the only
|
* So nothing reachable from here may await `getSession()`. `setCurrentUser` is safe on
|
||||||
* way an application can know who it is. Upstream the question does not arise: an app
|
* that count by construction — it FIRES the connection work without awaiting it
|
||||||
* passes `user_id` to `session_start(wallet_name, user_id)` (`@ng-org/web`), having got it
|
* (`bootstrap.ts`), which is the property that keeps this half session-free.
|
||||||
* 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
|
* ── One barrier, however many callers ────────────────────────────────────
|
||||||
* application had to read the gate's own private storage key — a boundary no consumer
|
* Settling now has TWO entry points — an application's `init()` and its
|
||||||
* should be able to see, let alone depend on.
|
* `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();
|
const already = getCurrentUser();
|
||||||
if (already !== null) {
|
if (already !== null) {
|
||||||
rememberIdentity(already);
|
rememberIdentity(already);
|
||||||
await connected();
|
|
||||||
return already;
|
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.
|
// partition's storage, which the round-trip does not carry. The address bar does.
|
||||||
rememberIdentity(known);
|
rememberIdentity(known);
|
||||||
setCurrentUser(known);
|
setCurrentUser(known);
|
||||||
await connected();
|
|
||||||
return known;
|
return known;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,10 +340,44 @@ export async function ensureIdentity(): Promise<PrincipalId> {
|
|||||||
const normalized = normalizeIdentity(chosen);
|
const normalized = normalizeIdentity(chosen);
|
||||||
rememberIdentity(normalized);
|
rememberIdentity(normalized);
|
||||||
setCurrentUser(normalized);
|
setCurrentUser(normalized);
|
||||||
await connected();
|
|
||||||
return normalized;
|
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
|
* Wait for the connection work `setCurrentUser` fires — restoring what others shared
|
||||||
* with this user, draining its inboxes — before this call resolves.
|
* 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` /
|
* Lifecycle re-exports — SDK-shaped forwarders so the app imports `init` /
|
||||||
* `initNg` from `@ng-eventually/polyfill` rather than from `@ng-org/*`. They
|
* `initNg` from `@ng-eventually/polyfill` rather than from `@ng-org/*`. They
|
||||||
* delegate to the REAL functions injected at `configure()`. Passthrough today;
|
* delegate to the REAL functions injected at `configure()`.
|
||||||
* a hook point later (e.g. opening the shared wallet on `init`).
|
*
|
||||||
|
* ── `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 { 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 {
|
export function init(...args: any[]): any {
|
||||||
const f = getConfig().init;
|
const f = getConfig().init;
|
||||||
if (!f) throw new Error("[ng-eventually] init() not injected — pass it to configure()");
|
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). */
|
/** Forwards to the real `@ng-org/orm` `initNg` (ORM signals). */
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
/**
|
||||||
|
* `init()` — the hand-over to the broker, and the one thing that must happen before it.
|
||||||
|
*
|
||||||
|
* The real `@ng-org/web` `init()` navigates a top-level page to NextGraph's login as its
|
||||||
|
* FIRST statement, carrying `window.location.href` AS IT FINDS IT. Everything the
|
||||||
|
* application would have done afterwards runs in a document that no longer exists. So an
|
||||||
|
* application that signed in on the next line never signed in at all: no barrier, no
|
||||||
|
* identifier in the URL that crossed, and a first-time user parked on a login with no
|
||||||
|
* wallet and no error anywhere.
|
||||||
|
*
|
||||||
|
* The library's `init()` therefore settles the identity BEFORE it delegates. That order is
|
||||||
|
* carried by composition, not by an instruction to a caller — so what these tests pin is
|
||||||
|
* what the injected `init` OBSERVES when it is called, never the sequence of calls: a
|
||||||
|
* regression that delegated first would still call things in the right order, and would
|
||||||
|
* still hand over a bare URL.
|
||||||
|
*/
|
||||||
|
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 type { RegistrySession } from "../src/shared-wallet/account-registry";
|
||||||
|
|
||||||
|
const KEY = "ng-eventually:identity";
|
||||||
|
const APP = "https://app.example/";
|
||||||
|
|
||||||
|
/** A localStorage double — the real one is absent in `bun test`. */
|
||||||
|
function fakeStorage(initial: Record<string, string> = {}) {
|
||||||
|
const map = new Map(Object.entries(initial));
|
||||||
|
return {
|
||||||
|
getItem: (k: string) => map.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void map.set(k, v),
|
||||||
|
removeItem: (k: string) => void map.delete(k),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A browser page, faithful in the one way these assertions depend on: `location.search`
|
||||||
|
* tracks `href`, and `history.replaceState` MOVES `href` — as a real browser does, and as
|
||||||
|
* the real `init()` relies on when it reads the address bar. A double whose `replaceState`
|
||||||
|
* did nothing would let a broken `init()` pass.
|
||||||
|
*/
|
||||||
|
function inBrowser(url: string, storage: ReturnType<typeof fakeStorage>) {
|
||||||
|
let href = url;
|
||||||
|
const location = {
|
||||||
|
get href(): string { return href; },
|
||||||
|
get search(): string { return new URL(href).search; },
|
||||||
|
};
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
location,
|
||||||
|
history: { replaceState: (_s: unknown, _t: string, next: string): void => void (href = next) },
|
||||||
|
localStorage: storage,
|
||||||
|
});
|
||||||
|
return { location };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One element of the barrier, holding the listeners the gate attaches to it. */
|
||||||
|
function fakeElement() {
|
||||||
|
const handlers = new Map<string, ((e: unknown) => void)[]>();
|
||||||
|
return {
|
||||||
|
value: "",
|
||||||
|
disabled: false,
|
||||||
|
addEventListener(type: string, fn: (e: unknown) => void): void {
|
||||||
|
handlers.set(type, [...(handlers.get(type) ?? []), fn]);
|
||||||
|
},
|
||||||
|
focus(): void {},
|
||||||
|
fire(type: string): void {
|
||||||
|
for (const fn of handlers.get(type) ?? []) fn({});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A DOM that COUNTS the barriers mounted on it — the one thing this file needs from a
|
||||||
|
* document. It does not parse markup, so `querySelector` hands back the elements it was
|
||||||
|
* built with; what the markup contains is `access-gate.test.ts`'s subject and the e2e's.
|
||||||
|
*/
|
||||||
|
function fakeDom() {
|
||||||
|
const input = fakeElement();
|
||||||
|
const go = fakeElement();
|
||||||
|
let mounted = 0;
|
||||||
|
const root = {
|
||||||
|
innerHTML: "",
|
||||||
|
querySelector: (sel: string) => (sel === "input" ? input : sel === "button.go" ? go : null),
|
||||||
|
};
|
||||||
|
const host = {
|
||||||
|
setAttribute: (): void => {},
|
||||||
|
attachShadow: () => root,
|
||||||
|
remove: (): void => void (mounted -= 1),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
document: { createElement: () => host, body: { appendChild: (): void => void (mounted += 1) } },
|
||||||
|
/** How many barriers are on screen right now. */
|
||||||
|
get mounted(): number {
|
||||||
|
return mounted;
|
||||||
|
},
|
||||||
|
/** What a user does at the barrier: type an identifier, then press Entrer. */
|
||||||
|
submit(id: string): void {
|
||||||
|
input.value = id;
|
||||||
|
input.fire("input");
|
||||||
|
go.fire("click");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_GLOBALS = ["location", "localStorage", "history", "document"] as const;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setCurrentUser(null);
|
||||||
|
resetConfig();
|
||||||
|
resetStoreRegistry();
|
||||||
|
for (const name of PAGE_GLOBALS) Reflect.deleteProperty(globalThis, name);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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() {
|
||||||
|
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 });
|
||||||
|
arrived({ sessionId: "s", privateStoreId: "did:ng:o:p" });
|
||||||
|
return Promise.resolve(returned);
|
||||||
|
};
|
||||||
|
return { sessionReady, calls, injectedInit, returned, getSession: () => sessionReady };
|
||||||
|
}
|
||||||
|
|
||||||
|
function configured(wiring: ReturnType<typeof consumerWiring>, opts: { sharedWallet?: boolean } = {}) {
|
||||||
|
configure({
|
||||||
|
ng: {} as never,
|
||||||
|
useShape: (() => {}) as never,
|
||||||
|
init: wiring.injectedInit,
|
||||||
|
getSession: wiring.getSession,
|
||||||
|
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
|
||||||
|
...(opts.sharedWallet === false ? {} : { sharedWallet: { fileUrl: "/w.ngw", password: "pw" } }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a hang into a readable failure. NOT part of the mechanism — nothing in the library
|
||||||
|
* races or times out; this only says WHY a test stopped, instead of leaving the runner to
|
||||||
|
* report a bare timeout on a test whose whole subject is a deadlock.
|
||||||
|
*/
|
||||||
|
function within<T>(p: Promise<T>): Promise<T> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const deadline = new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(
|
||||||
|
() => reject(new Error("init() never resolved — it is waiting on the session it is itself supposed to open")),
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return Promise.race([p, deadline]).finally(() => clearTimeout(timer));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the injected `init` finds the identifier ALREADY in the address bar", async () => {
|
||||||
|
// The assertion is on what the delegate OBSERVES, not on call order: the real `init()`
|
||||||
|
// reads `window.location.href` at its first statement and carries it to the broker, so a
|
||||||
|
// wrapper that delegated first and settled after would satisfy any ordering check and
|
||||||
|
// still send the round-trip off bare. Identity from storage is the case that bites —
|
||||||
|
// storage does not cross the broker's partition, the URL does.
|
||||||
|
const wiring = consumerWiring();
|
||||||
|
configured(wiring);
|
||||||
|
inBrowser(APP, fakeStorage({ [KEY]: "hana" }));
|
||||||
|
|
||||||
|
await within(init(() => {}, true, []));
|
||||||
|
|
||||||
|
expect(wiring.calls.length).toBe(1);
|
||||||
|
expect(wiring.calls[0]!.href).toBe(APP + "?ng-id=hana");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it resolves though the session exists only AFTER it delegates — the cycle, pinned", async () => {
|
||||||
|
// The deadlock this split exists to prevent, reproduced exactly. Signing in fully
|
||||||
|
// (`ensureIdentity`) awaits the connection work → `resolveAccount` → `lookupAccount` →
|
||||||
|
// `session()` → the consumer's thunk → a promise only `init()`'s callback resolves. Await
|
||||||
|
// that here and nothing ever runs: `init` is never called, so the session never arrives,
|
||||||
|
// so the wait never ends. `init()` therefore awaits the SESSION-FREE half only.
|
||||||
|
const wiring = consumerWiring();
|
||||||
|
configured(wiring);
|
||||||
|
inBrowser(APP + "?ng-id=iris", fakeStorage());
|
||||||
|
|
||||||
|
await within(init(() => {}, true, []));
|
||||||
|
|
||||||
|
expect(wiring.calls.length).toBe(1);
|
||||||
|
// And the direction the cycle was broken in: delegating is what made the session exist.
|
||||||
|
await expect(within(wiring.sessionReady)).resolves.toMatchObject({ sessionId: "s" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a settling failure rejects — the page is not handed to the broker without an identity", async () => {
|
||||||
|
// Nothing known, and no shared wallet to hand the user one. Delegating anyway IS the
|
||||||
|
// defect: the page navigates to a login the user cannot complete and cannot come back
|
||||||
|
// from, silently. Failing at the call the application made is the only place the cause
|
||||||
|
// is still visible.
|
||||||
|
const wiring = consumerWiring();
|
||||||
|
configured(wiring, { sharedWallet: false });
|
||||||
|
inBrowser(APP, fakeStorage());
|
||||||
|
|
||||||
|
await expect(init(() => {}, true, [])).rejects.toThrow(/no shared wallet configured/i);
|
||||||
|
expect(wiring.calls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("`init` and `ensureIdentity` in the same tick raise ONE barrier, not two", async () => {
|
||||||
|
// Exactly the reference application's bootstrap: `init()` at module load, then `signIn()`
|
||||||
|
// awaiting `ensureIdentity()` (`examples/notebook/app.ts`). Both settle the identity now,
|
||||||
|
// so both reach the barrier in the same tick. Two barriers is not a cosmetic fault: the
|
||||||
|
// user answers whichever is on top, the other never resolves, and the `init()` waiting on
|
||||||
|
// it never hands the page to the broker — the application hangs before it ever loads.
|
||||||
|
const wiring = consumerWiring();
|
||||||
|
configured(wiring);
|
||||||
|
inBrowser(APP, fakeStorage());
|
||||||
|
const dom = fakeDom();
|
||||||
|
Object.assign(globalThis, { document: dom.document });
|
||||||
|
|
||||||
|
const delegated = init(() => {}, true, []);
|
||||||
|
const signedIn = ensureIdentity();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(dom.mounted).toBe(1);
|
||||||
|
dom.submit("kira");
|
||||||
|
await within(delegated);
|
||||||
|
expect(await within(signedIn)).toBe("kira");
|
||||||
|
expect(wiring.calls[0]!.href).toBe(APP + "?ng-id=kira");
|
||||||
|
});
|
||||||
|
|
||||||
|
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.
|
||||||
|
const wiring = consumerWiring();
|
||||||
|
configured(wiring);
|
||||||
|
inBrowser(APP, fakeStorage());
|
||||||
|
setCurrentUser("juno");
|
||||||
|
const callback = (): void => {};
|
||||||
|
|
||||||
|
const result = await within(init(callback, true, ["a-broker"]));
|
||||||
|
|
||||||
|
expect(wiring.calls[0]!.args).toEqual([callback, true, ["a-broker"]]);
|
||||||
|
expect(result).toBe(wiring.returned);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user