diff --git a/.project/concepts/app-contract/_debt.md b/.project/concepts/app-contract/_debt.md deleted file mode 100644 index a846688..0000000 --- a/.project/concepts/app-contract/_debt.md +++ /dev/null @@ -1,16 +0,0 @@ -# Doc-debt — app-contract - -> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file. -> One block = one "big change": `why` + `files` + `verify` (leaves to review). - -## The broker redirect is stated as the application's obligation, and it is not - -**why** — `contract_polyfill-surface.md` lists, under `### Deployment requirements`, that the application must "be opened through the broker redirect". No such obligation belongs to the caller: the redirect is a step of the shared-wallet sign-in, which lives in this package (`access-gate.ts`, "the whole shared-wallet sign-in, moved out of consumer applications"). An application carrying it would have to know there is a broker, an iframe, and a redirect — three things the target SDK will never show it, and three things it would have to delete at migration. - -The clause was written from an absent implementation: nothing in `src/` navigates to the redirect today, and that gap was recorded as a division of roles. `rule_no-divergence-from-nextgraph` forbids exactly that inference. - -**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/.project/concepts/app-contract/contract_polyfill-surface.md b/.project/concepts/app-contract/contract_polyfill-surface.md index 28729f0..ade9bc7 100644 --- a/.project/concepts/app-contract/contract_polyfill-surface.md +++ b/.project/concepts/app-contract/contract_polyfill-surface.md @@ -85,7 +85,7 @@ export interface Deposit { from: PrincipalId | null; payload: unknown; ts: numbe // ── the wrapped SDK objects ────────────────────────────────────────────── export const ng: Record; // call this instead of the `ng` passed to `configure` -export function init(...args: any[]): any; +export function init(...args: any[]): any; // likewise — not the `init` passed to `configure` export function initNg(...args: any[]): any; ``` diff --git a/docs/api-contract.md b/docs/api-contract.md index be0403e..48a8729 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -60,7 +60,7 @@ So an application's whole bootstrap is `configure({ … })` plus `await ensureId ### Today — `@ng-eventually/polyfill` ```ts -// lifecycle.ts:11 — forwards to the real @ng-org/web init injected at configure() +// lifecycle.ts:11 — settles the identity, then forwards to the real @ng-org/web init injected at configure() export function init(...args: any[]): any; // lifecycle.ts:18 — forwards to the real @ng-org/orm initNg injected at configure() export function initNg(...args: any[]): any; diff --git a/packages/polyfill/src/shared-wallet/access-gate.ts b/packages/polyfill/src/shared-wallet/access-gate.ts index 5f27d1d..dbdd781 100644 --- a/packages/polyfill/src/shared-wallet/access-gate.ts +++ b/packages/polyfill/src/shared-wallet/access-gate.ts @@ -56,10 +56,10 @@ */ import { + adoptCurrentUser, getConfig, getCurrentUser, getStoreRegistryDeps, - setCurrentUser, } from "./bootstrap"; import { connectedUser } from "../emulated-verifier/connect"; import type { PrincipalId } from "../model/types"; @@ -268,9 +268,20 @@ let settling: Promise | null = null; * 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. * - * 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. + * So nothing reachable from here may CALL `getSession()` — not merely "may not await it". + * That distinction is the one this half got wrong and shipped on. `setCurrentUser` looked + * safe because it fires the connection work without awaiting it; but firing still RUNS it, + * and its first line asks `resolveAccount`, which awaits the consumer's session thunk. The + * thunk cannot answer here — settling runs before `init()` has been delegated to, and the + * reference application builds its `sessionReady` promise around that very `init()` call, + * so at that instant the promise does not exist yet. The thunk threw, `resolveAccount` + * answered null, the run abandoned before restoring a single cap, and the `connectedUser()` + * that {@link ensureIdentity} awaits JOINED that abandoned run rather than doing the work + * (`emulated-verifier/connect.ts:56`, `:84`). Silently: a user simply could not read what + * had been shared with it. + * + * Hence {@link adoptCurrentUser} below, which records who is acting and stops there. The + * connecting is {@link ensureIdentity}'s, where it is AWAITED and where a session exists. * * ── One barrier, however many callers ──────────────────────────────────── * Settling now has TWO entry points — an application's `init()` and its @@ -316,7 +327,7 @@ async function resolveIdentity(): Promise { // Even though `storedIdentity()` just read it: what it read may have come from THIS // partition's storage, which the round-trip does not carry. The address bar does. rememberIdentity(known); - setCurrentUser(known); + adoptCurrentUser(known); return known; } @@ -339,7 +350,7 @@ async function resolveIdentity(): Promise { const chosen = await askForIdentity(cfg); const normalized = normalizeIdentity(chosen); rememberIdentity(normalized); - setCurrentUser(normalized); + adoptCurrentUser(normalized); return normalized; } @@ -379,15 +390,20 @@ export async function ensureIdentity(): Promise { } /** - * Wait for the connection work `setCurrentUser` fires — restoring what others shared - * with this user, draining its inboxes — before this call resolves. + * Do the connection work — restoring what others shared with this user, draining its + * inboxes — and do not resolve until it has actually run. * - * **Not a convenience: a correctness fix, found by the applicative e2e.** Setting an - * identity FIRES that work and does not wait for it. An application that rendered on - * `ensureIdentity()` alone could read a note someone had just shared with it as + * **Not a convenience: a correctness fix, found by the applicative e2e.** An application + * that rendered without it could read a note someone had just shared with it as * unreadable — which looks like a permission problem and is a timing one, in the one * place where the difference is invisible (nothing throws; a read is simply empty). * + * It is now the ONLY thing that connects the settled identity, and that is deliberate. + * Settling used to fire this work too, in the background, before a session could exist — + * so the run poisoned itself and this await joined it instead of doing the job. One + * connection, started where the session is reachable and awaited by whoever asked to sign + * in, is the shape that cannot go wrong (see {@link settleIdentity}). + * * Doing it here rather than exposing `connectedUser()` is the point: the awaited thing * has NO counterpart upstream — there, opening the session IS the connection, and no * application awaits a second call. So the polyfill absorbs it, and an application's diff --git a/packages/polyfill/src/shared-wallet/bootstrap.ts b/packages/polyfill/src/shared-wallet/bootstrap.ts index 8a9cf03..f24fd03 100644 --- a/packages/polyfill/src/shared-wallet/bootstrap.ts +++ b/packages/polyfill/src/shared-wallet/bootstrap.ts @@ -209,24 +209,55 @@ export function resetStoreRegistry(): void { } /** - * Set the current identity id — who the SDK is reading/writing as. In the target - * this is the wallet user established at wallet-import time; here the consumer - * relays that id through this call so the read filter and the inbox `from` know - * who is acting. Passing `null` clears it (no identity yet, e.g. during startup). + * Record who is acting — and touch nothing else. Answers whether it CHANGED, which is + * what decides whether there is any connecting to do. + * + * Split out of {@link setCurrentUser} so that the two things it did — naming the identity + * and reaching for the session on its behalf — can be asked for separately. Only the + * session-FREE half of signing in uses this one ({@link ./access-gate}.settleIdentity), + * and that is the whole of why it exists: see the warning on {@link setCurrentUser}. + * + * @internal Never published. A consumer names its identity through the access gate. */ -export function setCurrentUser(id: PrincipalId | null): void { +export function adoptCurrentUser(id: PrincipalId | null): boolean { const changed = currentUser !== id; currentUser = id; + return changed; +} + +/** + * Set the current identity id — who the SDK is reading/writing as — **and connect it**. + * In the target this is the wallet user established at wallet-import time; here the + * consumer relays that id through this call so the read filter and the inbox `from` know + * who is acting. Passing `null` clears it (no identity yet, e.g. during startup). + * + * ── This call REACHES THE SESSION, and a caller must know it ────────────── + * "Fire-and-forget" says nothing about whether the session is touched — only about who + * waits. The fired work asks `resolveAccount` on its first line, which awaits the + * consumer's `getSession` thunk (`connect.ts` → `account-registry.ts:280`). So this + * setter is unusable at any moment the session cannot yet answer, and calling it there is + * not merely wasteful — it **poisons** the work: the thunk throws, `resolveAccount` + * returns null, the run abandons before restoring anything, and it is that abandoned run + * that the next `connectedUser()` JOINS instead of doing the work (`connect.ts:56`). + * Nothing anywhere throws; a user simply cannot read what was shared with it. + * + * That is not hypothetical — it shipped. The reference application calls the polyfill's + * `init()` from inside the executor that is still building its own `sessionReady` + * promise, so the thunk could not answer, by construction. Whoever settles an identity + * before a session can exist wants {@link adoptCurrentUser} and an awaited + * `connectedUser()` later. + * + * Gated on the registry being configured, and that is not a test convenience: an + * identity set before the registry is wired has nothing to restore and no inbox to + * reach. The consumer's real sequence is `configureStoreRegistry` then `setCurrentUser`; + * anything else can call `connectedUser()` explicitly. + */ +export function setCurrentUser(id: PrincipalId | null): void { + const changed = adoptCurrentUser(id); // Connecting a user is what triggers inbox processing — the library's job, not // the app's. Fire-and-forget: this setter is synchronous and every consumer calls // it from synchronous code, so the work announces itself through the cap // registry's change signal instead of making callers await. See `connect.ts`. - // - // Gated on the registry being configured, and that is not a test convenience: an - // identity set before the session resolves has nothing to restore and no inbox to - // reach, so firing would be I/O that can only fail. The consumer's real sequence - // is `configureStoreRegistry` then `setCurrentUser`; anything else can call - // `connectedUser()` explicitly. if (changed && id !== null && registryDeps !== null) startConnect(); } diff --git a/packages/polyfill/test/sign-in-connects.test.ts b/packages/polyfill/test/sign-in-connects.test.ts new file mode 100644 index 0000000..653a356 --- /dev/null +++ b/packages/polyfill/test/sign-in-connects.test.ts @@ -0,0 +1,149 @@ +/** + * Signing in CONNECTS — and settling, on its own, does not reach for the session. + * + * These two are one subject seen from both ends, and the applicative e2e is what found it: + * after Alice shared a protected note with Bob, Bob reopened the application and read + * "(illisible)". Nothing threw anywhere. It cost minutes of real browser and real broker to + * see, so it is pinned here for the price of a millisecond. + * + * ── The mechanism, so a future reader can judge a change against it ──────── + * Settling the identity called `setCurrentUser`, which FIRES the connection work. Firing is + * not awaiting, but it is still running: the work's first act is `resolveAccount`, which + * awaits the consumer's `getSession` thunk. Settling happens before `init()` has been + * delegated to — and the reference application builds its `sessionReady` promise AROUND + * that very `init()` call, so at that instant the promise it would wait on does not exist. + * The thunk could not answer. It threw, `resolveAccount` answered null, and the run + * abandoned before restoring a single capability — after registering itself as the + * connection in flight. The `connectedUser()` that `ensureIdentity()` awaits then JOINED + * that abandoned run instead of doing the work, and resolved having done nothing. The + * application rendered, and Bob held no key to a note that had been shared with him. + * + * So what is pinned is not "the calls happen in this order" — a regression would still call + * them in order. It is what each half TOUCHES: settling must not call the session thunk at + * all, and signing in must not come back until the thunk has actually answered. + */ +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 APP = "https://app.example/"; + +/** A localStorage double — the real one is absent in `bun test`. */ +function fakeStorage() { + const map = new Map(); + 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 page whose address bar MOVES on `replaceState`, as the gate and `init()` both rely on. */ +function inBrowser(url: string): void { + let href = url; + Object.assign(globalThis, { + location: { + get href(): string { return href; }, + get search(): string { return new URL(href).search; }, + }, + history: { replaceState: (_s: unknown, _t: string, next: string): void => void (href = next) }, + localStorage: fakeStorage(), + }); +} + +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 reference application's bootstrap, in its real order (`examples/notebook/app.ts`): + * `configure()` first, then `init()` called from INSIDE the executor that builds the very + * promise the session thunk waits on. + * + * That shape is not a curiosity of this fixture, it is what the e2e serves: in the bundle, + * `sessionReady` is a hoisted `var`, so while the executor runs it is still `undefined` and + * the thunk has nothing to wait on. It therefore **refuses** rather than blocking — and an + * application is entitled to refuse, since at that moment there is genuinely nothing to + * return. A thunk that merely blocked would hide the whole defect, which is why the double + * here refuses exactly as the application's does. + * + * The injected `init` resolves the session, as the real one does through its callback: + * before it is called, nothing in the system can make the session exist. + */ +function bootTheApplication(identifier: string) { + /** What the consumer's thunk was asked, and what it was able to say. */ + const thunk = { asked: 0, answered: 0, refused: 0 }; + let session: RegistrySession | null = null; + // Deliberately assigned AFTER `init()` runs — see above. `undefined` until then. + let sessionReady: Promise | undefined; + let arrive!: (s: RegistrySession) => void; + + configure({ + ng: {} as never, // no store behind it: the assertions are about what is REACHED + useShape: (() => {}) as never, + init: (..._args: unknown[]): Promise => { + arrive({ sessionId: "s", privateStoreId: "did:ng:o:private" }); + return Promise.resolve("delegated"); + }, + getSession: async (): Promise => { + thunk.asked += 1; + try { + const s = session ?? (await sessionReady!); + const answer = { sessionId: s.sessionId, privateStoreId: s.privateStoreId }; + thunk.answered += 1; + return answer; + } catch (refusal) { + thunk.refused += 1; + throw refusal; + } + }, + normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(), + sharedWallet: { fileUrl: "/w.ngw", password: "pw" }, + }); + + inBrowser(`${APP}?ng-id=${encodeURIComponent(identifier)}`); + + let delegated!: Promise; + sessionReady = new Promise((resolve) => { + arrive = resolve; + delegated = init(() => {}, true, []) as Promise; + }); + void sessionReady.then((s) => { session = s; }); + + return { thunk, delegated }; +} + +test("settling the identity never asks the application for a session", async () => { + // The session-free half, taken at its word. `init()` awaits settling and nothing else, so + // by the time it has delegated, the consumer's thunk must not have been called ONCE — + // not called-and-blocked, not called-and-refused, not called at all. Anything the gate + // reaches that ends up at `getSession` is outside the half it claims to be. + const app = bootTheApplication("bob"); + + await app.delegated; + + expect(app.thunk.asked).toBe(0); + expect(app.thunk.refused).toBe(0); +}); + +test("signing in does not come back until the connection work has reached a live session", async () => { + // The consequence, from the application's side. `ensureIdentity()` promises that what was + // shared with you is readable when it resolves; it can only keep that promise by having + // restored and drained, and both begin by resolving the account — which needs the + // session. So a run that resolved without the thunk ever ANSWERING did no such work, + // whatever it reported. That is exactly the state Bob's page was in. + const app = bootTheApplication("bob"); + await app.delegated; + + await ensureIdentity(); + + expect(app.thunk.answered).toBeGreaterThanOrEqual(1); + expect(app.thunk.refused).toBe(0); +});