@oxy.so/core 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,8 +13,23 @@
13
13
  * 2. Node — backed by the built-in `node:crypto` module (`webcrypto`, else
14
14
  * `randomFillSync`). This is what a Node runtime WITHOUT a global WebCrypto
15
15
  * (Node 18 script entrypoints, some embedded hosts) falls back to.
16
- * 3. React Native — `expo-crypto.getRandomBytes` (statically imported via the
17
- * per-platform `platform/crypto` module in `@oxy.so/protocol`).
16
+ * 3. React Native — `expo-crypto.getRandomBytes`, through the dependency-free
17
+ * `@oxy.so/protocol/random` entry.
18
+ *
19
+ * # Evaluation order — this module must import NOTHING that can capture the global
20
+ *
21
+ * `@noble/hashes` 1.x reads `globalThis.crypto` exactly once, when its
22
+ * `crypto.js` is evaluated, and `randomBytes` uses that captured binding for
23
+ * the lifetime of the app. ES imports are evaluated before the importing
24
+ * module's body, so every module this file imports runs BEFORE the shim below
25
+ * is installed. This file used to import `@oxy.so/protocol`'s root entry,
26
+ * which reaches `@noble/curves` → `@noble/hashes` through the envelope signer:
27
+ * on Hermes (no `globalThis.crypto` at startup) noble captured `undefined`
28
+ * before the shim existed, and every identity creation on Android failed with
29
+ * `crypto.getRandomValues must be defined`. Its imports are therefore limited
30
+ * to `buffer` and `@oxy.so/protocol/random`, both free of crypto libraries,
31
+ * and `index.ts` imports this file first. `__tests__/polyfillOrder.test.ts`
32
+ * guards both halves.
18
33
  *
19
34
  * Historically step (2) delegated to `@oxy.so/protocol`'s RN-only
20
35
  * `getRandomBytesRN`, which THROWS on Node — so any Node host lacking a global
@@ -693,7 +693,12 @@ export interface AssetUploadProgress {
693
693
  export interface DeviceLinkedSession {
694
694
  sessionId: string;
695
695
  deviceId: string;
696
- deviceName: string;
696
+ /**
697
+ * Not sent by `GET /session/device/sessions/:sessionId` today — every row
698
+ * shares one device, and the account a row belongs to is `user`. Optional so
699
+ * a caller cannot render it unchecked ("undefined (This device)").
700
+ */
701
+ deviceName?: string;
697
702
  isActive: boolean;
698
703
  lastActive: string;
699
704
  expiresAt: string;
@@ -220,8 +220,19 @@ export interface SignInFlowState {
220
220
  }
221
221
  /** Immutable snapshot consumed by `useSyncExternalStore`. */
222
222
  export interface AccountDialogSnapshot {
223
- /** The current view. */
223
+ /**
224
+ * The current view. Never `'accounts'` without a session: the account menu
225
+ * describes a signed-in account, so with no bearer the controller shows the
226
+ * sign-in entry (`'signin'`) in its place.
227
+ */
224
228
  view: AccountDialogView;
229
+ /**
230
+ * Where {@link AccountDialogController.back} leads from {@link view}, or
231
+ * `null` when `view` is the dialog's first view (a back there closes the
232
+ * dialog instead). Hosts show their back affordance from this, not from a
233
+ * table of their own.
234
+ */
235
+ backView: AccountDialogView | null;
225
236
  /**
226
237
  * The server-authoritative device directory — principals and the contexts
227
238
  * each may act as (ADR 0002) — or `null` before the first read.
@@ -449,8 +460,32 @@ export declare class AccountDialogController {
449
460
  * (no retry storm).
450
461
  */
451
462
  private reconcileAuth;
452
- /** Set the dialog view directly. */
453
- setView(view: AccountDialogView): void;
463
+ /**
464
+ * Set the dialog view directly.
465
+ *
466
+ * `'accounts'` is only honoured with a session. Signed out it lands on the
467
+ * sign-in entry instead — the menu it would open holds "Switch account",
468
+ * storage and "Sign out" for an account that does not exist.
469
+ */
470
+ setView(requested: AccountDialogView): void;
471
+ /**
472
+ * Go back one step: `signup` and `qr` return to the sign-in entry, and the
473
+ * entry returns to the account menu when somebody is signed in.
474
+ *
475
+ * Returns `false` — changing nothing — when the current view is the dialog's
476
+ * first one, so the host closes the dialog instead. Leaving an active
477
+ * request withdraws it; otherwise its authorize code stays approvable until
478
+ * it expires.
479
+ */
480
+ back(): boolean;
481
+ /** `'accounts'` needs a session; without one the sign-in entry stands in for it. */
482
+ private resolveView;
483
+ /**
484
+ * Whether an account is signed in here — the precondition of the account
485
+ * menu. The bearer, not the directory: the directory is bearer-read, and the
486
+ * menu's content (hero, storage, sign-out) is all about the bearer's account.
487
+ */
488
+ private hasSession;
454
489
  /** Switch to the "add account" view (the sign-in entry chooser). */
455
490
  add(): void;
456
491
  /** Switch to the "create account" view (passkey / Commons signup entry). */
@@ -795,6 +830,18 @@ export declare class AccountDialogController {
795
830
  /** Recompute the snapshot and notify subscribers. */
796
831
  private emit;
797
832
  }
833
+ /**
834
+ * The view one step back from `view`, or `null` when `view` is the dialog's
835
+ * first view.
836
+ *
837
+ * The sign-in entry is `'add'` ("add another account") when somebody is signed
838
+ * in and `'signin'` when nobody is; `signup` and `qr` are reached FROM that
839
+ * entry, so they return to it. The entry itself returns to the account menu —
840
+ * which exists only with a session. This used to be a host-side
841
+ * "back = accounts" that assumed a signed-in origin, so a signed-out Back from
842
+ * "Create your account" opened a menu holding "Sign out" for nobody.
843
+ */
844
+ export declare function backViewOf(view: AccountDialogView, hasSession: boolean): AccountDialogView | null;
798
845
  /** Factory mirroring `createSessionClient`, for ergonomic wiring by consumers. */
799
846
  export declare function createAccountDialogController(options: AccountDialogControllerOptions): AccountDialogController;
800
847
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -119,7 +119,7 @@
119
119
  "@noble/ciphers": "^1.3.0",
120
120
  "@noble/hashes": "^1.8.0",
121
121
  "@oxy.so/contracts": "^1.4.0",
122
- "@oxy.so/protocol": "^1.1.0",
122
+ "@oxy.so/protocol": "^1.1.1",
123
123
  "@scure/bip39": "^1.6.0",
124
124
  "buffer": "^6.0.3",
125
125
  "express-rate-limit": "^8.6.0",
@@ -13,7 +13,7 @@
13
13
  const mockGetRandomBytesRN = jest.fn<Uint8Array, [number]>();
14
14
  const mockState = { isNodeJS: true };
15
15
 
16
- jest.mock('@oxy.so/protocol', () => ({
16
+ jest.mock('@oxy.so/protocol/random', () => ({
17
17
  isNodeJS: () => mockState.isNodeJS,
18
18
  getRandomBytesRN: (byteCount: number) => mockGetRandomBytesRN(byteCount),
19
19
  }));
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The crypto polyfill must be installed before ANY `@noble/*` module evaluates.
3
+ *
4
+ * `@noble/hashes` 1.x's `crypto.js` captures `globalThis.crypto` once, at module
5
+ * evaluation, and `randomBytes` uses that captured binding forever. Hermes has
6
+ * no `globalThis.crypto` at startup, so if noble is evaluated before
7
+ * `./polyfill` installs the shim, every random draw throws
8
+ * `crypto.getRandomValues must be defined` — which is exactly how identity
9
+ * creation broke on Android: the polyfill imported `@oxy.so/protocol`'s root
10
+ * entry, whose envelope signer reaches `@noble/curves` → `@noble/hashes`, and
11
+ * imports are evaluated before the polyfill body.
12
+ *
13
+ * Node's resolver picks noble's `cryptoNode.js` (the `node` export condition),
14
+ * which never captures the global, so a plain Node run cannot see the bug. The
15
+ * suites below substitute the REAL `crypto.js` that Metro resolves for React
16
+ * Native, remove `globalThis.crypto`, and load core in a fresh module registry.
17
+ */
18
+
19
+ import { readFileSync } from 'node:fs';
20
+ import { dirname, join, resolve } from 'node:path';
21
+
22
+ type RandomBytes = (length?: number) => Uint8Array;
23
+
24
+ const ORIGINAL_CRYPTO_DESCRIPTOR = Object.getOwnPropertyDescriptor(globalThis, 'crypto');
25
+
26
+ /** noble's browser / React Native `crypto.js`: the variant that captures the global. */
27
+ const NOBLE_CAPTURING_CRYPTO = join(dirname(require.resolve('@noble/hashes/utils')), 'crypto.js');
28
+
29
+ /** Remove `globalThis.crypto` the way a Hermes host starts: no property at all. */
30
+ function removeHostCrypto(): void {
31
+ if (!Reflect.deleteProperty(globalThis, 'crypto') || 'crypto' in globalThis) {
32
+ Object.defineProperty(globalThis, 'crypto', {
33
+ value: undefined,
34
+ configurable: true,
35
+ writable: true,
36
+ });
37
+ }
38
+ }
39
+
40
+ function restoreHostCrypto(): void {
41
+ Reflect.deleteProperty(globalThis, 'crypto');
42
+ if (ORIGINAL_CRYPTO_DESCRIPTOR) {
43
+ Object.defineProperty(globalThis, 'crypto', ORIGINAL_CRYPTO_DESCRIPTOR);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Run `body` in a fresh module registry that resolves `@noble/hashes/crypto` as
49
+ * a React Native bundle does, with no host `globalThis.crypto`.
50
+ */
51
+ function withReactNativeNobleAndNoHostCrypto(body: () => void): void {
52
+ removeHostCrypto();
53
+ try {
54
+ jest.isolateModules(() => {
55
+ jest.doMock('@noble/hashes/crypto', () => jest.requireActual(NOBLE_CAPTURING_CRYPTO));
56
+ body();
57
+ });
58
+ } finally {
59
+ restoreHostCrypto();
60
+ }
61
+ }
62
+
63
+ describe('crypto polyfill evaluation order', () => {
64
+ it('reproduces the capture: noble evaluated before the polyfill has no randomness', () => {
65
+ // Guards the harness itself — if this ever stops throwing, the substitution
66
+ // no longer models the capturing variant and the suites below prove nothing.
67
+ withReactNativeNobleAndNoHostCrypto(() => {
68
+ const { randomBytes } = require('@noble/hashes/utils') as { randomBytes: RandomBytes };
69
+ require('../polyfill');
70
+ expect(() => randomBytes(32)).toThrow('crypto.getRandomValues must be defined');
71
+ });
72
+ });
73
+
74
+ it('importing @oxy.so/core installs the shim before noble captures the global', () => {
75
+ withReactNativeNobleAndNoHostCrypto(() => {
76
+ require('../../index');
77
+ const { randomBytes } = require('@noble/hashes/utils') as { randomBytes: RandomBytes };
78
+
79
+ const bytes = randomBytes(32);
80
+ expect(bytes).toHaveLength(32);
81
+ expect(bytes.some((byte) => byte !== 0)).toBe(true);
82
+ });
83
+ });
84
+
85
+ it('generates an identity (key pair + recovery mnemonic) through core', () => {
86
+ withReactNativeNobleAndNoHostCrypto(() => {
87
+ const core = require('../../index') as typeof import('../../index');
88
+ const { generateMnemonic } = require('@scure/bip39') as typeof import('@scure/bip39');
89
+ const { wordlist } = require('@scure/bip39/wordlists/english') as {
90
+ wordlist: string[];
91
+ };
92
+
93
+ // The two random draws `RecoveryPhraseService.generateIdentityWithRecovery`
94
+ // and `KeyManager.generateKeyPairSync` make — the ones Commons failed on.
95
+ expect(generateMnemonic(wordlist, 128).split(' ')).toHaveLength(12);
96
+ const keyPair = core.KeyManager.generateKeyPairSync();
97
+ expect(keyPair.privateKey).toMatch(/^[0-9a-f]{64}$/);
98
+ });
99
+ });
100
+
101
+ it('the polyfill imports nothing that can reach a crypto library', () => {
102
+ const source = readFileSync(resolve(__dirname, '..', 'polyfill.ts'), 'utf8');
103
+ const specifiers = [...source.matchAll(/^import\s+[^'"]*?['"]([^'"]+)['"]/gm)].map(
104
+ (match) => match[1],
105
+ );
106
+ expect(specifiers.sort()).toEqual(['@oxy.so/protocol/random', 'buffer']);
107
+ });
108
+
109
+ it('the core entry imports the polyfill before anything else', () => {
110
+ const source = readFileSync(resolve(__dirname, '..', '..', 'index.ts'), 'utf8');
111
+ const firstModuleReference = source.match(/^(?:import|export)\s[^;]*?['"]([^'"]+)['"]/m);
112
+ expect(firstModuleReference?.[1]).toBe('./crypto/polyfill');
113
+ });
114
+ });
@@ -13,8 +13,23 @@
13
13
  * 2. Node — backed by the built-in `node:crypto` module (`webcrypto`, else
14
14
  * `randomFillSync`). This is what a Node runtime WITHOUT a global WebCrypto
15
15
  * (Node 18 script entrypoints, some embedded hosts) falls back to.
16
- * 3. React Native — `expo-crypto.getRandomBytes` (statically imported via the
17
- * per-platform `platform/crypto` module in `@oxy.so/protocol`).
16
+ * 3. React Native — `expo-crypto.getRandomBytes`, through the dependency-free
17
+ * `@oxy.so/protocol/random` entry.
18
+ *
19
+ * # Evaluation order — this module must import NOTHING that can capture the global
20
+ *
21
+ * `@noble/hashes` 1.x reads `globalThis.crypto` exactly once, when its
22
+ * `crypto.js` is evaluated, and `randomBytes` uses that captured binding for
23
+ * the lifetime of the app. ES imports are evaluated before the importing
24
+ * module's body, so every module this file imports runs BEFORE the shim below
25
+ * is installed. This file used to import `@oxy.so/protocol`'s root entry,
26
+ * which reaches `@noble/curves` → `@noble/hashes` through the envelope signer:
27
+ * on Hermes (no `globalThis.crypto` at startup) noble captured `undefined`
28
+ * before the shim existed, and every identity creation on Android failed with
29
+ * `crypto.getRandomValues must be defined`. Its imports are therefore limited
30
+ * to `buffer` and `@oxy.so/protocol/random`, both free of crypto libraries,
31
+ * and `index.ts` imports this file first. `__tests__/polyfillOrder.test.ts`
32
+ * guards both halves.
18
33
  *
19
34
  * Historically step (2) delegated to `@oxy.so/protocol`'s RN-only
20
35
  * `getRandomBytesRN`, which THROWS on Node — so any Node host lacking a global
@@ -23,7 +38,7 @@
23
38
  */
24
39
 
25
40
  import { Buffer } from 'buffer';
26
- import { getRandomBytesRN, isNodeJS } from '@oxy.so/protocol';
41
+ import { getRandomBytesRN, isNodeJS } from '@oxy.so/protocol/random';
27
42
 
28
43
  const getGlobalObject = (): typeof globalThis => {
29
44
  if (typeof globalThis !== 'undefined') return globalThis;
@@ -125,7 +140,7 @@ const cryptoPolyfill: CryptoLike = {
125
140
  return array;
126
141
  }
127
142
  // React Native (and any non-Node host without WebCrypto): synchronous
128
- // expo-crypto via @oxy.so/protocol's RN `platform/crypto` variant.
143
+ // expo-crypto via @oxy.so/protocol's RN `platform/random` variant.
129
144
  const bytes = getRandomBytesRN(array.byteLength);
130
145
  const uint8View = new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
131
146
  uint8View.set(bytes);
@@ -1850,6 +1850,7 @@
1850
1850
  "switch": {
1851
1851
  "title": "Switch account",
1852
1852
  "count": "{{count}} accounts",
1853
+ "countOne": "{{count}} account",
1853
1854
  "empty": "Accounts you own or share"
1854
1855
  }
1855
1856
  },
@@ -1970,7 +1971,8 @@
1970
1971
  "loading": "Loading sessions…",
1971
1972
  "empty": "No active sessions",
1972
1973
  "signOutAllOnThisDevice": "Sign out of all other devices",
1973
- "signOutAllSubtitle": "End {{count}} other device session(s)"
1974
+ "signOutAllSubtitle": "End {{count}} other device session(s)",
1975
+ "otherSession": "Another session"
1974
1976
  },
1975
1977
  "confirms": {
1976
1978
  "removeDeviceTitle": "Remove device",
@@ -1850,6 +1850,7 @@
1850
1850
  "switch": {
1851
1851
  "title": "Cambiar de cuenta",
1852
1852
  "count": "{{count}} cuentas",
1853
+ "countOne": "{{count}} cuenta",
1853
1854
  "empty": "Cuentas que posees o compartes"
1854
1855
  }
1855
1856
  },
@@ -1970,7 +1971,8 @@
1970
1971
  "loading": "Cargando sesiones…",
1971
1972
  "empty": "No hay sesiones activas",
1972
1973
  "signOutAllOnThisDevice": "Cerrar sesión en los demás dispositivos",
1973
- "signOutAllSubtitle": "Finalizar {{count}} sesión(es) de otros dispositivos"
1974
+ "signOutAllSubtitle": "Finalizar {{count}} sesión(es) de otros dispositivos",
1975
+ "otherSession": "Otra sesión"
1974
1976
  },
1975
1977
  "confirms": {
1976
1978
  "removeDeviceTitle": "Eliminar dispositivo",
package/src/index.ts CHANGED
@@ -17,7 +17,10 @@
17
17
  * If a symbol does not appear here, it is NOT part of the public API.
18
18
  */
19
19
 
20
- // Ensure crypto polyfills are loaded before anything else
20
+ // Ensure crypto polyfills are loaded before anything else. This MUST stay the
21
+ // first import: `@noble/hashes` captures `globalThis.crypto` when it is
22
+ // evaluated, so the shim has to exist before any module below reaches it (see
23
+ // the evaluation-order note in `./crypto/polyfill`).
21
24
  import './crypto/polyfill';
22
25
 
23
26
  // ---------------------------------------------------------------------------
@@ -801,7 +801,12 @@ export interface AssetUploadProgress {
801
801
  export interface DeviceLinkedSession {
802
802
  sessionId: string;
803
803
  deviceId: string;
804
- deviceName: string;
804
+ /**
805
+ * Not sent by `GET /session/device/sessions/:sessionId` today — every row
806
+ * shares one device, and the account a row belongs to is `user`. Optional so
807
+ * a caller cannot render it unchecked ("undefined (This device)").
808
+ */
809
+ deviceName?: string;
805
810
  isActive: boolean;
806
811
  lastActive: string;
807
812
  expiresAt: string;
@@ -241,6 +241,111 @@ function makeHarness(
241
241
  const directoryReads = (urls: string[]): number =>
242
242
  urls.filter((url) => url === '/session/device/directory').length;
243
243
 
244
+ describe('AccountDialogController — back, and the menu needs a session', () => {
245
+ /** A harness with no bearer — nobody is signed in on this device. */
246
+ function signedOut(): Harness {
247
+ const harness = makeHarness();
248
+ harness.oxy.emitTokenChange(null);
249
+ return harness;
250
+ }
251
+
252
+ it('signed out, Back from "Create your account" returns to the sign-in entry, not the account menu', () => {
253
+ // The Android QA repro (OxyHQ/oxy#1375 item 1): Sign In → "New to Oxy?
254
+ // Create one" → ‹. The host's back used to be a hard-coded
255
+ // `setView('accounts')`, which opened "Switch account … Sign out" for nobody.
256
+ const { controller } = signedOut();
257
+ controller.setView('signin');
258
+ controller.startSignup();
259
+ expect(controller.getSnapshot().backView).toBe('signin');
260
+
261
+ expect(controller.back()).toBe(true);
262
+
263
+ expect(controller.getSnapshot().view).toBe('signin');
264
+ });
265
+
266
+ it('signed out, the sign-in entry is the first view: back reports nothing to go back to', () => {
267
+ const { controller } = signedOut();
268
+ controller.setView('signin');
269
+ const before = controller.getSnapshot();
270
+ expect(before.backView).toBeNull();
271
+
272
+ expect(controller.back()).toBe(false);
273
+
274
+ expect(controller.getSnapshot()).toBe(before);
275
+ });
276
+
277
+ it('signed out, the account menu is never the view — it resolves to the sign-in entry', async () => {
278
+ const { controller } = signedOut();
279
+ // A controller is constructed on `accounts`; started with no bearer, it
280
+ // shows the entry.
281
+ controller.start();
282
+ await flush();
283
+ expect(controller.getSnapshot().view).toBe('signin');
284
+
285
+ controller.setView('qr');
286
+ controller.setView('accounts');
287
+ expect(controller.getSnapshot().view).toBe('signin');
288
+
289
+ controller.add();
290
+ expect(controller.getSnapshot().backView).toBeNull();
291
+ expect(controller.back()).toBe(false);
292
+ expect(controller.getSnapshot().view).toBe('add');
293
+ controller.destroy();
294
+ });
295
+
296
+ it('signed out, Back from the QR request withdraws it and returns to the entry', async () => {
297
+ const { controller, oxy } = signedOut();
298
+ oxy.startCommonsSignIn.mockResolvedValue({
299
+ sessionToken: 'secret-tok',
300
+ authorizeCode: 'AUTH-CODE',
301
+ qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
302
+ expiresAt: Date.now() + 300_000,
303
+ status: 'pending',
304
+ });
305
+ controller.setView('signin');
306
+ await controller.showQr();
307
+ expect(controller.getSnapshot().view).toBe('qr');
308
+ expect(controller.getSnapshot().signIn.phase).toBe('waiting');
309
+
310
+ expect(controller.back()).toBe(true);
311
+
312
+ const snap = controller.getSnapshot();
313
+ expect(snap.view).toBe('signin');
314
+ expect(snap.signIn.phase).toBe('idle');
315
+ expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
316
+ });
317
+
318
+ it('signed in, back walks signup → add → accounts, and the menu is the first view', () => {
319
+ const { controller } = makeHarness();
320
+ controller.add();
321
+ controller.startSignup();
322
+ expect(controller.getSnapshot().backView).toBe('add');
323
+
324
+ expect(controller.back()).toBe(true);
325
+ expect(controller.getSnapshot().view).toBe('add');
326
+ expect(controller.getSnapshot().backView).toBe('accounts');
327
+
328
+ expect(controller.back()).toBe(true);
329
+ expect(controller.getSnapshot().view).toBe('accounts');
330
+ expect(controller.getSnapshot().backView).toBeNull();
331
+
332
+ expect(controller.back()).toBe(false);
333
+ expect(controller.getSnapshot().view).toBe('accounts');
334
+ });
335
+
336
+ it('signing out while the account menu is open leaves the menu for the sign-in entry', async () => {
337
+ const { controller, oxy } = makeHarness();
338
+ controller.start();
339
+ await flush();
340
+ expect(controller.getSnapshot().view).toBe('accounts');
341
+
342
+ oxy.emitTokenChange(null);
343
+
344
+ expect(controller.getSnapshot().view).toBe('signin');
345
+ controller.destroy();
346
+ });
347
+ });
348
+
244
349
  describe('AccountDialogController — initial + views', () => {
245
350
  it('starts on the accounts view with an empty list and idle sign-in', () => {
246
351
  const { controller } = makeHarness();
@@ -64,6 +64,7 @@ const oxyServices = {
64
64
  const SNAPSHOT_KEYS = [
65
65
  'activatingContextId',
66
66
  'activeContext',
67
+ 'backView',
67
68
  'commonsAvailability',
68
69
  'directory',
69
70
  'error',
@@ -297,8 +297,19 @@ function deriveSignInProgress(facts: SignInFlowFacts): SignInProgress {
297
297
 
298
298
  /** Immutable snapshot consumed by `useSyncExternalStore`. */
299
299
  export interface AccountDialogSnapshot {
300
- /** The current view. */
300
+ /**
301
+ * The current view. Never `'accounts'` without a session: the account menu
302
+ * describes a signed-in account, so with no bearer the controller shows the
303
+ * sign-in entry (`'signin'`) in its place.
304
+ */
301
305
  view: AccountDialogView;
306
+ /**
307
+ * Where {@link AccountDialogController.back} leads from {@link view}, or
308
+ * `null` when `view` is the dialog's first view (a back there closes the
309
+ * dialog instead). Hosts show their back affordance from this, not from a
310
+ * table of their own.
311
+ */
312
+ backView: AccountDialogView | null;
302
313
  /**
303
314
  * The server-authoritative device directory — principals and the contexts
304
315
  * each may act as (ADR 0002) — or `null` before the first read.
@@ -695,9 +706,11 @@ export class AccountDialogController {
695
706
  }
696
707
  // Signed out. The directory is bearer-read and `SessionClient` drops the one
697
708
  // it holds when the device empties, so there is nothing to clear here — only
698
- // the in-flight bookkeeping, which no longer describes anything.
709
+ // the in-flight bookkeeping, which no longer describes anything. The account
710
+ // menu described the account that just left, so it gives way to the entry.
699
711
  this.error = null;
700
712
  this.loading = false;
713
+ this.view = this.resolveView(this.view);
701
714
  this.emit();
702
715
  }
703
716
 
@@ -705,8 +718,15 @@ export class AccountDialogController {
705
718
  // View actions
706
719
  // =========================================================================
707
720
 
708
- /** Set the dialog view directly. */
709
- setView(view: AccountDialogView): void {
721
+ /**
722
+ * Set the dialog view directly.
723
+ *
724
+ * `'accounts'` is only honoured with a session. Signed out it lands on the
725
+ * sign-in entry instead — the menu it would open holds "Switch account",
726
+ * storage and "Sign out" for an account that does not exist.
727
+ */
728
+ setView(requested: AccountDialogView): void {
729
+ const view = this.resolveView(requested);
710
730
  if (this.view === view) return;
711
731
  this.view = view;
712
732
  // A `'completed'` flow owns no timers, socket, popup, or token — it is only
@@ -719,6 +739,39 @@ export class AccountDialogController {
719
739
  this.emit();
720
740
  }
721
741
 
742
+ /**
743
+ * Go back one step: `signup` and `qr` return to the sign-in entry, and the
744
+ * entry returns to the account menu when somebody is signed in.
745
+ *
746
+ * Returns `false` — changing nothing — when the current view is the dialog's
747
+ * first one, so the host closes the dialog instead. Leaving an active
748
+ * request withdraws it; otherwise its authorize code stays approvable until
749
+ * it expires.
750
+ */
751
+ back(): boolean {
752
+ const target = backViewOf(this.view, this.hasSession());
753
+ if (target === null) return false;
754
+ if (this.signIn.phase === 'starting' || this.signIn.phase === 'waiting') {
755
+ this.cancelSignIn();
756
+ }
757
+ this.setView(target);
758
+ return true;
759
+ }
760
+
761
+ /** `'accounts'` needs a session; without one the sign-in entry stands in for it. */
762
+ private resolveView(view: AccountDialogView): AccountDialogView {
763
+ return view === 'accounts' && !this.hasSession() ? 'signin' : view;
764
+ }
765
+
766
+ /**
767
+ * Whether an account is signed in here — the precondition of the account
768
+ * menu. The bearer, not the directory: the directory is bearer-read, and the
769
+ * menu's content (hero, storage, sign-out) is all about the bearer's account.
770
+ */
771
+ private hasSession(): boolean {
772
+ return this.isAuthenticated();
773
+ }
774
+
722
775
  /** Switch to the "add account" view (the sign-in entry chooser). */
723
776
  add(): void {
724
777
  this.setView('add');
@@ -1720,8 +1773,13 @@ export class AccountDialogController {
1720
1773
 
1721
1774
  private computeSnapshot(): AccountDialogSnapshot {
1722
1775
  const directory = this.sessionClient.getDirectory();
1776
+ const hasSession = this.hasSession();
1777
+ // Resolved here as well as in `setView`: the controller is constructed on
1778
+ // `'accounts'` before anyone knows whether a bearer exists.
1779
+ const view = this.resolveView(this.view);
1723
1780
  return {
1724
- view: this.view,
1781
+ view,
1782
+ backView: backViewOf(view, hasSession),
1725
1783
  directory,
1726
1784
  activeContext: resolveActiveContext(directory),
1727
1785
  loading: this.loading,
@@ -1747,6 +1805,30 @@ export class AccountDialogController {
1747
1805
  }
1748
1806
  }
1749
1807
 
1808
+ /**
1809
+ * The view one step back from `view`, or `null` when `view` is the dialog's
1810
+ * first view.
1811
+ *
1812
+ * The sign-in entry is `'add'` ("add another account") when somebody is signed
1813
+ * in and `'signin'` when nobody is; `signup` and `qr` are reached FROM that
1814
+ * entry, so they return to it. The entry itself returns to the account menu —
1815
+ * which exists only with a session. This used to be a host-side
1816
+ * "back = accounts" that assumed a signed-in origin, so a signed-out Back from
1817
+ * "Create your account" opened a menu holding "Sign out" for nobody.
1818
+ */
1819
+ export function backViewOf(view: AccountDialogView, hasSession: boolean): AccountDialogView | null {
1820
+ switch (view) {
1821
+ case 'signup':
1822
+ case 'qr':
1823
+ return hasSession ? 'add' : 'signin';
1824
+ case 'add':
1825
+ return hasSession ? 'accounts' : null;
1826
+ case 'signin':
1827
+ case 'accounts':
1828
+ return null;
1829
+ }
1830
+ }
1831
+
1750
1832
  /** Factory mirroring `createSessionClient`, for ergonomic wiring by consumers. */
1751
1833
  export function createAccountDialogController(
1752
1834
  options: AccountDialogControllerOptions,