@oxy.so/core 1.8.1 → 1.9.1

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.
@@ -141,6 +141,27 @@ export declare class HttpService {
141
141
  * it, and there is exactly one timestamp to reason about.
142
142
  */
143
143
  private lastRefreshWasRateLimited;
144
+ /**
145
+ * The access token the last SUCCESSFUL refresh handed back while it was still
146
+ * inside the refresh lead window, or `null`.
147
+ *
148
+ * The device mint (`POST /session/device/token`) answers with the session's
149
+ * STORED access token for as long as that token has not expired — it only
150
+ * mints a new one once the old one is past `exp`. A client that re-mints
151
+ * {@link TOKEN_REFRESH_LEAD_SECONDS} before `exp` therefore gets the SAME
152
+ * token back, and because that counts as a success (no cooldown), every
153
+ * following request re-ran the preflight and minted again: one mint per
154
+ * request for the last minute of every token, which exhausted the server's
155
+ * 30/min mint budget. The 429 that followed imposed a 60s cooldown that
156
+ * straddled the token's real expiry, so the next request went out with no
157
+ * bearer, drew a 401, found the refresh cooling down, and signed the user out.
158
+ *
159
+ * Remembering the answer ends that: while the current token IS this value and
160
+ * has not yet expired, the server has already said it has nothing fresher, so
161
+ * the preflight sends the still-valid token instead of asking again. Once the
162
+ * token crosses `exp` the preflight asks once more and the server rotates.
163
+ */
164
+ private noFresherTokenThan;
144
165
  private authRefreshHandler;
145
166
  private accessTokenProvider;
146
167
  private deviceSecretMintInFlight;
@@ -309,6 +330,13 @@ export declare class HttpService {
309
330
  * Public because the handler lives in `session/refresh.ts`, not on this class.
310
331
  */
311
332
  noteRefreshRateLimited(): void;
333
+ /**
334
+ * Whether the server has already answered a refresh of the CURRENT access
335
+ * token with that same, still-valid token — i.e. it has nothing fresher to
336
+ * give until the token's `exp`. The proactive scheduler reads this so it waits
337
+ * for expiry instead of re-asking at its 1s floor.
338
+ */
339
+ isAwaitingCurrentTokenExpiry(): boolean;
312
340
  /**
313
341
  * Whether the CURRENT stored access token is already past its `exp`. Drives
314
342
  * the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
@@ -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 {};
@@ -33,6 +33,20 @@ import { type IdentityBinding } from './identitySession';
33
33
  * window — the scheduler just fires it during idle/background.
34
34
  */
35
35
  export declare const TOKEN_REFRESH_LEAD_MS = 60000;
36
+ /**
37
+ * How long past `exp` to re-mint when a successful refresh handed back a token
38
+ * that is STILL inside the lead window.
39
+ *
40
+ * The device mint returns the session's stored access token until that token
41
+ * has actually expired, so asking again before `exp` only returns the same
42
+ * token. Re-arming from expiry in that state computed a negative delay, hit the
43
+ * {@link MIN_SCHEDULE_DELAY_MS} floor and re-minted once a second for the last
44
+ * minute of every token — 30+ mints, the server's whole per-minute budget, and
45
+ * the 429 that followed straddled the real expiry. Waiting until just past
46
+ * `exp` asks exactly once more, when the server will rotate. The margin covers
47
+ * the server's strict `exp < now` comparison and a little clock skew.
48
+ */
49
+ export declare const REMINT_AFTER_EXPIRY_MS = 2000;
36
50
  export interface RefreshDeps {
37
51
  oxy: OxyServices;
38
52
  store: AuthStateStore;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "1.8.1",
3
+ "version": "1.9.1",
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",
@@ -240,6 +240,24 @@ const RATE_LIMITED_REFRESH_COOLDOWN_MS = 60_000;
240
240
  */
241
241
  const TOKEN_REFRESH_LEAD_SECONDS = 60;
242
242
 
243
+ /**
244
+ * Whether `token` is a JWT that is still valid but already inside the refresh
245
+ * lead window. `false` for an expired, opaque or no-`exp` token.
246
+ */
247
+ function isInsideRefreshLeadWindow(token: string): boolean {
248
+ try {
249
+ const decoded = jwtDecode<JwtPayload>(token);
250
+ if (typeof decoded.exp !== 'number') {
251
+ return false;
252
+ }
253
+ const now = Math.floor(Date.now() / 1000);
254
+ // `<=`: the proactive scheduler fires at exactly `exp - lead`.
255
+ return decoded.exp > now && decoded.exp - now <= TOKEN_REFRESH_LEAD_SECONDS;
256
+ } catch {
257
+ return false;
258
+ }
259
+ }
260
+
243
261
  /**
244
262
  * Soft ceiling on the number of live entries in the identity-scoped GET
245
263
  * response cache. Crossing it does NOT evict anything (the {@link TTLCache}
@@ -390,6 +408,27 @@ export class HttpService {
390
408
  * it, and there is exactly one timestamp to reason about.
391
409
  */
392
410
  private lastRefreshWasRateLimited = false;
411
+ /**
412
+ * The access token the last SUCCESSFUL refresh handed back while it was still
413
+ * inside the refresh lead window, or `null`.
414
+ *
415
+ * The device mint (`POST /session/device/token`) answers with the session's
416
+ * STORED access token for as long as that token has not expired — it only
417
+ * mints a new one once the old one is past `exp`. A client that re-mints
418
+ * {@link TOKEN_REFRESH_LEAD_SECONDS} before `exp` therefore gets the SAME
419
+ * token back, and because that counts as a success (no cooldown), every
420
+ * following request re-ran the preflight and minted again: one mint per
421
+ * request for the last minute of every token, which exhausted the server's
422
+ * 30/min mint budget. The 429 that followed imposed a 60s cooldown that
423
+ * straddled the token's real expiry, so the next request went out with no
424
+ * bearer, drew a 401, found the refresh cooling down, and signed the user out.
425
+ *
426
+ * Remembering the answer ends that: while the current token IS this value and
427
+ * has not yet expired, the server has already said it has nothing fresher, so
428
+ * the preflight sends the still-valid token instead of asking again. Once the
429
+ * token crosses `exp` the preflight asks once more and the server rotates.
430
+ */
431
+ private noFresherTokenThan: string | null = null;
393
432
  private authRefreshHandler: AuthRefreshHandler | null = null;
394
433
  private accessTokenProvider: AccessTokenProvider | null = null;
395
434
  private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
@@ -1324,6 +1363,12 @@ export class HttpService {
1324
1363
 
1325
1364
  // If the token expires within the refresh lead window, refresh it.
1326
1365
  if (decoded.exp && decoded.exp - currentTime < TOKEN_REFRESH_LEAD_SECONDS) {
1366
+ // The server already answered this token's refresh with this very token:
1367
+ // it has nothing fresher until `exp`. Use it rather than asking again
1368
+ // (see `noFresherTokenThan`).
1369
+ if (decoded.exp > currentTime && accessToken === this.noFresherTokenThan) {
1370
+ return `Bearer ${accessToken}`;
1371
+ }
1327
1372
  const refreshed = await this.refreshAccessToken('preflight');
1328
1373
  if (refreshed) return `Bearer ${refreshed}`;
1329
1374
  if (decoded.exp > currentTime) {
@@ -1378,6 +1423,7 @@ export class HttpService {
1378
1423
  this.tokenStore.setTokens(newToken);
1379
1424
  this.notifyTokenChange();
1380
1425
  }
1426
+ this.noFresherTokenThan = isInsideRefreshLeadWindow(newToken) ? newToken : null;
1381
1427
  // A success clears the failure timestamp so the next refresh is never
1382
1428
  // throttled by a stale cooldown.
1383
1429
  this.lastRefreshFailureAt = 0;
@@ -1414,6 +1460,17 @@ export class HttpService {
1414
1460
  this.lastRefreshWasRateLimited = true;
1415
1461
  }
1416
1462
 
1463
+ /**
1464
+ * Whether the server has already answered a refresh of the CURRENT access
1465
+ * token with that same, still-valid token — i.e. it has nothing fresher to
1466
+ * give until the token's `exp`. The proactive scheduler reads this so it waits
1467
+ * for expiry instead of re-asking at its 1s floor.
1468
+ */
1469
+ isAwaitingCurrentTokenExpiry(): boolean {
1470
+ const token = this.tokenStore.getAccessToken();
1471
+ return token !== null && token === this.noFresherTokenThan && isInsideRefreshLeadWindow(token);
1472
+ }
1473
+
1417
1474
  /**
1418
1475
  * Whether the CURRENT stored access token is already past its `exp`. Drives
1419
1476
  * the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The near-expiry mint storm that signed a busy app out (OxyHQ/Mention#1140).
3
+ *
4
+ * What the server does: `POST /session/device/token` answers with the session's
5
+ * STORED access token for as long as that token has not expired, and mints a
6
+ * new one only once it is past `exp`. The mint is budgeted at 30/min per device.
7
+ *
8
+ * What the client did with that: the scheduler and the request-time preflight
9
+ * both re-mint 60s before `exp`. The server handed the same token back, which
10
+ * counted as a success (no cooldown), so the scheduler re-armed at its 1s floor
11
+ * and every request re-ran the preflight: ~30 mints in the token's last minute,
12
+ * then a 429, whose 60s cooldown straddled the real expiry. The next request
13
+ * carried no bearer, drew a 401, found the refresh cooling down, and the
14
+ * HttpService cleared the token — which the provider reads as a sign-out.
15
+ *
16
+ * Production logs for the device showed exactly this: a burst of ~30 mints at
17
+ * ~1.4s intervals every ~16 minutes, a single mint ~78s later (the scheduler's
18
+ * 5+10+20+40s backoff through the 60s rate-limit cooldown), and a sign-out on
19
+ * the one cycle where the app was in active use across the expiry.
20
+ *
21
+ * This suite simulates that server against the real HttpService, refresh
22
+ * scheduler and 401 lane.
23
+ */
24
+ import { OxyServices } from '../OxyServices';
25
+ import { startTokenRefreshScheduler } from '../session/refresh';
26
+
27
+ const TOKEN_TTL_SECONDS = 15 * 60;
28
+ const MINT_BUDGET_PER_MINUTE = 30;
29
+
30
+ function createJwt(payload: Record<string, unknown>): string {
31
+ const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
32
+ return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.signature`;
33
+ }
34
+
35
+ function expOf(token: string): number {
36
+ const [, payload] = token.split('.');
37
+ return (JSON.parse(Buffer.from(payload, 'base64url').toString()) as { exp: number }).exp;
38
+ }
39
+
40
+ /**
41
+ * The device mint as the server implements it, including its rate limiter.
42
+ *
43
+ * `rotateWithinSeconds` is how close to `exp` the stored token may be before the
44
+ * server rotates instead of handing it back: `0` is the server as it was (it
45
+ * rotated only a token already past `exp`), a positive value is the fixed one.
46
+ */
47
+ function createMintServer(rotateWithinSeconds: number): {
48
+ mint: () => { status: 200; token: string } | { status: 429 };
49
+ storedToken: () => string;
50
+ mints: number[];
51
+ rateLimited: number;
52
+ } {
53
+ let serial = 0;
54
+ const issue = (): string => {
55
+ serial += 1;
56
+ return createJwt({ userId: 'u', jti: `t${serial}`, exp: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS });
57
+ };
58
+ let stored = issue();
59
+ const mints: number[] = [];
60
+ const state = { rateLimited: 0 };
61
+ return {
62
+ mint: () => {
63
+ const now = Date.now();
64
+ if (mints.filter((at) => now - at < 60_000).length >= MINT_BUDGET_PER_MINUTE) {
65
+ state.rateLimited += 1;
66
+ return { status: 429 };
67
+ }
68
+ mints.push(now);
69
+ // `getAccessToken`: hand back the stored token unless it has expired.
70
+ if (expOf(stored) - Math.floor(now / 1000) < (rotateWithinSeconds > 0 ? rotateWithinSeconds : 0)) {
71
+ stored = issue();
72
+ }
73
+ return { status: 200, token: stored };
74
+ },
75
+ storedToken: () => stored,
76
+ mints,
77
+ get rateLimited() {
78
+ return state.rateLimited;
79
+ },
80
+ };
81
+ }
82
+
83
+ describe('a busy app across an access token expiry', () => {
84
+ const originalFetch = globalThis.fetch;
85
+
86
+ afterEach(() => {
87
+ globalThis.fetch = originalFetch;
88
+ jest.useRealTimers();
89
+ });
90
+
91
+ async function runBusyApp(rotateWithinSeconds: number): Promise<{
92
+ tokenEvents: Array<string | null>;
93
+ finalToken: string | null;
94
+ server: ReturnType<typeof createMintServer>;
95
+ }> {
96
+ jest.useFakeTimers({ now: new Date('2026-09-25T13:48:00Z') });
97
+ const server = createMintServer(rotateWithinSeconds);
98
+
99
+ // The app's own API: 401 without a live bearer, as any resource server does.
100
+ globalThis.fetch = (async (_input: unknown, init?: RequestInit) => {
101
+ const headers = (init?.headers ?? {}) as Record<string, string>;
102
+ const bearer = headers.Authorization ?? headers.authorization;
103
+ const token = bearer?.startsWith('Bearer ') ? bearer.slice(7) : null;
104
+ if (!token || expOf(token) <= Math.floor(Date.now() / 1000)) {
105
+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
106
+ status: 401,
107
+ headers: { 'content-type': 'application/json' },
108
+ });
109
+ }
110
+ return new Response(JSON.stringify({ data: { ok: true } }), {
111
+ status: 200,
112
+ headers: { 'content-type': 'application/json' },
113
+ });
114
+ }) as typeof fetch;
115
+
116
+ const oxy = new OxyServices({ baseURL: 'https://api.mention.earth', enableCache: false, enableRetry: false });
117
+ oxy.setTokens(server.storedToken());
118
+ oxy.httpService.setAuthRefreshHandler(async (reason) => {
119
+ const answer = server.mint();
120
+ if (answer.status === 429) {
121
+ // What `refreshDeviceSecretArm` does with a 429.
122
+ oxy.httpService.noteRefreshRateLimited();
123
+ return null;
124
+ }
125
+ oxy.setTokens(answer.token);
126
+ return answer.token;
127
+ });
128
+
129
+ const tokenEvents: Array<string | null> = [];
130
+ oxy.onTokensChanged((token) => tokenEvents.push(token));
131
+ const scheduler = startTokenRefreshScheduler(oxy);
132
+
133
+ // Twenty minutes of an app in use: a request every 700ms, straight across
134
+ // the first token's expiry at 14:03:00.
135
+ for (let elapsed = 0; elapsed < 20 * 60_000; elapsed += 700) {
136
+ await oxy.httpService.get('/feed').catch(() => undefined);
137
+ await jest.advanceTimersByTimeAsync(700);
138
+ }
139
+ scheduler.dispose();
140
+ return { tokenEvents, finalToken: oxy.getAccessToken(), server };
141
+ }
142
+
143
+ it('against the server that hands back an unexpired token: never cleared, never rate limited', async () => {
144
+ const { tokenEvents, finalToken, server } = await runBusyApp(0);
145
+
146
+ // Before the fix: ~30 mints of the same token, a 429, then the token was
147
+ // cleared (a run of `null` events) and the app was signed out.
148
+ expect(tokenEvents).not.toContain(null);
149
+ expect(finalToken).not.toBeNull();
150
+ expect(server.rateLimited).toBe(0);
151
+ // One rotation. The server still hands the expired token back for the
152
+ // second it expires in (it compares `exp < now`), which costs a few mints in
153
+ // that one second — nowhere near the budget.
154
+ expect(server.mints.length).toBeLessThan(MINT_BUDGET_PER_MINUTE / 3);
155
+ });
156
+
157
+ it('against the server that rotates a token near expiry: one mint per rotation', async () => {
158
+ const { tokenEvents, finalToken, server } = await runBusyApp(120);
159
+
160
+ expect(tokenEvents).not.toContain(null);
161
+ expect(finalToken).not.toBeNull();
162
+ expect(server.rateLimited).toBe(0);
163
+ expect(server.mints).toHaveLength(1);
164
+ });
165
+ });
@@ -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",
@@ -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',