@oxyhq/core 12.10.6 → 12.11.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 (139) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +66 -6
  3. package/dist/cjs/boot/sessionColdBoot.js +142 -37
  4. package/dist/cjs/i18n/locales/ar-SA.json +13 -1
  5. package/dist/cjs/i18n/locales/ca-ES.json +13 -1
  6. package/dist/cjs/i18n/locales/de-DE.json +13 -1
  7. package/dist/cjs/i18n/locales/en-US.json +13 -4
  8. package/dist/cjs/i18n/locales/es-ES.json +13 -4
  9. package/dist/cjs/i18n/locales/fr-FR.json +13 -1
  10. package/dist/cjs/i18n/locales/it-IT.json +13 -1
  11. package/dist/cjs/i18n/locales/ja-JP.json +13 -1
  12. package/dist/cjs/i18n/locales/ko-KR.json +13 -1
  13. package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
  14. package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
  15. package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
  16. package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
  17. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
  18. package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
  19. package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
  20. package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
  21. package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
  22. package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
  23. package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
  24. package/dist/cjs/i18n/locales/pt-PT.json +13 -1
  25. package/dist/cjs/i18n/locales/zh-CN.json +13 -1
  26. package/dist/cjs/index.js +31 -5
  27. package/dist/cjs/mixins/OxyServices.auth.js +257 -17
  28. package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
  29. package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
  30. package/dist/cjs/mixins/OxyServices.privacy.js +6 -0
  31. package/dist/cjs/mixins/OxyServices.user.js +1 -0
  32. package/dist/cjs/mixins/index.js +5 -0
  33. package/dist/cjs/session/SessionClient.js +40 -4
  34. package/dist/cjs/session/accountDialogController.js +288 -41
  35. package/dist/cjs/session/identityPin.js +256 -0
  36. package/dist/cjs/session/identitySession.js +135 -0
  37. package/dist/cjs/session/projectSessionState.js +54 -15
  38. package/dist/cjs/session/refresh.js +73 -8
  39. package/dist/cjs/utils/commonsDelivery.js +59 -0
  40. package/dist/cjs/utils/oauthPkce.js +33 -3
  41. package/dist/esm/.tsbuildinfo +1 -1
  42. package/dist/esm/HttpService.js +66 -6
  43. package/dist/esm/boot/sessionColdBoot.js +142 -37
  44. package/dist/esm/i18n/locales/ar-SA.json +13 -1
  45. package/dist/esm/i18n/locales/ca-ES.json +13 -1
  46. package/dist/esm/i18n/locales/de-DE.json +13 -1
  47. package/dist/esm/i18n/locales/en-US.json +13 -4
  48. package/dist/esm/i18n/locales/es-ES.json +13 -4
  49. package/dist/esm/i18n/locales/fr-FR.json +13 -1
  50. package/dist/esm/i18n/locales/it-IT.json +13 -1
  51. package/dist/esm/i18n/locales/ja-JP.json +13 -1
  52. package/dist/esm/i18n/locales/ko-KR.json +13 -1
  53. package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
  54. package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
  55. package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
  56. package/dist/esm/i18n/locales/locales/en-US.json +13 -4
  57. package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
  58. package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
  59. package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
  60. package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
  61. package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
  62. package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
  63. package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
  64. package/dist/esm/i18n/locales/pt-PT.json +13 -1
  65. package/dist/esm/i18n/locales/zh-CN.json +13 -1
  66. package/dist/esm/index.js +14 -1
  67. package/dist/esm/mixins/OxyServices.auth.js +257 -17
  68. package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
  69. package/dist/esm/mixins/OxyServices.notifications.js +60 -0
  70. package/dist/esm/mixins/OxyServices.privacy.js +6 -0
  71. package/dist/esm/mixins/OxyServices.user.js +1 -0
  72. package/dist/esm/mixins/index.js +5 -0
  73. package/dist/esm/session/SessionClient.js +40 -4
  74. package/dist/esm/session/accountDialogController.js +288 -41
  75. package/dist/esm/session/identityPin.js +249 -0
  76. package/dist/esm/session/identitySession.js +131 -0
  77. package/dist/esm/session/projectSessionState.js +54 -15
  78. package/dist/esm/session/refresh.js +73 -8
  79. package/dist/esm/utils/commonsDelivery.js +54 -0
  80. package/dist/esm/utils/oauthPkce.js +31 -2
  81. package/dist/types/.tsbuildinfo +1 -1
  82. package/dist/types/HttpService.d.ts +18 -1
  83. package/dist/types/boot/sessionColdBoot.d.ts +38 -1
  84. package/dist/types/index.d.ts +12 -4
  85. package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
  86. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
  87. package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
  88. package/dist/types/mixins/OxyServices.user.d.ts +2 -0
  89. package/dist/types/mixins/index.d.ts +2 -1
  90. package/dist/types/session/SessionClient.d.ts +28 -0
  91. package/dist/types/session/accountDialogController.d.ts +187 -9
  92. package/dist/types/session/identityPin.d.ts +87 -0
  93. package/dist/types/session/identitySession.d.ts +105 -0
  94. package/dist/types/session/projectSessionState.d.ts +21 -19
  95. package/dist/types/session/refresh.d.ts +32 -1
  96. package/dist/types/utils/commonsDelivery.d.ts +105 -0
  97. package/dist/types/utils/oauthPkce.d.ts +21 -1
  98. package/package.json +3 -3
  99. package/src/HttpService.ts +67 -6
  100. package/src/__tests__/inSessionRefresh.test.ts +67 -0
  101. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
  102. package/src/boot/sessionColdBoot.ts +183 -37
  103. package/src/i18n/locales/ar-SA.json +13 -1
  104. package/src/i18n/locales/ca-ES.json +13 -1
  105. package/src/i18n/locales/de-DE.json +13 -1
  106. package/src/i18n/locales/en-US.json +13 -4
  107. package/src/i18n/locales/es-ES.json +13 -4
  108. package/src/i18n/locales/fr-FR.json +13 -1
  109. package/src/i18n/locales/it-IT.json +13 -1
  110. package/src/i18n/locales/ja-JP.json +13 -1
  111. package/src/i18n/locales/ko-KR.json +13 -1
  112. package/src/i18n/locales/pt-PT.json +13 -1
  113. package/src/i18n/locales/zh-CN.json +13 -1
  114. package/src/index.ts +49 -0
  115. package/src/mixins/OxyServices.auth.ts +464 -24
  116. package/src/mixins/OxyServices.deviceBoot.ts +54 -2
  117. package/src/mixins/OxyServices.notifications.ts +142 -0
  118. package/src/mixins/OxyServices.privacy.ts +6 -0
  119. package/src/mixins/OxyServices.user.ts +3 -0
  120. package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
  121. package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
  122. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
  123. package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +2 -0
  124. package/src/mixins/index.ts +6 -0
  125. package/src/session/SessionClient.ts +57 -4
  126. package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
  127. package/src/session/__tests__/accountDialogController.test.ts +812 -1
  128. package/src/session/__tests__/identityPin.test.ts +131 -0
  129. package/src/session/__tests__/identitySession.test.ts +194 -0
  130. package/src/session/__tests__/projectSessionState.test.ts +88 -0
  131. package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
  132. package/src/session/accountDialogController.ts +435 -44
  133. package/src/session/identityPin.ts +310 -0
  134. package/src/session/identitySession.ts +206 -0
  135. package/src/session/projectSessionState.ts +61 -13
  136. package/src/session/refresh.ts +103 -9
  137. package/src/utils/__tests__/oauthPkce.test.ts +51 -0
  138. package/src/utils/commonsDelivery.ts +132 -0
  139. package/src/utils/oauthPkce.ts +56 -3
@@ -21,6 +21,17 @@
21
21
  * - the "Sign in with Oxy" device flow (same-device shared-keychain via
22
22
  * `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
23
23
  * via `startCommonsSignIn` → poll → `claimSessionByToken`);
24
+ * - AUTOMATIC delivery selection for that flow (issue #691): the user presses
25
+ * ONE primary action and the controller — not the user — picks how the
26
+ * request reaches their Commons identity, by gathering the facts
27
+ * (`platform`, `commonsAvailability`, and the `targets` a bearer-authorized
28
+ * `deliverCommonsSignIn` reached) and handing them to the pure
29
+ * `selectCommonsDelivery`. Exactly one route is primary; alternatives are
30
+ * never chained behind it, they are state (`signIn.route`,
31
+ * `signIn.routeFailed`) the UI reveals on its own terms;
32
+ * - honest, non-sensitive PROGRESS for that flow (`signIn.progress`), derived
33
+ * only from real signals — the chosen route, `pushSentAt`, `openedAt`,
34
+ * `authorized`, and the commit — never from an optimistic timeline;
24
35
  * - `commonsAvailability` — whether Commons is installed on this device
25
36
  * (native only, via the injected `canOpenApp` probe), so the QR view can
26
37
  * offer a "Get Commons" fallback instead of a same-device dead end.
@@ -47,6 +58,37 @@ import { logger } from '../logger/index.js';
47
58
  import { extractErrorStatus } from '../utils/errorUtils.js';
48
59
  import { CENTRAL_IDP_APEX } from '../utils/authWebUrl.js';
49
60
  import { projectSwitchableAccounts, switchableAccountIds, } from './accountProjection.js';
61
+ import { pushTargetsFromDelivery, selectCommonsDelivery, } from '../utils/commonsDelivery.js';
62
+ /**
63
+ * Derive the surface-facing progress from the flow's real facts. Pure, total,
64
+ * and the single place the ladder is defined.
65
+ *
66
+ * Ordering within `'waiting'` is most-specific-first, so a late-arriving weaker
67
+ * signal can never pull the display backwards.
68
+ */
69
+ function deriveSignInProgress(facts) {
70
+ switch (facts.phase) {
71
+ case 'idle':
72
+ case 'error':
73
+ return 'idle';
74
+ case 'starting':
75
+ return 'preparing';
76
+ case 'authorized':
77
+ return 'confirming-identity';
78
+ case 'completed':
79
+ return 'identity-confirmed';
80
+ case 'waiting':
81
+ if (facts.openedAt !== null)
82
+ return 'opened-in-commons';
83
+ // `route === 'await-push'` is itself a server-confirmed dispatch (the
84
+ // route is only chosen when `deliverCommonsSignIn` reported ≥1 target),
85
+ // so it is a real signal — not an optimistic assumption that a push will
86
+ // arrive. `pushSentAt` is the same fact re-confirmed by the status poll.
87
+ if (facts.pushSentAt !== null || facts.route === 'await-push')
88
+ return 'delivered-to-commons';
89
+ return facts.route === null ? 'preparing' : 'awaiting-approval';
90
+ }
91
+ }
50
92
  /**
51
93
  * Slow FALLBACK poll cadence for the QR flow. The `/auth-session` socket delivers
52
94
  * the approval instantly via `auth_update`; this poll only covers the case where
@@ -62,12 +104,30 @@ const AUTH_SESSION_NAMESPACE = '/auth-session';
62
104
  * itself is the flow's `qrPayload`.
63
105
  */
64
106
  const COMMONS_APP_SCHEME = 'oxycommons://';
65
- const IDLE_SIGN_IN = {
107
+ const IDLE_SIGN_IN_FACTS = {
66
108
  phase: 'idle',
67
109
  authorizeCode: null,
68
110
  qrPayload: null,
69
111
  expiresAt: null,
70
112
  error: null,
113
+ route: null,
114
+ routeFailed: false,
115
+ pushSentAt: null,
116
+ openedAt: null,
117
+ };
118
+ const IDLE_SIGN_IN = {
119
+ ...IDLE_SIGN_IN_FACTS,
120
+ progress: deriveSignInProgress(IDLE_SIGN_IN_FACTS),
121
+ };
122
+ /**
123
+ * Terminal SUCCESS state: the session was claimed and committed. Holds no live
124
+ * resources and no request handles — only the terminal progress the surface
125
+ * shows ("Identity confirmed") before it closes.
126
+ */
127
+ const COMPLETED_SIGN_IN = {
128
+ ...IDLE_SIGN_IN_FACTS,
129
+ phase: 'completed',
130
+ progress: deriveSignInProgress({ ...IDLE_SIGN_IN_FACTS, phase: 'completed' }),
71
131
  };
72
132
  function errorMessage(error) {
73
133
  return error instanceof Error ? error.message : String(error);
@@ -124,6 +184,7 @@ export class AccountDialogController {
124
184
  this.socketFactory = options.socketFactory;
125
185
  this.openPopup = options.openPopup;
126
186
  this.hubBaseUrl = options.hubBaseUrl ?? `https://auth.${CENTRAL_IDP_APEX}`;
187
+ this.platform = options.platform ?? 'unknown';
127
188
  this.snapshot = this.computeSnapshot();
128
189
  }
129
190
  // =========================================================================
@@ -253,6 +314,13 @@ export class AccountDialogController {
253
314
  if (this.view === view)
254
315
  return;
255
316
  this.view = view;
317
+ // A `'completed'` flow owns no timers, socket, popup, or token — it is only
318
+ // the terminal "Identity confirmed" the finished surface showed. Moving to
319
+ // another view is a NEW intention, so drop it; otherwise a later `add()`
320
+ // would open on the previous sign-in's terminal state.
321
+ if (this.signIn.phase === 'completed') {
322
+ this.signIn = IDLE_SIGN_IN;
323
+ }
256
324
  this.emit();
257
325
  }
258
326
  /** Switch to the "add account" view (the sign-in entry chooser). */
@@ -436,7 +504,7 @@ export class AccountDialogController {
436
504
  */
437
505
  async signInWithOxy() {
438
506
  this.setView('qr');
439
- this.setSignIn({ ...IDLE_SIGN_IN, phase: 'starting' });
507
+ this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'starting' });
440
508
  try {
441
509
  const session = await this.oxyServices.signInWithSharedIdentity();
442
510
  if (session) {
@@ -460,7 +528,7 @@ export class AccountDialogController {
460
528
  async showQr() {
461
529
  this.cancelSignIn();
462
530
  this.setView('qr');
463
- await this.startDeviceFlowSession();
531
+ await this.startDeviceFlowSession({ deliver: true });
464
532
  }
465
533
  /**
466
534
  * Web-only: "Sign in with a passkey" on a non-Oxy origin cannot run the
@@ -486,10 +554,16 @@ export class AccountDialogController {
486
554
  this.setView('qr');
487
555
  const popup = this.openPopup?.() ?? null;
488
556
  if (!popup) {
489
- await this.startDeviceFlowSession();
557
+ await this.startDeviceFlowSession({ deliver: true });
490
558
  return;
491
559
  }
492
- const handle = await this.startDeviceFlowSession();
560
+ // The hub popup IS the primary surface here, chosen explicitly by the user —
561
+ // so this flow does NOT run automatic Commons delivery (ringing the user's
562
+ // phone because they asked for a passkey would be exactly the "menu of
563
+ // methods" the one-primary-action rule forbids). The underlying request is
564
+ // the same `AuthSession`, and its Commons route stays the QR the view
565
+ // renders beneath the popup.
566
+ const handle = await this.startDeviceFlowSession({ deliver: false });
493
567
  if (!handle) {
494
568
  popup.close();
495
569
  return;
@@ -511,42 +585,148 @@ export class AccountDialogController {
511
585
  * same `startCommonsSignIn` → poll/socket wiring either way. Returns the
512
586
  * handle on success (already reflected in `signIn`), or `null` on failure
513
587
  * (already set as `signIn.error`).
588
+ *
589
+ * @param opts.deliver - Whether to run automatic Commons delivery selection
590
+ * ({@link resolveDeliveryRoute}). `true` for the normal one-primary-action
591
+ * entry; `false` when the caller already owns the primary surface (the
592
+ * passkey hub popup), where the request's Commons route is simply the QR.
514
593
  */
515
- async startDeviceFlowSession() {
594
+ async startDeviceFlowSession(opts) {
516
595
  if (!this.clientId) {
517
- this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: 'This app is not configured for sign-in (missing clientId).' });
596
+ this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: 'This app is not configured for sign-in (missing clientId).' });
518
597
  return null;
519
598
  }
520
- this.setSignIn({ ...IDLE_SIGN_IN, phase: 'starting' });
599
+ this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'starting' });
521
600
  try {
522
601
  const handle = await this.oxyServices.startCommonsSignIn({ clientId: this.clientId });
523
602
  this.signInToken = handle.sessionToken;
524
603
  this.setSignIn({
604
+ ...IDLE_SIGN_IN_FACTS,
525
605
  phase: 'waiting',
526
606
  authorizeCode: handle.authorizeCode,
527
607
  qrPayload: handle.qrPayload,
528
608
  expiresAt: handle.expiresAt,
529
- error: null,
609
+ // No route yet: the surface shows "Preparing request" until the primary
610
+ // route is resolved below. It is never guessed in the meantime.
611
+ route: opts.deliver ? null : 'qr',
530
612
  });
531
613
  // Primary path: an instant `auth_update` wake over the `/auth-session`
532
614
  // socket. The poll below is only the fallback for when the socket can't
533
615
  // connect, so it now runs at the slow fallback cadence.
534
616
  this.openAuthSessionSocket(handle.sessionToken);
535
617
  this.scheduleNextPoll(handle.sessionToken);
536
- // Same-device convenience: if Commons is confirmed installed (native
537
- // only — stays `'unknown'` on web, where this never opens anything),
538
- // deep-link straight into its approve screen with the same
539
- // `oxycommons://approve?...` payload the QR encodes. The QR + polling
540
- // stay live as the fallback, so a user who dismisses the app-open still
541
- // completes the sign-in by scanning.
542
- void this.deepLinkIntoCommonsIfAvailable(handle.qrPayload);
618
+ if (opts.deliver) {
619
+ // Non-blocking on purpose: the QR/authorizeCode are already renderable
620
+ // and the popup caller can navigate immediately, while the route (a
621
+ // local probe plus at most one delivery round-trip) resolves behind it.
622
+ void this.resolveDeliveryRoute(handle);
623
+ }
543
624
  return handle;
544
625
  }
545
626
  catch (error) {
546
- this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: errorMessage(error) });
627
+ this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: errorMessage(error) });
547
628
  return null;
548
629
  }
549
630
  }
631
+ /**
632
+ * Choose and carry out the ONE primary delivery route for the active request
633
+ * (issue #691, "Automatic delivery selection").
634
+ *
635
+ * The controller gathers the facts — is a verified Commons link openable on
636
+ * THIS device, and how many known Commons installations did Oxy actually push
637
+ * to — and hands them to the pure {@link selectCommonsDelivery}. The selector
638
+ * owns the decision; this method owns only the observations and the single
639
+ * action the chosen route implies. It never cascades: a route that yields
640
+ * nothing resolves to QR *before* a route is chosen, never after.
641
+ */
642
+ async resolveDeliveryRoute(handle) {
643
+ if (!this.isAwaitingApproval(handle.sessionToken))
644
+ return;
645
+ // Fact 1 — a VERIFIED Commons link openable on this very device. Native
646
+ // only; on web `commonsAvailability` stays `'unknown'` (a browser cannot be
647
+ // asked whether a custom scheme is registered) and this is simply `false`.
648
+ if (this.canOpenApp &&
649
+ (this.commonsAvailability === 'unknown' || this.commonsAvailability === 'checking')) {
650
+ await this.resolveCommonsAvailability();
651
+ if (!this.isAwaitingApproval(handle.sessionToken))
652
+ return;
653
+ }
654
+ const commonsAvailable = this.commonsAvailability === 'available';
655
+ // Fact 2 — how many known Commons installations the server pushed to.
656
+ const pushTargets = await this.deliverToKnownCommons(handle.authorizeCode, commonsAvailable);
657
+ if (!this.isAwaitingApproval(handle.sessionToken))
658
+ return;
659
+ const route = selectCommonsDelivery({ platform: this.platform, commonsAvailable, pushTargets });
660
+ // The only route that has an action to perform on this device — and the only
661
+ // one that can fail here. `'await-push'` was already dispatched server-side;
662
+ // `'qr'` is rendered by the surface from `qrPayload`.
663
+ const routeFailed = route === 'open-commons' ? !this.openCommonsLink(handle.qrPayload) : false;
664
+ this.patchSignIn({ route, routeFailed });
665
+ }
666
+ /**
667
+ * Whether `sessionToken` is still THE request this surface is waiting on.
668
+ *
669
+ * Guards every step of the asynchronous route resolution: a cancelled,
670
+ * superseded, failed, or already-approved flow must neither open Commons nor
671
+ * mutate the surface — an approval that lands mid-resolution would otherwise
672
+ * be followed by a pointless app switch.
673
+ */
674
+ isAwaitingApproval(sessionToken) {
675
+ return this.signInToken === sessionToken && this.signIn.phase === 'waiting';
676
+ }
677
+ /**
678
+ * Ask Oxy to deliver the pending request to the identity's known Commons
679
+ * installations, returning how many it reached (`0` when delivery is not
680
+ * applicable, not permitted, or reached nobody).
681
+ *
682
+ * Two hard rules, both from the issue:
683
+ * - **Never push from an unauthenticated surface.** `deliverCommonsSignIn`
684
+ * is bearer-required precisely because a request that merely carries a
685
+ * typed-in username must never be able to ring somebody's phone. Without a
686
+ * planted bearer the call is not made AT ALL — not made-and-failed.
687
+ * - **Zero targets and a failed delivery are the same normal outcome.** Both
688
+ * return `0`, which resolves the primary route to QR silently. Neither is
689
+ * surfaced as an error: there is nothing the user did wrong and nothing for
690
+ * them to fix.
691
+ */
692
+ async deliverToKnownCommons(authorizeCode, commonsAvailable) {
693
+ // Route 1 (mobile with a verified local Commons link) reaches the identity
694
+ // on this very device — pushing as well would notify a second surface for a
695
+ // request the user is about to confirm here.
696
+ if (this.platform === 'mobile' && commonsAvailable)
697
+ return 0;
698
+ if (!this.isAuthenticated())
699
+ return 0;
700
+ try {
701
+ const result = await this.oxyServices.deliverCommonsSignIn(authorizeCode);
702
+ // `delivered: false` with `targets > 0` is a transport failure, not a
703
+ // reachable install — the shared helper owns that rule so the surface
704
+ // can never park a user on "check your phone" for a push that failed.
705
+ return pushTargetsFromDelivery(result);
706
+ }
707
+ catch (error) {
708
+ logger.debug('[AccountDialogController] Commons delivery unavailable (QR route)', { component: 'AccountDialogController' }, error);
709
+ return 0;
710
+ }
711
+ }
712
+ /**
713
+ * Open the verified Commons link for the `'open-commons'` route. Returns
714
+ * whether the navigation was actually handed off, so a missing opener or a
715
+ * throwing one becomes an observable `routeFailed` instead of a silent dead
716
+ * end the user stares at.
717
+ */
718
+ openCommonsLink(qrPayload) {
719
+ if (!this.openUrl)
720
+ return false;
721
+ try {
722
+ this.openUrl(qrPayload);
723
+ return true;
724
+ }
725
+ catch (error) {
726
+ logger.debug('[AccountDialogController] Commons deep link failed', { component: 'AccountDialogController' }, error);
727
+ return false;
728
+ }
729
+ }
550
730
  /**
551
731
  * Poll {@link PopupWindowHandle.closed} so a user who dismisses the hub
552
732
  * popup without completing sign-in gets prompt feedback (there is no DOM
@@ -561,7 +741,12 @@ export class AccountDialogController {
561
741
  return;
562
742
  this.clearPopupWatchTimer();
563
743
  if (this.signIn.phase === 'starting' || this.signIn.phase === 'waiting') {
744
+ // Closing the surface cancels the REQUEST too, not just this listener.
745
+ const pendingCode = this.signIn.authorizeCode;
564
746
  this.failSignIn('Sign-in was cancelled.');
747
+ if (pendingCode) {
748
+ void this.withdrawRequest(pendingCode);
749
+ }
565
750
  }
566
751
  }, 1000);
567
752
  }
@@ -614,32 +799,43 @@ export class AccountDialogController {
614
799
  this.emit();
615
800
  }
616
801
  /**
617
- * When Commons is confirmed installed, deep-link straight into its approve
618
- * screen via the injected `openUrl` with the same `oxycommons://approve?...`
619
- * payload the QR encodes. Best-effort and non-blocking — the QR/polling
620
- * fallback stays live regardless of the outcome here.
802
+ * Tear down the active sign-in device flow (timers + socket + popup + token),
803
+ * WITHDRAW the request server-side, and reset to idle.
804
+ *
805
+ * Cancellation has to converge in both directions: the surface closing must
806
+ * cancel the request, not just stop listening to it. Without the withdrawal a
807
+ * dismissed QR would stay approvable until it expired, so a later scan of a
808
+ * stale code could authorize a session nobody is waiting for.
621
809
  */
622
- async deepLinkIntoCommonsIfAvailable(qrPayload) {
623
- if (!this.openUrl)
624
- return;
625
- if (this.commonsAvailability === 'unknown' || this.commonsAvailability === 'checking') {
626
- // The eager `start()` probe hasn't resolved yet (or was never run, e.g.
627
- // `showQr` called without a prior `start()`) — resolve it now rather
628
- // than skipping the deep link.
629
- await this.resolveCommonsAvailability();
630
- }
631
- if (this.commonsAvailability === 'available') {
632
- this.openUrl(qrPayload);
633
- }
634
- }
635
- /** Tear down the active sign-in device flow (timers + socket + popup + token) and reset to idle. */
636
810
  cancelSignIn() {
811
+ // Capture before the teardown clears it, and only for a request that can
812
+ // still be approved — a completed/failed flow has nothing to withdraw.
813
+ const pendingCode = this.signIn.phase === 'starting' || this.signIn.phase === 'waiting'
814
+ ? this.signIn.authorizeCode
815
+ : null;
637
816
  this.clearPollTimer();
638
817
  this.closeAuthSessionSocket();
639
818
  this.closeActivePopup();
640
819
  this.signInToken = null;
641
820
  if (this.signIn !== IDLE_SIGN_IN) {
642
- this.setSignIn(IDLE_SIGN_IN);
821
+ this.setSignIn(IDLE_SIGN_IN_FACTS);
822
+ }
823
+ if (pendingCode) {
824
+ void this.withdrawRequest(pendingCode);
825
+ }
826
+ }
827
+ /**
828
+ * Best-effort server-side withdrawal of a request this surface abandoned
829
+ * (`POST /auth/session/deny/:authorizeCode`). Fire-and-forget by design: the
830
+ * local teardown already happened, and a race with an approval that just
831
+ * landed legitimately rejects here — neither outcome is worth surfacing.
832
+ */
833
+ async withdrawRequest(authorizeCode) {
834
+ try {
835
+ await this.oxyServices.denyCommonsSignIn(authorizeCode);
836
+ }
837
+ catch (error) {
838
+ logger.debug('[AccountDialogController] request withdrawal failed', { component: 'AccountDialogController' }, error);
643
839
  }
644
840
  }
645
841
  // =========================================================================
@@ -673,6 +869,18 @@ export class AccountDialogController {
673
869
  const status = await this.oxyServices.pollCommonsSignIn(sessionToken);
674
870
  if (this.signInToken !== sessionToken)
675
871
  return; // cancelled mid-request
872
+ // Delivery PROGRESS first: it is reported alongside every status, and
873
+ // recording it before the terminal branches means a poll that also
874
+ // carries the approval still leaves an honest trail behind it.
875
+ this.recordDeliveryProgress(status.pushSentAt, status.openedAt);
876
+ const purpose = status.purpose === 'oauth_authorization' ? 'oauth_authorization' : 'device_sign_in';
877
+ if (status.authorized && purpose === 'oauth_authorization') {
878
+ // OAuth-bound sessions mint no sessionId on approval — they finalize
879
+ // into an authorization code. The account dialog only starts device
880
+ // sign-in today; stop rather than poll until expiry.
881
+ this.failSignIn('This sign-in flow cannot be completed here. Use the app\'s OAuth sign-in instead.');
882
+ return;
883
+ }
676
884
  if (status.authorized && status.sessionId) {
677
885
  this.clearPollTimer();
678
886
  await this.claimAndComplete(status.sessionId, sessionToken);
@@ -699,8 +907,23 @@ export class AccountDialogController {
699
907
  this.pollInFlight = false;
700
908
  }
701
909
  }
910
+ /**
911
+ * Record server-reported delivery progress on the active flow.
912
+ *
913
+ * Monotone and additive: a timestamp is only ever adopted, never replaced or
914
+ * cleared, so an older API build (or a partial payload) that omits a field can
915
+ * at most fail to advance the surface — it can never walk it backwards.
916
+ * Emits only on a real change, so a steady poll does not churn the snapshot.
917
+ */
918
+ recordDeliveryProgress(pushSentAt, openedAt) {
919
+ const nextPushSentAt = this.signIn.pushSentAt ?? pushSentAt ?? null;
920
+ const nextOpenedAt = this.signIn.openedAt ?? openedAt ?? null;
921
+ if (nextPushSentAt === this.signIn.pushSentAt && nextOpenedAt === this.signIn.openedAt)
922
+ return;
923
+ this.patchSignIn({ pushSentAt: nextPushSentAt, openedAt: nextOpenedAt });
924
+ }
702
925
  async claimAndComplete(sessionId, sessionToken) {
703
- this.setSignIn({ ...this.signIn, phase: 'authorized' });
926
+ this.patchSignIn({ phase: 'authorized' });
704
927
  let claimed;
705
928
  try {
706
929
  claimed = await this.oxyServices.claimSessionByToken(sessionToken);
@@ -746,7 +969,9 @@ export class AccountDialogController {
746
969
  this.clearPollTimer();
747
970
  this.closeAuthSessionSocket();
748
971
  this.closeActivePopup();
749
- this.signIn = IDLE_SIGN_IN;
972
+ // Terminal SUCCESS, not idle: the surface gets one honest frame to show
973
+ // "Identity confirmed" before it closes. Cleared on the next view change.
974
+ this.signIn = COMPLETED_SIGN_IN;
750
975
  this.view = 'accounts';
751
976
  this.emit();
752
977
  this.onSignedIn?.(user);
@@ -779,7 +1004,7 @@ export class AccountDialogController {
779
1004
  this.closeAuthSessionSocket();
780
1005
  this.closeActivePopup();
781
1006
  this.signInToken = null;
782
- this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: message });
1007
+ this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: message });
783
1008
  }
784
1009
  clearPollTimer() {
785
1010
  if (this.pollTimer !== null) {
@@ -861,10 +1086,32 @@ export class AccountDialogController {
861
1086
  // =========================================================================
862
1087
  // Snapshot plumbing
863
1088
  // =========================================================================
864
- setSignIn(next) {
865
- this.signIn = next;
1089
+ /**
1090
+ * Replace the device-flow state from its FACTS, re-deriving `progress`. The
1091
+ * only writer of `this.signIn` besides the two terminal constants — which is
1092
+ * what keeps `progress` impossible to set by hand, and therefore impossible
1093
+ * to advance without a fact behind it.
1094
+ */
1095
+ setSignIn(facts) {
1096
+ this.signIn = { ...facts, progress: deriveSignInProgress(facts) };
866
1097
  this.emit();
867
1098
  }
1099
+ /** Update a subset of the device-flow facts, re-deriving `progress`. */
1100
+ patchSignIn(patch) {
1101
+ const { phase, authorizeCode, qrPayload, expiresAt, error, route, routeFailed, pushSentAt, openedAt, } = this.signIn;
1102
+ this.setSignIn({
1103
+ phase,
1104
+ authorizeCode,
1105
+ qrPayload,
1106
+ expiresAt,
1107
+ error,
1108
+ route,
1109
+ routeFailed,
1110
+ pushSentAt,
1111
+ openedAt,
1112
+ ...patch,
1113
+ });
1114
+ }
868
1115
  computeSnapshot() {
869
1116
  const state = this.sessionClient.getState();
870
1117
  return {