@oxyhq/core 12.10.5 → 12.11.0

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