diff --git a/.project/concepts/app-contract/_debt.md b/.project/concepts/app-contract/_debt.md index 0af879e..a846688 100644 --- a/.project/concepts/app-contract/_debt.md +++ b/.project/concepts/app-contract/_debt.md @@ -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. **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) diff --git a/examples/notebook/app.ts b/examples/notebook/app.ts index 92f875a..5dc3dc1 100644 --- a/examples/notebook/app.ts +++ b/examples/notebook/app.ts @@ -33,6 +33,7 @@ import { docs, ensureIdentity, inbox, + init, readUnion, storeRegistry, subscribeDoc, @@ -60,16 +61,6 @@ interface Note { // 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. -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({ ng: realNg, useShape: (() => {}) as never, // this example reads through `readUnion`, not the ORM @@ -90,6 +81,20 @@ configure({ 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 --------------------------------------------------------------- /** Write a new note in `scope`. The document is created, then filled. */ diff --git a/packages/polyfill/src/shared-wallet/access-gate.ts b/packages/polyfill/src/shared-wallet/access-gate.ts index fdb1601..5f27d1d 100644 --- a/packages/polyfill/src/shared-wallet/access-gate.ts +++ b/packages/polyfill/src/shared-wallet/access-gate.ts @@ -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 { }); } +/** The settling in flight, shared by every caller — see {@link settleIdentity}. */ +let settling: Promise | 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 { +export function settleIdentity(): Promise { + 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 { const already = getCurrentUser(); if (already !== null) { rememberIdentity(already); - await connected(); return already; } @@ -285,7 +317,6 @@ export async function ensureIdentity(): Promise { // 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 { 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 { + 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. diff --git a/packages/polyfill/src/surface/lifecycle.ts b/packages/polyfill/src/surface/lifecycle.ts index 3010184..30edb7a 100644 --- a/packages/polyfill/src/surface/lifecycle.ts +++ b/packages/polyfill/src/surface/lifecycle.ts @@ -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). */ diff --git a/packages/polyfill/test/lifecycle.test.ts b/packages/polyfill/test/lifecycle.test.ts new file mode 100644 index 0000000..bec7287 --- /dev/null +++ b/packages/polyfill/test/lifecycle.test.ts @@ -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 = {}) { + 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) { + 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 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((resolve) => { arrived = resolve; }); + const calls: { href: string; args: unknown[] }[] = []; + const returned = { itsOwnReturnValue: true }; + const injectedInit = (...args: unknown[]): Promise => { + 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, 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(p: Promise): Promise { + let timer: ReturnType | undefined; + const deadline = new Promise((_, 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); +});