fix: régler l'identité n'a plus le droit de réclamer une session
Le partage était cassé : Bob n'ouvrait pas le document qu'Alice venait de lui partager, sans erreur, juste « (illisible) ». Régression introduite en scindant ensureIdentity(). Chaîne observée aux sondes, pas déduite : settleIdentity() appelait setCurrentUser, qui déclenche startConnect(), qui va chercher la session via le thunk getSession de l'application. Or l'exemple appelle init() DEPUIS L'EXÉCUTEUR qui construit sessionReady — le thunk ne peut donc pas répondre, par construction. Il lève, resolveAccount rend null, l'exécution est abandonnée sans restauration ni drainage, mais s'est déjà enregistrée « en vol ». Le ensureIdentity() suivant rejoint cette exécution morte et se résout sans avoir rien fait. Avant la scission, rien n'appelait setCurrentUser pendant l'évaluation du module : la session existait, l'exécution était saine, et la rejoindre était sans danger. C'était bien une affaire de moment. bootstrap.ts scinde le setter : adoptCurrentUser enregistre qui agit, setCurrentUser reste « enregistrer + connecter » pour tous les autres appelants. La moitié sans session ne réclame donc plus de session, et se connecter redevient l'affaire du seul ensureIdentity(), attendu, là où une session existe. Ce que ça bloque, tracé avant de livrer : une application qui appellerait init() sans jamais appeler ensureIdentity() n'aurait plus de restauration en arrière- plan. Aucun appelant de ce genre n'existe, et avant la scission init() était un passthrough nu qui ne déclenchait rien — c'est une répartition rétablie, pas un comportement retiré. Reste connu, non corrigé : connectedUser mémorise toujours une exécution abandonnée. Le piège est documenté sur setCurrentUser.
This commit is contained in:
@@ -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)
|
||||
@@ -85,7 +85,7 @@ export interface Deposit { from: PrincipalId | null; payload: unknown; ts: numbe
|
||||
|
||||
// ── the wrapped SDK objects ──────────────────────────────────────────────
|
||||
export const ng: Record<string, any>; // 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;
|
||||
```
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PrincipalId> | 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<PrincipalId> {
|
||||
// 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<PrincipalId> {
|
||||
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<PrincipalId> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string>();
|
||||
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<RegistrySession> | 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<string> => {
|
||||
arrive({ sessionId: "s", privateStoreId: "did:ng:o:private" });
|
||||
return Promise.resolve("delegated");
|
||||
},
|
||||
getSession: async (): Promise<RegistrySession> => {
|
||||
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<unknown>;
|
||||
sessionReady = new Promise<RegistrySession>((resolve) => {
|
||||
arrive = resolve;
|
||||
delegated = init(() => {}, true, []) as Promise<unknown>;
|
||||
});
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user