@oxy.so/core 1.2.1 → 1.3.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.
Files changed (100) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/crypto/identityMove.js +123 -0
  3. package/dist/cjs/crypto/keyManager.js +60 -21
  4. package/dist/cjs/crypto/webIdentityCarrier.js +262 -0
  5. package/dist/cjs/i18n/locales/ar-SA.json +16 -13
  6. package/dist/cjs/i18n/locales/ca-ES.json +16 -13
  7. package/dist/cjs/i18n/locales/de-DE.json +16 -13
  8. package/dist/cjs/i18n/locales/en-US.json +16 -17
  9. package/dist/cjs/i18n/locales/es-ES.json +16 -17
  10. package/dist/cjs/i18n/locales/fr-FR.json +16 -13
  11. package/dist/cjs/i18n/locales/it-IT.json +16 -13
  12. package/dist/cjs/i18n/locales/ja-JP.json +16 -13
  13. package/dist/cjs/i18n/locales/ko-KR.json +16 -13
  14. package/dist/cjs/i18n/locales/locales/ar-SA.json +16 -13
  15. package/dist/cjs/i18n/locales/locales/ca-ES.json +16 -13
  16. package/dist/cjs/i18n/locales/locales/de-DE.json +16 -13
  17. package/dist/cjs/i18n/locales/locales/en-US.json +16 -17
  18. package/dist/cjs/i18n/locales/locales/es-ES.json +16 -17
  19. package/dist/cjs/i18n/locales/locales/fr-FR.json +16 -13
  20. package/dist/cjs/i18n/locales/locales/it-IT.json +16 -13
  21. package/dist/cjs/i18n/locales/locales/ja-JP.json +16 -13
  22. package/dist/cjs/i18n/locales/locales/ko-KR.json +16 -13
  23. package/dist/cjs/i18n/locales/locales/pt-PT.json +16 -13
  24. package/dist/cjs/i18n/locales/locales/zh-CN.json +16 -13
  25. package/dist/cjs/i18n/locales/pt-PT.json +16 -13
  26. package/dist/cjs/i18n/locales/zh-CN.json +16 -13
  27. package/dist/cjs/index.js +38 -4
  28. package/dist/cjs/mixins/OxyServices.auth.js +14 -4
  29. package/dist/cjs/mixins/OxyServices.identity.js +14 -0
  30. package/dist/cjs/session/accountDialogController.js +333 -142
  31. package/dist/cjs/utils/authWebUrl.js +8 -1
  32. package/dist/esm/.tsbuildinfo +1 -1
  33. package/dist/esm/crypto/identityMove.js +110 -0
  34. package/dist/esm/crypto/keyManager.js +60 -21
  35. package/dist/esm/crypto/webIdentityCarrier.js +243 -0
  36. package/dist/esm/i18n/locales/ar-SA.json +16 -13
  37. package/dist/esm/i18n/locales/ca-ES.json +16 -13
  38. package/dist/esm/i18n/locales/de-DE.json +16 -13
  39. package/dist/esm/i18n/locales/en-US.json +16 -17
  40. package/dist/esm/i18n/locales/es-ES.json +16 -17
  41. package/dist/esm/i18n/locales/fr-FR.json +16 -13
  42. package/dist/esm/i18n/locales/it-IT.json +16 -13
  43. package/dist/esm/i18n/locales/ja-JP.json +16 -13
  44. package/dist/esm/i18n/locales/ko-KR.json +16 -13
  45. package/dist/esm/i18n/locales/locales/ar-SA.json +16 -13
  46. package/dist/esm/i18n/locales/locales/ca-ES.json +16 -13
  47. package/dist/esm/i18n/locales/locales/de-DE.json +16 -13
  48. package/dist/esm/i18n/locales/locales/en-US.json +16 -17
  49. package/dist/esm/i18n/locales/locales/es-ES.json +16 -17
  50. package/dist/esm/i18n/locales/locales/fr-FR.json +16 -13
  51. package/dist/esm/i18n/locales/locales/it-IT.json +16 -13
  52. package/dist/esm/i18n/locales/locales/ja-JP.json +16 -13
  53. package/dist/esm/i18n/locales/locales/ko-KR.json +16 -13
  54. package/dist/esm/i18n/locales/locales/pt-PT.json +16 -13
  55. package/dist/esm/i18n/locales/locales/zh-CN.json +16 -13
  56. package/dist/esm/i18n/locales/pt-PT.json +16 -13
  57. package/dist/esm/i18n/locales/zh-CN.json +16 -13
  58. package/dist/esm/index.js +5 -1
  59. package/dist/esm/mixins/OxyServices.auth.js +14 -4
  60. package/dist/esm/mixins/OxyServices.identity.js +14 -0
  61. package/dist/esm/session/accountDialogController.js +334 -143
  62. package/dist/esm/utils/authWebUrl.js +7 -0
  63. package/dist/types/.tsbuildinfo +1 -1
  64. package/dist/types/crypto/identityMove.d.ts +59 -0
  65. package/dist/types/crypto/keyManager.d.ts +35 -5
  66. package/dist/types/crypto/webIdentityCarrier.d.ts +123 -0
  67. package/dist/types/index.d.ts +5 -2
  68. package/dist/types/mixins/OxyServices.auth.d.ts +12 -0
  69. package/dist/types/models/interfaces.d.ts +15 -0
  70. package/dist/types/session/accountDialogController.d.ts +169 -17
  71. package/dist/types/utils/authWebUrl.d.ts +7 -0
  72. package/package.json +3 -3
  73. package/src/crypto/__tests__/identityMove.test.ts +104 -0
  74. package/src/crypto/__tests__/keyManager.sharedSlotSync.test.ts +150 -0
  75. package/src/crypto/__tests__/webIdentityCarrier.test.ts +211 -0
  76. package/src/crypto/identityMove.ts +133 -0
  77. package/src/crypto/keyManager.ts +71 -21
  78. package/src/crypto/webIdentityCarrier.ts +326 -0
  79. package/src/i18n/locales/ar-SA.json +16 -13
  80. package/src/i18n/locales/ca-ES.json +16 -13
  81. package/src/i18n/locales/de-DE.json +16 -13
  82. package/src/i18n/locales/en-US.json +16 -17
  83. package/src/i18n/locales/es-ES.json +16 -17
  84. package/src/i18n/locales/fr-FR.json +16 -13
  85. package/src/i18n/locales/it-IT.json +16 -13
  86. package/src/i18n/locales/ja-JP.json +16 -13
  87. package/src/i18n/locales/ko-KR.json +16 -13
  88. package/src/i18n/locales/pt-PT.json +16 -13
  89. package/src/i18n/locales/zh-CN.json +16 -13
  90. package/src/index.ts +38 -1
  91. package/src/mixins/OxyServices.auth.ts +16 -3
  92. package/src/mixins/OxyServices.identity.ts +14 -0
  93. package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +47 -0
  94. package/src/mixins/__tests__/commonsSignIn.test.ts +3 -0
  95. package/src/mixins/__tests__/verifyChallenge.test.ts +31 -0
  96. package/src/models/interfaces.ts +15 -0
  97. package/src/session/__tests__/accountDialogController.test.ts +407 -10
  98. package/src/session/accountDialogController.ts +406 -145
  99. package/src/utils/__tests__/oauthPkce.test.ts +50 -26
  100. package/src/utils/authWebUrl.ts +8 -0
@@ -62,7 +62,7 @@ import type { SessionLoginResponse, MinimalUserData } from '../models/session';
62
62
  import type { User } from '../models/interfaces';
63
63
  import { logger } from '../logger';
64
64
  import { extractErrorStatus } from '../utils/errorUtils';
65
- import { CENTRAL_IDP_APEX } from '../utils/authWebUrl';
65
+ import { IDENTITY_WEB_ORIGIN } from '../utils/authWebUrl';
66
66
  import type { SessionClient } from './SessionClient';
67
67
  import { getSocketIO, type MinimalSocket, type SocketIOFactory } from './socketLoader';
68
68
  import { resolveActiveContext, type DeviceContext } from './deviceDirectory';
@@ -139,6 +139,30 @@ export type SignInProgress =
139
139
  | 'confirming-identity'
140
140
  | 'identity-confirmed';
141
141
 
142
+ /**
143
+ * WHY a sign-in attempt ended in `'error'` — a machine-readable reason the UI
144
+ * turns into localized copy (the controller ships no user-facing prose).
145
+ *
146
+ * - `'cancelled'` — the USER walked away (closed the passkey hub popup). A
147
+ * voluntary outcome, not a failure to report.
148
+ * - `'denied'` — the approver declined the request in Commons.
149
+ * - `'expired'` — the request outlived its server-authoritative expiry.
150
+ * - `'network'` — Oxy could not be reached (no response, timeout).
151
+ * - `'not-configured'` — the app has no registered `clientId`.
152
+ * - `'unsupported-flow'` — an OAuth-bound request, which this surface cannot finalize.
153
+ * - `'claim-failed'` — the request WAS approved, but the session could not be claimed.
154
+ * - `'unknown'` — anything else; the raw message stays on `error` for logs.
155
+ */
156
+ export type SignInFailureReason =
157
+ | 'cancelled'
158
+ | 'denied'
159
+ | 'expired'
160
+ | 'network'
161
+ | 'not-configured'
162
+ | 'unsupported-flow'
163
+ | 'claim-failed'
164
+ | 'unknown';
165
+
142
166
  /**
143
167
  * Minimal structural handle over a popup `Window` — just enough for the
144
168
  * cross-origin passkey hub flow ({@link AccountDialogController.startPasskeyHubSignIn}):
@@ -175,8 +199,13 @@ export interface SignInFlowState {
175
199
  qrPayload: string | null;
176
200
  /** Server-authoritative expiry (epoch ms), or `null`. */
177
201
  expiresAt: number | null;
178
- /** Human-readable error for the retry UI, or `null`. */
202
+ /**
203
+ * The raw, UNLOCALIZED failure message (diagnostics only), or `null`. A surface
204
+ * renders {@link failure} instead — this string is English or a server message.
205
+ */
179
206
  error: string | null;
207
+ /** Why the attempt failed, set together with `phase: 'error'`; `null` otherwise. */
208
+ failure: SignInFailureReason | null;
180
209
  /**
181
210
  * The ONE primary delivery route the controller chose for this request
182
211
  * ({@link selectCommonsDelivery}), or `null` while it is still being resolved.
@@ -217,14 +246,25 @@ export interface SignInFlowState {
217
246
  * cannot drift from them or run ahead of a real signal.
218
247
  */
219
248
  progress: SignInProgress;
249
+ /**
250
+ * Identity of the sign-in ATTEMPT this state belongs to — a counter that moves
251
+ * whenever an attempt is started, cancelled, or the controller is destroyed.
252
+ * Not a secret. It lets a surface treat "this attempt failed" as one event (a
253
+ * re-notification of the same failure is not a second failure, while a new
254
+ * attempt failing with the same message is).
255
+ */
256
+ attempt: number;
220
257
  }
221
258
 
222
259
  /**
223
260
  * The observable FACTS of a device flow — {@link SignInFlowState} minus the
224
- * value derived from them. Every mutation of the flow goes through this shape,
225
- * which is what makes `progress` structurally impossible to set by hand.
261
+ * values the controller derives. Every mutation of the flow goes through this
262
+ * shape, which is what makes `progress` (and `attempt`) impossible to set by hand.
226
263
  */
227
- type SignInFlowFacts = Omit<SignInFlowState, 'progress'>;
264
+ type SignInFlowFacts = Omit<SignInFlowState, 'progress' | 'attempt'>;
265
+
266
+ /** How the current sign-in attempt was started — what "Try again" repeats. */
267
+ type SignInMethod = 'oxy' | 'qr' | 'passkey-hub';
228
268
 
229
269
  /**
230
270
  * Derive the surface-facing progress from the flow's real facts. Pure, total,
@@ -356,8 +396,16 @@ export interface AccountDialogControllerOptions {
356
396
  */
357
397
  openPopup?: () => PopupWindowHandle | null;
358
398
  /**
359
- * Base origin of the auth.oxy.so passkey hub (defaults to
360
- * `https://auth.${CENTRAL_IDP_APEX}`). Overridable for local/staging testing.
399
+ * Origin of the web identity carrier (default `IDENTITY_WEB_ORIGIN`,
400
+ * `https://id.oxy.so`) — where a passkey sign-in or sign-up runs
401
+ * and the account's identity is kept sealed under the passkey. The popup opens
402
+ * `<identityOrigin>/continue?code=…`. Overridable for local/staging testing.
403
+ */
404
+ identityOrigin?: string;
405
+ /**
406
+ * @deprecated Alias of {@link identityOrigin}, kept for existing
407
+ * configuration. The popup used to open `auth.oxy.so/hub-passkey`; it now opens
408
+ * the identity origin's `/continue`, so a value here must be that origin.
361
409
  */
362
410
  hubBaseUrl?: string;
363
411
  /**
@@ -398,32 +446,41 @@ const IDLE_SIGN_IN_FACTS: SignInFlowFacts = {
398
446
  qrPayload: null,
399
447
  expiresAt: null,
400
448
  error: null,
449
+ failure: null,
401
450
  route: null,
402
451
  routeFailed: false,
403
452
  pushSentAt: null,
404
453
  openedAt: null,
405
454
  };
406
455
 
407
- const IDLE_SIGN_IN: SignInFlowState = {
408
- ...IDLE_SIGN_IN_FACTS,
409
- progress: deriveSignInProgress(IDLE_SIGN_IN_FACTS),
410
- };
411
-
412
456
  /**
413
- * Terminal SUCCESS state: the session was claimed and committed. Holds no live
457
+ * Terminal SUCCESS facts: the session was claimed and committed. Holds no live
414
458
  * resources and no request handles — only the terminal progress the surface
415
459
  * shows ("Identity confirmed") before it closes.
416
460
  */
417
- const COMPLETED_SIGN_IN: SignInFlowState = {
418
- ...IDLE_SIGN_IN_FACTS,
419
- phase: 'completed',
420
- progress: deriveSignInProgress({ ...IDLE_SIGN_IN_FACTS, phase: 'completed' }),
421
- };
461
+ const COMPLETED_SIGN_IN_FACTS: SignInFlowFacts = { ...IDLE_SIGN_IN_FACTS, phase: 'completed' };
462
+
463
+ /** The full flow state for `facts`, stamped with the attempt it belongs to. */
464
+ function buildSignIn(facts: SignInFlowFacts, attempt: number): SignInFlowState {
465
+ return { ...facts, progress: deriveSignInProgress(facts), attempt };
466
+ }
422
467
 
423
468
  function errorMessage(error: unknown): string {
424
469
  return error instanceof Error ? error.message : String(error);
425
470
  }
426
471
 
472
+ /**
473
+ * Classify a request that never produced a response. `OxyServices.handleError`
474
+ * normalizes transport failures to `NETWORK_ERROR` / `TIMEOUT` with status `0`.
475
+ */
476
+ function requestFailureReason(error: unknown): SignInFailureReason {
477
+ const code = error && typeof error === 'object' ? (error as { code?: unknown }).code : undefined;
478
+ if (code === 'NETWORK_ERROR' || code === 'TIMEOUT' || extractErrorStatus(error) === 0) {
479
+ return 'network';
480
+ }
481
+ return 'unknown';
482
+ }
483
+
427
484
  type SnapshotListener = (snapshot: AccountDialogSnapshot) => void;
428
485
 
429
486
  export class AccountDialogController {
@@ -437,7 +494,7 @@ export class AccountDialogController {
437
494
  private readonly canOpenApp?: (url: string) => Promise<boolean>;
438
495
  private readonly socketFactory?: SocketIOFactory;
439
496
  private readonly openPopup?: () => PopupWindowHandle | null;
440
- private readonly hubBaseUrl: string;
497
+ private readonly identityOrigin: string;
441
498
  private readonly platform: CommonsDeliveryPlatform;
442
499
 
443
500
  private readonly listeners = new Set<SnapshotListener>();
@@ -449,10 +506,26 @@ export class AccountDialogController {
449
506
  private activatingContextId: string | null = null;
450
507
  private removingContextId: string | null = null;
451
508
  private removingPrincipalId: string | null = null;
452
- private signIn: SignInFlowState = IDLE_SIGN_IN;
509
+ /**
510
+ * `true` while an operation run through {@link runDeviceMutation} (a host
511
+ * sign-out) is in flight. The fourth member of the ONE device-mutation gate,
512
+ * alongside the three flags above.
513
+ */
514
+ private exclusiveMutationInFlight = false;
515
+ private signIn: SignInFlowState = buildSignIn(IDLE_SIGN_IN_FACTS, 0);
453
516
  private commonsAvailability: CommonsAvailability = 'unknown';
454
517
 
455
518
  // --- Sign-in device-flow bookkeeping ---
519
+ /**
520
+ * The CURRENT sign-in attempt. Every asynchronous step of a sign-in captures
521
+ * it before awaiting and re-checks it after (`isCurrentAttempt`); cancelling,
522
+ * starting another attempt, or destroying the controller moves it, so a late
523
+ * response from an abandoned attempt can neither update the surface nor
524
+ * install a session.
525
+ */
526
+ private signInAttempt = 0;
527
+ /** How the current attempt was started, so a retry repeats the user's choice. */
528
+ private signInMethod: SignInMethod = 'oxy';
456
529
  /** The secret device-flow token of the active QR flow (never surfaced). */
457
530
  private signInToken: string | null = null;
458
531
  private pollTimer: ReturnType<typeof setTimeout> | null = null;
@@ -465,9 +538,10 @@ export class AccountDialogController {
465
538
  /**
466
539
  * Guards {@link pollOnce} against re-entrancy: the fallback timer and a socket
467
540
  * `auth_update` wake can fire together — without this both could claim the
468
- * single-use token concurrently.
541
+ * single-use token concurrently. Keyed by token, so a poll still running for an
542
+ * ABANDONED request never blocks the next request's first poll.
469
543
  */
470
- private pollInFlight = false;
544
+ private pollInFlightToken: string | null = null;
471
545
  /** The popup opened by {@link startPasskeyHubSignIn}, or `null`. */
472
546
  private activePopup: PopupWindowHandle | null = null;
473
547
  /** Watches {@link activePopup}'s `closed` state; see {@link watchPopup}. */
@@ -493,7 +567,7 @@ export class AccountDialogController {
493
567
  this.canOpenApp = options.canOpenApp;
494
568
  this.socketFactory = options.socketFactory;
495
569
  this.openPopup = options.openPopup;
496
- this.hubBaseUrl = options.hubBaseUrl ?? `https://auth.${CENTRAL_IDP_APEX}`;
570
+ this.identityOrigin = options.identityOrigin ?? options.hubBaseUrl ?? IDENTITY_WEB_ORIGIN;
497
571
  this.platform = options.platform ?? 'unknown';
498
572
  this.snapshot = this.computeSnapshot();
499
573
  }
@@ -564,6 +638,8 @@ export class AccountDialogController {
564
638
  */
565
639
  destroy(): void {
566
640
  this.started = false;
641
+ // Nothing still in flight may act on a destroyed controller.
642
+ this.signInAttempt += 1;
567
643
  if (this.unsubscribeSession) {
568
644
  this.unsubscribeSession();
569
645
  this.unsubscribeSession = null;
@@ -638,7 +714,7 @@ export class AccountDialogController {
638
714
  // another view is a NEW intention, so drop it; otherwise a later `add()`
639
715
  // would open on the previous sign-in's terminal state.
640
716
  if (this.signIn.phase === 'completed') {
641
- this.signIn = IDLE_SIGN_IN;
717
+ this.signIn = buildSignIn(IDLE_SIGN_IN_FACTS, this.signInAttempt);
642
718
  }
643
719
  this.emit();
644
720
  }
@@ -667,8 +743,11 @@ export class AccountDialogController {
667
743
  * — and the reconstruction was not merely redundant: it enumerated the
668
744
  * CALLER's account graph, which on a device holding two people is one
669
745
  * person's answer presented as the device's.
746
+ *
747
+ * Resolves `false` when THIS read failed (a 401 is the signed-out edge, not a
748
+ * failure). Never rejects.
670
749
  */
671
- async refresh(): Promise<void> {
750
+ async refresh(): Promise<boolean> {
672
751
  const seq = ++this.refreshSeq;
673
752
 
674
753
  // Never read the directory while signed out: at cold boot the bearer is not
@@ -678,7 +757,7 @@ export class AccountDialogController {
678
757
  this.loading = false;
679
758
  this.error = null;
680
759
  this.emit();
681
- return;
760
+ return true;
682
761
  }
683
762
 
684
763
  // Nothing to show yet is the only state worth a spinner; a re-read behind an
@@ -687,6 +766,7 @@ export class AccountDialogController {
687
766
  this.error = null;
688
767
  this.emit();
689
768
 
769
+ let failure: string | null = null;
690
770
  try {
691
771
  await this.sessionClient.refreshDirectory();
692
772
  } catch (error) {
@@ -698,14 +778,18 @@ export class AccountDialogController {
698
778
  if (extractErrorStatus(error) === 401) {
699
779
  logger.debug('[AccountDialogController] directory unauthorized (signed out)', { component: 'AccountDialogController' }, error);
700
780
  } else {
701
- this.error = errorMessage(error);
781
+ failure = errorMessage(error);
702
782
  logger.warn('[AccountDialogController] directory refresh failed', { component: 'AccountDialogController' }, error);
703
783
  }
704
784
  }
705
- if (seq !== this.refreshSeq) return; // superseded by a newer refresh
785
+ // Superseded by a newer refresh: that read owns `error` and `loading`, so an
786
+ // older failure must not overwrite its outcome.
787
+ if (seq !== this.refreshSeq) return failure === null;
706
788
 
789
+ this.error = failure;
707
790
  this.loading = false;
708
791
  this.emit();
792
+ return failure === null;
709
793
  }
710
794
 
711
795
  // =========================================================================
@@ -725,23 +809,22 @@ export class AccountDialogController {
725
809
  * A context id is not stable across a removal, so a stale one is an ordinary
726
810
  * outcome rather than a bug: the server answers 404 or 403, heals the row, and
727
811
  * the refresh below re-reads a directory that no longer offers it.
812
+ *
813
+ * Resolves `true` once the SWITCH happened — even if the directory re-read
814
+ * after it fails (that failure lands on `error`, but the subject did change).
815
+ * Resolves `false` when the switch failed, or was refused because another
816
+ * device mutation is in flight ({@link isDeviceMutationInFlight}).
728
817
  */
729
818
  async activateContext(contextId: string): Promise<boolean> {
730
- if (this.activatingContextId) return false;
731
- this.activatingContextId = contextId;
732
- this.error = null;
733
- this.emit();
734
- try {
735
- await this.sessionClient.activateContext(contextId);
736
- await this.refresh();
737
- return true;
738
- } catch (error) {
739
- this.error = errorMessage(error);
740
- return false;
741
- } finally {
742
- this.activatingContextId = null;
743
- this.emit();
744
- }
819
+ return this.mutateDevice(
820
+ () => {
821
+ this.activatingContextId = contextId;
822
+ },
823
+ () => {
824
+ this.activatingContextId = null;
825
+ },
826
+ () => this.sessionClient.activateContext(contextId),
827
+ );
745
828
  }
746
829
 
747
830
  /**
@@ -757,21 +840,15 @@ export class AccountDialogController {
757
840
  * revision — so nothing may hold a context id across this call.
758
841
  */
759
842
  async signOutContext(contextId: string): Promise<boolean> {
760
- if (this.removingContextId || this.removingPrincipalId) return false;
761
- this.removingContextId = contextId;
762
- this.error = null;
763
- this.emit();
764
- try {
765
- await this.sessionClient.signOutContext(contextId);
766
- await this.refresh();
767
- return true;
768
- } catch (error) {
769
- this.error = errorMessage(error);
770
- return false;
771
- } finally {
772
- this.removingContextId = null;
773
- this.emit();
774
- }
843
+ return this.mutateDevice(
844
+ () => {
845
+ this.removingContextId = contextId;
846
+ },
847
+ () => {
848
+ this.removingContextId = null;
849
+ },
850
+ () => this.sessionClient.signOutContext(contextId),
851
+ );
775
852
  }
776
853
 
777
854
  /**
@@ -783,19 +860,85 @@ export class AccountDialogController {
783
860
  * and elects a replacement active context in one transition.
784
861
  */
785
862
  async signOutPrincipal(principalId: string): Promise<boolean> {
786
- if (this.removingContextId || this.removingPrincipalId) return false;
787
- this.removingPrincipalId = principalId;
863
+ return this.mutateDevice(
864
+ () => {
865
+ this.removingPrincipalId = principalId;
866
+ },
867
+ () => {
868
+ this.removingPrincipalId = null;
869
+ },
870
+ () => this.sessionClient.signOutPrincipal(principalId),
871
+ );
872
+ }
873
+
874
+ /**
875
+ * Whether ANY operation that changes who is on this device, or which of them
876
+ * is active, is in flight: an activation, either removal, or a host operation
877
+ * run through {@link runDeviceMutation}.
878
+ *
879
+ * They share ONE gate because they mutate the same device set: a removal that
880
+ * elects a replacement active context racing a switch could leave either one
881
+ * active. A refused call is refused, never queued — a press that could not run
882
+ * must not fire later, after the user has moved on.
883
+ */
884
+ isDeviceMutationInFlight(): boolean {
885
+ return (
886
+ this.activatingContextId !== null ||
887
+ this.removingContextId !== null ||
888
+ this.removingPrincipalId !== null ||
889
+ this.exclusiveMutationInFlight
890
+ );
891
+ }
892
+
893
+ /**
894
+ * Run a device mutation the controller does not own (the host's sign-out)
895
+ * under the same gate as {@link activateContext} and the removals.
896
+ *
897
+ * Resolves `{ ran: false }` without calling `operation` when another mutation
898
+ * is in flight; otherwise `{ ran: true, value }`. A rejection from `operation`
899
+ * propagates, after the gate is released.
900
+ */
901
+ async runDeviceMutation<T>(
902
+ operation: () => Promise<T>,
903
+ ): Promise<{ ran: true; value: T } | { ran: false }> {
904
+ if (this.isDeviceMutationInFlight()) return { ran: false };
905
+ this.exclusiveMutationInFlight = true;
906
+ try {
907
+ return { ran: true, value: await operation() };
908
+ } finally {
909
+ this.exclusiveMutationInFlight = false;
910
+ }
911
+ }
912
+
913
+ /**
914
+ * The shared body of the three controller-owned device mutations: take the
915
+ * gate, run the mutation, then re-read the directory.
916
+ *
917
+ * The mutation's outcome and the re-read's are kept apart. Once `operation`
918
+ * has resolved the device HAS changed, so this resolves `true` even when the
919
+ * re-read fails — that failure is the directory's (`error`), and reporting it
920
+ * as a failed switch would invite the user to repeat something that happened.
921
+ */
922
+ private async mutateDevice(
923
+ mark: () => void,
924
+ clear: () => void,
925
+ operation: () => Promise<void>,
926
+ ): Promise<boolean> {
927
+ if (this.isDeviceMutationInFlight()) return false;
928
+ mark();
788
929
  this.error = null;
789
930
  this.emit();
790
931
  try {
791
- await this.sessionClient.signOutPrincipal(principalId);
932
+ try {
933
+ await operation();
934
+ } catch (error) {
935
+ this.error = errorMessage(error);
936
+ return false;
937
+ }
792
938
  await this.refresh();
793
939
  return true;
794
- } catch (error) {
795
- this.error = errorMessage(error);
796
- return false;
797
940
  } finally {
798
- this.removingPrincipalId = null;
941
+ clear();
799
942
  this.emit();
800
943
  }
801
944
  }
@@ -808,23 +951,28 @@ export class AccountDialogController {
808
951
  * Start "Sign in with Oxy". Native devices with a shared identity mint a
809
952
  * session silently (`signInWithSharedIdentity`); everything else (web, or a
810
953
  * native device without a shared identity) falls through to the cross-device
811
- * QR handoff.
954
+ * QR handoff — as the SAME attempt, so cancelling during either half stops both.
812
955
  */
813
956
  async signInWithOxy(): Promise<void> {
957
+ const attempt = this.beginSignInAttempt('oxy');
814
958
  this.setView('qr');
815
959
  this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'starting' });
816
960
  try {
817
- const session = await this.oxyServices.signInWithSharedIdentity();
961
+ // Minted WITHOUT planting the bearer: `completeSignIn` installs it only if
962
+ // this attempt is still the current one when the mint returns.
963
+ const session = await this.oxyServices.signInWithSharedIdentity({ plantTokens: false });
964
+ if (!this.isCurrentAttempt(attempt)) return;
818
965
  if (session) {
819
- await this.completeSignIn(session, session.user);
966
+ await this.completeSignIn(attempt, session, session.user);
820
967
  return;
821
968
  }
822
969
  } catch (error) {
970
+ if (!this.isCurrentAttempt(attempt)) return;
823
971
  // Shared-key mint failed — log and fall through to the QR handoff rather
824
972
  // than dead-ending the sign-in.
825
973
  logger.warn('[AccountDialogController] signInWithSharedIdentity failed', { component: 'AccountDialogController' }, error);
826
974
  }
827
- await this.showQr();
975
+ await this.startDeviceFlowSession(attempt, { deliver: true });
828
976
  }
829
977
 
830
978
  /**
@@ -834,17 +982,40 @@ export class AccountDialogController {
834
982
  * session committed. Requires `clientId`.
835
983
  */
836
984
  async showQr(): Promise<void> {
837
- this.cancelSignIn();
985
+ const attempt = this.beginSignInAttempt('qr');
838
986
  this.setView('qr');
839
- await this.startDeviceFlowSession({ deliver: true });
987
+ await this.startDeviceFlowSession(attempt, { deliver: true });
840
988
  }
841
989
 
842
990
  /**
843
- * Web-only: "Sign in with a passkey" on a non-Oxy origin cannot run the
844
- * WebAuthn ceremony locally — a credential minted with `WEBAUTHN_RP_ID=oxy.so`
845
- * can only be asserted from `oxy.so`/a subdomain/loopback, a browser-enforced
846
- * boundary `isOxyRpOrigin()` (consumer-side) already gates on. Instead, open
847
- * a popup at the auth.oxy.so passkey hub, scoped to the SAME device-flow
991
+ * Start a NEW attempt the same way the last one was started — "Try again"
992
+ * repeats the user's choice rather than silently switching method. A failed
993
+ * passkey-hub attempt reopens the hub popup; a failed "Sign in with Oxy"
994
+ * retries that; an explicit QR request shows the QR.
995
+ *
996
+ * Call it straight from the press handler: the hub route opens its popup
997
+ * synchronously, before this method's first `await`, so the browser still
998
+ * attributes it to the click.
999
+ */
1000
+ retrySignIn(): Promise<void> {
1001
+ switch (this.signInMethod) {
1002
+ case 'passkey-hub':
1003
+ return this.startPasskeyHubSignIn();
1004
+ case 'qr':
1005
+ return this.showQr();
1006
+ default:
1007
+ return this.signInWithOxy();
1008
+ }
1009
+ }
1010
+
1011
+ /**
1012
+ * Web-only: sign in (or create an account) with a passkey at the web identity
1013
+ * carrier. The ceremony runs on `id.oxy.so`, never on the calling origin, for
1014
+ * two reasons: a credential minted with `WEBAUTHN_RP_ID=oxy.so` can only be
1015
+ * asserted from `oxy.so`/a subdomain/loopback (a browser-enforced boundary),
1016
+ * and only the identity origin may unseal or create the account's identity
1017
+ * (one identity, two carriers). Opens a popup at `<identityOrigin>/continue`,
1018
+ * scoped to the SAME device-flow
848
1019
  * session {@link showQr} would create (same `authorizeCode`/`sessionToken`
849
1020
  * pair), and let the SAME poll/socket/claim engine complete it once the hub
850
1021
  * authorizes the session (`POST /auth/session/authorize-code/:authorizeCode`,
@@ -859,83 +1030,120 @@ export class AccountDialogController {
859
1030
  * `showQr`'s plain QR rendering — no dead end.
860
1031
  */
861
1032
  async startPasskeyHubSignIn(): Promise<void> {
862
- this.cancelSignIn();
1033
+ const attempt = this.beginSignInAttempt('passkey-hub');
863
1034
  this.setView('qr');
864
1035
  const popup = this.openPopup?.() ?? null;
865
1036
  if (!popup) {
866
- await this.startDeviceFlowSession({ deliver: true });
1037
+ await this.startDeviceFlowSession(attempt, { deliver: true });
867
1038
  return;
868
1039
  }
1040
+ // Owned by the attempt from the moment it exists, so cancelling while the
1041
+ // session is still being created closes it instead of leaving a blank
1042
+ // window behind.
1043
+ this.activePopup = popup;
869
1044
  // The hub popup IS the primary surface here, chosen explicitly by the user —
870
1045
  // so this flow does NOT run automatic Commons delivery (ringing the user's
871
1046
  // phone because they asked for a passkey would be exactly the "menu of
872
1047
  // methods" the one-primary-action rule forbids). The underlying request is
873
1048
  // the same `AuthSession`, and its Commons route stays the QR the view
874
1049
  // renders beneath the popup.
875
- const handle = await this.startDeviceFlowSession({ deliver: false });
876
- if (!handle) {
877
- popup.close();
878
- return;
879
- }
1050
+ const handle = await this.startDeviceFlowSession(attempt, { deliver: false });
1051
+ // `null` means the attempt failed (its teardown closed the popup) or was
1052
+ // abandoned (the cancel closed it).
1053
+ if (!handle) return;
880
1054
  // The user may have closed the popup during the async session creation
881
1055
  // above, before any watcher was attached to catch it — guard rather than
882
1056
  // navigate a dead window (browsers vary on whether that throws).
883
1057
  if (popup.closed) {
884
- this.failSignIn('Sign-in was cancelled.');
1058
+ const pendingCode = this.signIn.authorizeCode;
1059
+ this.failSignIn('cancelled', 'Sign-in was cancelled.');
1060
+ if (pendingCode) void this.withdrawRequest(pendingCode);
885
1061
  return;
886
1062
  }
887
- this.activePopup = popup;
888
- popup.location.href = `${this.hubBaseUrl}/hub-passkey?code=${encodeURIComponent(handle.authorizeCode)}`;
1063
+ popup.location.href = `${this.identityOrigin}/continue?code=${encodeURIComponent(handle.authorizeCode)}`;
889
1064
  this.watchPopup(popup);
890
1065
  }
891
1066
 
1067
+ /**
1068
+ * End whatever attempt is running (withdrawing its request) and begin a new
1069
+ * one. Returns the new attempt's identity for the caller to re-check after
1070
+ * every `await`.
1071
+ */
1072
+ private beginSignInAttempt(method: SignInMethod): number {
1073
+ this.cancelSignIn();
1074
+ this.signInMethod = method;
1075
+ return this.signInAttempt;
1076
+ }
1077
+
1078
+ /** Whether `attempt` is still the one the surface is waiting on. */
1079
+ private isCurrentAttempt(attempt: number): boolean {
1080
+ return attempt === this.signInAttempt;
1081
+ }
1082
+
892
1083
  /**
893
1084
  * Shared device-flow session creation for both {@link showQr} (renders the
894
1085
  * QR) and {@link startPasskeyHubSignIn} (also opens the hub popup) — the
895
1086
  * same `startCommonsSignIn` → poll/socket wiring either way. Returns the
896
- * handle on success (already reflected in `signIn`), or `null` on failure
897
- * (already set as `signIn.error`).
1087
+ * handle on success (already reflected in `signIn`), or `null` when the
1088
+ * attempt failed (already set as `signIn.failure`) or was abandoned while the
1089
+ * request was being created.
898
1090
  *
1091
+ * @param attempt - The attempt this request belongs to. A request that comes
1092
+ * back for an abandoned attempt is withdrawn server-side and never wired up:
1093
+ * closing the dialog on a slow connection must not leave an approvable
1094
+ * request, a socket, and a poll behind it.
899
1095
  * @param opts.deliver - Whether to run automatic Commons delivery selection
900
1096
  * ({@link resolveDeliveryRoute}). `true` for the normal one-primary-action
901
1097
  * entry; `false` when the caller already owns the primary surface (the
902
1098
  * passkey hub popup), where the request's Commons route is simply the QR.
903
1099
  */
904
- private async startDeviceFlowSession(opts: { deliver: boolean }): Promise<CommonsSignInHandle | null> {
1100
+ private async startDeviceFlowSession(
1101
+ attempt: number,
1102
+ opts: { deliver: boolean },
1103
+ ): Promise<CommonsSignInHandle | null> {
1104
+ if (!this.isCurrentAttempt(attempt)) return null;
905
1105
  if (!this.clientId) {
906
- this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: 'This app is not configured for sign-in (missing clientId).' });
1106
+ this.failSignIn('not-configured', 'This app is not configured for sign-in (missing clientId).');
907
1107
  return null;
908
1108
  }
909
1109
  this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'starting' });
1110
+ let handle: CommonsSignInHandle;
910
1111
  try {
911
- const handle = await this.oxyServices.startCommonsSignIn({ clientId: this.clientId });
912
- this.signInToken = handle.sessionToken;
913
- this.setSignIn({
914
- ...IDLE_SIGN_IN_FACTS,
915
- phase: 'waiting',
916
- authorizeCode: handle.authorizeCode,
917
- qrPayload: handle.qrPayload,
918
- expiresAt: handle.expiresAt,
919
- // No route yet: the surface shows "Preparing request" until the primary
920
- // route is resolved below. It is never guessed in the meantime.
921
- route: opts.deliver ? null : 'qr',
922
- });
923
- // Primary path: an instant `auth_update` wake over the `/auth-session`
924
- // socket. The poll below is only the fallback for when the socket can't
925
- // connect, so it now runs at the slow fallback cadence.
926
- this.openAuthSessionSocket(handle.sessionToken);
927
- this.scheduleNextPoll(handle.sessionToken);
928
- if (opts.deliver) {
929
- // Non-blocking on purpose: the QR/authorizeCode are already renderable
930
- // and the popup caller can navigate immediately, while the route (a
931
- // local probe plus at most one delivery round-trip) resolves behind it.
932
- void this.resolveDeliveryRoute(handle);
933
- }
934
- return handle;
1112
+ handle = await this.oxyServices.startCommonsSignIn({ clientId: this.clientId });
935
1113
  } catch (error) {
936
- this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: errorMessage(error) });
1114
+ if (!this.isCurrentAttempt(attempt)) return null;
1115
+ this.failSignIn(requestFailureReason(error), errorMessage(error));
1116
+ return null;
1117
+ }
1118
+ if (!this.isCurrentAttempt(attempt)) {
1119
+ // Created after the user walked away. The cancel could not withdraw it (it
1120
+ // had no code yet), so withdraw it now — best effort, like every withdrawal.
1121
+ void this.withdrawRequest(handle.authorizeCode);
937
1122
  return null;
938
1123
  }
1124
+ this.signInToken = handle.sessionToken;
1125
+ this.setSignIn({
1126
+ ...IDLE_SIGN_IN_FACTS,
1127
+ phase: 'waiting',
1128
+ authorizeCode: handle.authorizeCode,
1129
+ qrPayload: handle.qrPayload,
1130
+ expiresAt: handle.expiresAt,
1131
+ // No route yet: the surface shows "Preparing request" until the primary
1132
+ // route is resolved below. It is never guessed in the meantime.
1133
+ route: opts.deliver ? null : 'qr',
1134
+ });
1135
+ // Primary path: an instant `auth_update` wake over the `/auth-session`
1136
+ // socket. The poll below is only the fallback for when the socket can't
1137
+ // connect, so it now runs at the slow fallback cadence.
1138
+ void this.openAuthSessionSocket(handle.sessionToken);
1139
+ this.scheduleNextPoll(handle.sessionToken);
1140
+ if (opts.deliver) {
1141
+ // Non-blocking on purpose: the QR/authorizeCode are already renderable
1142
+ // and the popup caller can navigate immediately, while the route (a
1143
+ // local probe plus at most one delivery round-trip) resolves behind it.
1144
+ void this.resolveDeliveryRoute(handle);
1145
+ }
1146
+ return handle;
939
1147
  }
940
1148
 
941
1149
  /**
@@ -1061,7 +1269,7 @@ export class AccountDialogController {
1061
1269
  if (this.signIn.phase === 'starting' || this.signIn.phase === 'waiting') {
1062
1270
  // Closing the surface cancels the REQUEST too, not just this listener.
1063
1271
  const pendingCode = this.signIn.authorizeCode;
1064
- this.failSignIn('Sign-in was cancelled.');
1272
+ this.failSignIn('cancelled', 'Sign-in was cancelled.');
1065
1273
  if (pendingCode) {
1066
1274
  void this.withdrawRequest(pendingCode);
1067
1275
  }
@@ -1128,8 +1336,15 @@ export class AccountDialogController {
1128
1336
  * cancel the request, not just stop listening to it. Without the withdrawal a
1129
1337
  * dismissed QR would stay approvable until it expired, so a later scan of a
1130
1338
  * stale code could authorize a session nobody is waiting for.
1339
+ *
1340
+ * It also ENDS the attempt: anything of it still awaiting a response (the
1341
+ * request being created, a shared-identity mint, a claim) finds itself
1342
+ * superseded when that response arrives, and neither touches the surface nor
1343
+ * installs a session. The withdrawal itself is best effort — cancelling
1344
+ * locally is not proof the server received it (see {@link withdrawRequest}).
1131
1345
  */
1132
1346
  cancelSignIn(): void {
1347
+ this.signInAttempt += 1;
1133
1348
  // Capture before the teardown clears it, and only for a request that can
1134
1349
  // still be approved — a completed/failed flow has nothing to withdraw.
1135
1350
  const pendingCode =
@@ -1140,7 +1355,7 @@ export class AccountDialogController {
1140
1355
  this.closeAuthSessionSocket();
1141
1356
  this.closeActivePopup();
1142
1357
  this.signInToken = null;
1143
- if (this.signIn !== IDLE_SIGN_IN) {
1358
+ if (this.signIn.phase !== 'idle') {
1144
1359
  this.setSignIn(IDLE_SIGN_IN_FACTS);
1145
1360
  }
1146
1361
  if (pendingCode) {
@@ -1186,12 +1401,13 @@ export class AccountDialogController {
1186
1401
  */
1187
1402
  private async pollOnce(sessionToken: string): Promise<void> {
1188
1403
  // A superseded / cancelled flow must not act; a poll already running owns the claim.
1189
- if (this.signInToken !== sessionToken || this.pollInFlight) return;
1190
- this.pollInFlight = true;
1404
+ if (this.signInToken !== sessionToken || this.pollInFlightToken === sessionToken) return;
1405
+ const attempt = this.signInAttempt;
1406
+ this.pollInFlightToken = sessionToken;
1191
1407
  try {
1192
1408
  const expiresAt = this.signIn.expiresAt;
1193
1409
  if (typeof expiresAt === 'number' && Date.now() > expiresAt) {
1194
- this.failSignIn('Session expired. Please try again.');
1410
+ this.failSignIn('expired', 'Session expired. Please try again.');
1195
1411
  return;
1196
1412
  }
1197
1413
  try {
@@ -1206,20 +1422,20 @@ export class AccountDialogController {
1206
1422
  // OAuth-bound sessions mint no sessionId on approval — they finalize
1207
1423
  // into an authorization code. The account dialog only starts device
1208
1424
  // sign-in today; stop rather than poll until expiry.
1209
- this.failSignIn('This sign-in flow cannot be completed here. Use the app\'s OAuth sign-in instead.');
1425
+ this.failSignIn('unsupported-flow', 'This sign-in flow cannot be completed here. Use the app\'s OAuth sign-in instead.');
1210
1426
  return;
1211
1427
  }
1212
1428
  if (status.authorized && status.sessionId) {
1213
1429
  this.clearPollTimer();
1214
- await this.claimAndComplete(status.sessionId, sessionToken);
1430
+ await this.claimAndComplete(attempt, status.sessionId, sessionToken);
1215
1431
  return;
1216
1432
  }
1217
1433
  if (status.status === 'cancelled') {
1218
- this.failSignIn('Authorization was denied.');
1434
+ this.failSignIn('denied', 'Authorization was denied.');
1219
1435
  return;
1220
1436
  }
1221
1437
  if (status.status === 'expired') {
1222
- this.failSignIn('Session expired. Please try again.');
1438
+ this.failSignIn('expired', 'Session expired. Please try again.');
1223
1439
  return;
1224
1440
  }
1225
1441
  } catch (error) {
@@ -1230,7 +1446,7 @@ export class AccountDialogController {
1230
1446
  this.scheduleNextPoll(sessionToken);
1231
1447
  }
1232
1448
  } finally {
1233
- this.pollInFlight = false;
1449
+ if (this.pollInFlightToken === sessionToken) this.pollInFlightToken = null;
1234
1450
  }
1235
1451
  }
1236
1452
 
@@ -1249,7 +1465,7 @@ export class AccountDialogController {
1249
1465
  this.patchSignIn({ pushSentAt: nextPushSentAt, openedAt: nextOpenedAt });
1250
1466
  }
1251
1467
 
1252
- private async claimAndComplete(sessionId: string, sessionToken: string): Promise<void> {
1468
+ private async claimAndComplete(attempt: number, sessionId: string, sessionToken: string): Promise<void> {
1253
1469
  this.patchSignIn({ phase: 'authorized' });
1254
1470
  let claimed: {
1255
1471
  accessToken: string;
@@ -1260,13 +1476,17 @@ export class AccountDialogController {
1260
1476
  deviceSecret?: string;
1261
1477
  };
1262
1478
  try {
1263
- claimed = await this.oxyServices.claimSessionByToken(sessionToken);
1479
+ // Claimed WITHOUT planting the bearer — `completeSignIn` installs it only
1480
+ // if the user is still waiting for this attempt when the claim returns.
1481
+ claimed = await this.oxyServices.claimSessionByToken(sessionToken, { plantTokens: false });
1264
1482
  } catch (error) {
1265
- this.failSignIn(errorMessage(error));
1483
+ if (!this.isCurrentAttempt(attempt)) return;
1484
+ this.failSignIn('claim-failed', errorMessage(error));
1266
1485
  return;
1267
1486
  }
1487
+ if (!this.isCurrentAttempt(attempt)) return;
1268
1488
  if (!claimed?.accessToken || !claimed.user) {
1269
- this.failSignIn('Authorization succeeded but the session could not be claimed. Please try again.');
1489
+ this.failSignIn('claim-failed', 'Authorization succeeded but the session could not be claimed. Please try again.');
1270
1490
  return;
1271
1491
  }
1272
1492
  // `SessionLoginResponse.user` is the minimal session-carried shape; the claim
@@ -1280,6 +1500,7 @@ export class AccountDialogController {
1280
1500
  };
1281
1501
  try {
1282
1502
  await this.completeSignIn(
1503
+ attempt,
1283
1504
  {
1284
1505
  sessionId: claimed.sessionId || sessionId,
1285
1506
  deviceId: claimed.deviceId ?? '',
@@ -1291,28 +1512,62 @@ export class AccountDialogController {
1291
1512
  minimalUser,
1292
1513
  );
1293
1514
  } catch (error) {
1294
- this.failSignIn(errorMessage(error));
1515
+ if (this.isCurrentAttempt(attempt)) this.failSignIn('unknown', errorMessage(error));
1295
1516
  }
1296
1517
  }
1297
1518
 
1298
1519
  /**
1299
- * Commit an authorized session, notify, and return to the account list. Shared
1300
- * by the shared-key, QR, and mint-switch paths so they cannot drift.
1520
+ * Install an authorized session, notify, and return to the account list.
1521
+ * Shared by the shared-key and QR paths so they cannot drift.
1522
+ *
1523
+ * THE install point, and so the last place an abandoned attempt is stopped:
1524
+ * the bearer is planted and the session committed only while `attempt` is
1525
+ * still current. A commit that fails restores the bearer the client had
1526
+ * before, so a half-installed session never leaves requests going out as an
1527
+ * account the device does not hold.
1528
+ *
1529
+ * A failed commit REJECTS (after that restore), so each caller keeps its own
1530
+ * policy: the QR path fails the attempt, the shared-identity path falls
1531
+ * through to the QR handoff.
1532
+ *
1533
+ * Once the commit has resolved the session IS this device's, whether or not
1534
+ * the user moved on while it ran. It is reported (`onSignedIn`, re-read) either
1535
+ * way; only the surface bookkeeping is skipped for a superseded attempt, so a
1536
+ * newer attempt's state is never overwritten.
1537
+ *
1538
+ * Residual: an attempt abandoned after the server minted its session (a claim
1539
+ * or shared-identity mint that was already in flight) leaves that server
1540
+ * session un-installed on this device — it is never planted or committed here,
1541
+ * but it is not revoked either.
1301
1542
  */
1302
1543
  private async completeSignIn(
1544
+ attempt: number,
1303
1545
  session: SessionLoginResponse,
1304
1546
  user: MinimalUserData,
1305
1547
  ): Promise<void> {
1306
- await this.commitAuthorizedSession(session, user);
1307
- this.signInToken = null;
1308
- this.clearPollTimer();
1309
- this.closeAuthSessionSocket();
1310
- this.closeActivePopup();
1311
- // Terminal SUCCESS, not idle: the surface gets one honest frame to show
1312
- // "Identity confirmed" before it closes. Cleared on the next view change.
1313
- this.signIn = COMPLETED_SIGN_IN;
1314
- this.view = 'accounts';
1315
- this.emit();
1548
+ if (!this.isCurrentAttempt(attempt)) return;
1549
+ const previousToken = this.oxyServices.getAccessToken();
1550
+ try {
1551
+ if (session.accessToken) this.oxyServices.setTokens(session.accessToken);
1552
+ await this.commitAuthorizedSession(session, user);
1553
+ } catch (error) {
1554
+ if (session.accessToken && this.oxyServices.getAccessToken() === session.accessToken) {
1555
+ if (previousToken) this.oxyServices.setTokens(previousToken);
1556
+ else this.oxyServices.clearTokens();
1557
+ }
1558
+ throw error;
1559
+ }
1560
+ if (this.isCurrentAttempt(attempt)) {
1561
+ this.signInToken = null;
1562
+ this.clearPollTimer();
1563
+ this.closeAuthSessionSocket();
1564
+ this.closeActivePopup();
1565
+ // Terminal SUCCESS, not idle: the surface gets one honest frame to show
1566
+ // "Identity confirmed" before it closes. Cleared on the next view change.
1567
+ this.signIn = buildSignIn(COMPLETED_SIGN_IN_FACTS, this.signInAttempt);
1568
+ this.view = 'accounts';
1569
+ this.emit();
1570
+ }
1316
1571
  this.onSignedIn?.(user);
1317
1572
  await this.refresh();
1318
1573
  }
@@ -1333,12 +1588,16 @@ export class AccountDialogController {
1333
1588
  }
1334
1589
  }
1335
1590
 
1336
- private failSignIn(message: string): void {
1591
+ /**
1592
+ * End the CURRENT attempt as failed. The attempt keeps its identity (the
1593
+ * failure belongs to it); only a new attempt or a cancel moves it on.
1594
+ */
1595
+ private failSignIn(failure: SignInFailureReason, message: string): void {
1337
1596
  this.clearPollTimer();
1338
1597
  this.closeAuthSessionSocket();
1339
1598
  this.closeActivePopup();
1340
1599
  this.signInToken = null;
1341
- this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: message });
1600
+ this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: message, failure });
1342
1601
  }
1343
1602
 
1344
1603
  private clearPollTimer(): void {
@@ -1426,7 +1685,7 @@ export class AccountDialogController {
1426
1685
  * to advance without a fact behind it.
1427
1686
  */
1428
1687
  private setSignIn(facts: SignInFlowFacts): void {
1429
- this.signIn = { ...facts, progress: deriveSignInProgress(facts) };
1688
+ this.signIn = buildSignIn(facts, this.signInAttempt);
1430
1689
  this.emit();
1431
1690
  }
1432
1691
 
@@ -1438,6 +1697,7 @@ export class AccountDialogController {
1438
1697
  qrPayload,
1439
1698
  expiresAt,
1440
1699
  error,
1700
+ failure,
1441
1701
  route,
1442
1702
  routeFailed,
1443
1703
  pushSentAt,
@@ -1449,6 +1709,7 @@ export class AccountDialogController {
1449
1709
  qrPayload,
1450
1710
  expiresAt,
1451
1711
  error,
1712
+ failure,
1452
1713
  route,
1453
1714
  routeFailed,
1454
1715
  pushSentAt,