@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.
@@ -59,6 +70,12 @@ import {
59
70
  } from './accountProjection';
60
71
  import type { AccountNode } from '../mixins/OxyServices.accounts';
61
72
  import type { CommonsSignInHandle } from '../mixins/OxyServices.auth';
73
+ import {
74
+ pushTargetsFromDelivery,
75
+ selectCommonsDelivery,
76
+ type CommonsDeliveryPlatform,
77
+ type CommonsDeliveryRoute,
78
+ } from '../utils/commonsDelivery';
62
79
 
63
80
  /** The dialog's top-level view. */
64
81
  export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add' | 'signup';
@@ -76,8 +93,54 @@ export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add' | 'signup';
76
93
  */
77
94
  export type CommonsAvailability = 'unknown' | 'checking' | 'available' | 'unavailable';
78
95
 
79
- /** Lifecycle phase of the "Sign in with Oxy" device flow. */
80
- export type SignInFlowPhase = 'idle' | 'starting' | 'waiting' | 'authorized' | 'error';
96
+ /**
97
+ * Lifecycle phase of the "Sign in with Oxy" device flow — the RESOURCE state
98
+ * (is there a live request, a claim in flight, a terminal outcome), as opposed
99
+ * to {@link SignInProgress}, which is what the user is told.
100
+ *
101
+ * `'completed'` is terminal-but-successful: the session was claimed and
102
+ * committed, nothing is in flight, and the surface may show "Identity
103
+ * confirmed" before it goes away. It is cleared back to `'idle'` the moment the
104
+ * dialog moves to another view (a new intention), so a later sign-in entry can
105
+ * never inherit the previous flow's terminal state.
106
+ */
107
+ export type SignInFlowPhase =
108
+ | 'idle'
109
+ | 'starting'
110
+ | 'waiting'
111
+ | 'authorized'
112
+ | 'completed'
113
+ | 'error';
114
+
115
+ /**
116
+ * Ordered, non-sensitive progress of the active request — the ONE thing the
117
+ * sign-in surface renders its status line from (issue #691, Phase 5).
118
+ *
119
+ * Every step is DERIVED from a real fact the controller observed; there is no
120
+ * optimistic sequence and no timer that advances it. The mapping the UI is
121
+ * expected to render (copy lives in the UI layer, never here):
122
+ *
123
+ * | value | shown as | advanced by |
124
+ * |------------------------|--------------------------------|-------------|
125
+ * | `idle` | (nothing) | no live request, or a terminal error |
126
+ * | `preparing` | "Preparing request" | the request is being created / its route is still being resolved |
127
+ * | `awaiting-approval` | route-specific waiting copy | the primary route is known (`qr` → the QR itself, `open-commons` → "Continue in Commons") |
128
+ * | `delivered-to-commons` | "Check Commons on your phone" | the server confirmed a push to ≥1 known Commons install (`await-push`, or `pushSentAt`) |
129
+ * | `opened-in-commons` | "Opened in Commons" | the approver reported `openedAt` |
130
+ * | `confirming-identity` | "Confirming identity" | the request reported `authorized`; the claim/commit is running |
131
+ * | `identity-confirmed` | "Identity confirmed" | the session was claimed and committed |
132
+ *
133
+ * `opened-in-commons` and `delivered-to-commons` are PROGRESS only — neither is
134
+ * evidence of an approval. Only `authorized` moves the flow forward.
135
+ */
136
+ export type SignInProgress =
137
+ | 'idle'
138
+ | 'preparing'
139
+ | 'awaiting-approval'
140
+ | 'delivered-to-commons'
141
+ | 'opened-in-commons'
142
+ | 'confirming-identity'
143
+ | 'identity-confirmed';
81
144
 
82
145
  /**
83
146
  * Minimal structural handle over a popup `Window` — just enough for the
@@ -93,7 +156,14 @@ export interface PopupWindowHandle {
93
156
  location: { href: string };
94
157
  }
95
158
 
96
- /** State of the "Sign in with Oxy" (shared-key / QR) device flow. */
159
+ /**
160
+ * State of the "Sign in with Oxy" (shared-key / QR) device flow.
161
+ *
162
+ * Everything here is safe to render. The flow's SECRET credential (the
163
+ * device-flow `sessionToken`) is deliberately absent — it never leaves the
164
+ * controller's private field, so no surface, log, or serialized snapshot can
165
+ * leak it.
166
+ */
97
167
  export interface SignInFlowState {
98
168
  phase: SignInFlowPhase;
99
169
  /**
@@ -110,6 +180,82 @@ export interface SignInFlowState {
110
180
  expiresAt: number | null;
111
181
  /** Human-readable error for the retry UI, or `null`. */
112
182
  error: string | null;
183
+ /**
184
+ * The ONE primary delivery route the controller chose for this request
185
+ * ({@link selectCommonsDelivery}), or `null` while it is still being resolved.
186
+ *
187
+ * There is no chain: exactly one route is primary, and the UI renders exactly
188
+ * one action for it. Alternatives stay behind a "Having trouble?" affordance
189
+ * that the UI reveals on its own terms — see {@link routeFailed}.
190
+ */
191
+ route: CommonsDeliveryRoute | null;
192
+ /**
193
+ * `true` when the chosen primary route could NOT be carried out on this
194
+ * device — today only `'open-commons'` can fail this way (no URL opener was
195
+ * injected, or opening the verified Commons link threw). It is the signal the
196
+ * UI needs to reveal its alternatives; the controller never cascades to
197
+ * another route by itself.
198
+ *
199
+ * A push that reached zero installations is NOT a failure: no capable Commons
200
+ * install is a normal outcome that simply resolves the primary route to
201
+ * `'qr'`.
202
+ */
203
+ routeFailed: boolean;
204
+ /**
205
+ * Server-reported ISO-8601 timestamp of when the request was pushed to a
206
+ * known Commons installation, or `null`. Progress only — never evidence of an
207
+ * approval. Monotone: once observed it is never cleared by a later, emptier
208
+ * poll response.
209
+ */
210
+ pushSentAt: string | null;
211
+ /**
212
+ * Server-reported ISO-8601 timestamp of when the approval route was OPENED in
213
+ * Commons, or `null`. Progress only — never evidence of an approval. Monotone,
214
+ * like {@link pushSentAt}.
215
+ */
216
+ openedAt: string | null;
217
+ /**
218
+ * The derived, ordered progress the surface renders. Always computed from the
219
+ * facts above by {@link deriveSignInProgress} — never assigned directly, so it
220
+ * cannot drift from them or run ahead of a real signal.
221
+ */
222
+ progress: SignInProgress;
223
+ }
224
+
225
+ /**
226
+ * The observable FACTS of a device flow — {@link SignInFlowState} minus the
227
+ * value derived from them. Every mutation of the flow goes through this shape,
228
+ * which is what makes `progress` structurally impossible to set by hand.
229
+ */
230
+ type SignInFlowFacts = Omit<SignInFlowState, 'progress'>;
231
+
232
+ /**
233
+ * Derive the surface-facing progress from the flow's real facts. Pure, total,
234
+ * and the single place the ladder is defined.
235
+ *
236
+ * Ordering within `'waiting'` is most-specific-first, so a late-arriving weaker
237
+ * signal can never pull the display backwards.
238
+ */
239
+ function deriveSignInProgress(facts: SignInFlowFacts): SignInProgress {
240
+ switch (facts.phase) {
241
+ case 'idle':
242
+ case 'error':
243
+ return 'idle';
244
+ case 'starting':
245
+ return 'preparing';
246
+ case 'authorized':
247
+ return 'confirming-identity';
248
+ case 'completed':
249
+ return 'identity-confirmed';
250
+ case 'waiting':
251
+ if (facts.openedAt !== null) return 'opened-in-commons';
252
+ // `route === 'await-push'` is itself a server-confirmed dispatch (the
253
+ // route is only chosen when `deliverCommonsSignIn` reported ≥1 target),
254
+ // so it is a real signal — not an optimistic assumption that a push will
255
+ // arrive. `pushSentAt` is the same fact re-confirmed by the status poll.
256
+ if (facts.pushSentAt !== null || facts.route === 'await-push') return 'delivered-to-commons';
257
+ return facts.route === null ? 'preparing' : 'awaiting-approval';
258
+ }
113
259
  }
114
260
 
115
261
  /** Immutable snapshot consumed by `useSyncExternalStore`. */
@@ -224,6 +370,18 @@ export interface AccountDialogControllerOptions {
224
370
  * `https://auth.${CENTRAL_IDP_APEX}`). Overridable for local/staging testing.
225
371
  */
226
372
  hubBaseUrl?: string;
373
+ /**
374
+ * Which surface the sign-in is initiated from — a FACT supplied by the
375
+ * consumer, because only the consumer can classify its own environment
376
+ * (native → `'mobile'`; web → `'mobile'` for a mobile browser, `'desktop'`
377
+ * otherwise). Headless core never sniffs a user agent or a platform global.
378
+ *
379
+ * Feeds {@link selectCommonsDelivery} verbatim. Defaults to `'unknown'`,
380
+ * which is a first-class value there: an unclassified surface never opts into
381
+ * the deep-link route, because a custom-scheme navigation that does not
382
+ * resolve is a dead end with no automatic way back.
383
+ */
384
+ platform?: CommonsDeliveryPlatform;
227
385
  }
228
386
 
229
387
  /**
@@ -244,12 +402,32 @@ const AUTH_SESSION_NAMESPACE = '/auth-session';
244
402
  */
245
403
  const COMMONS_APP_SCHEME = 'oxycommons://';
246
404
 
247
- const IDLE_SIGN_IN: SignInFlowState = {
405
+ const IDLE_SIGN_IN_FACTS: SignInFlowFacts = {
248
406
  phase: 'idle',
249
407
  authorizeCode: null,
250
408
  qrPayload: null,
251
409
  expiresAt: null,
252
410
  error: null,
411
+ route: null,
412
+ routeFailed: false,
413
+ pushSentAt: null,
414
+ openedAt: null,
415
+ };
416
+
417
+ const IDLE_SIGN_IN: SignInFlowState = {
418
+ ...IDLE_SIGN_IN_FACTS,
419
+ progress: deriveSignInProgress(IDLE_SIGN_IN_FACTS),
420
+ };
421
+
422
+ /**
423
+ * Terminal SUCCESS state: the session was claimed and committed. Holds no live
424
+ * resources and no request handles — only the terminal progress the surface
425
+ * shows ("Identity confirmed") before it closes.
426
+ */
427
+ const COMPLETED_SIGN_IN: SignInFlowState = {
428
+ ...IDLE_SIGN_IN_FACTS,
429
+ phase: 'completed',
430
+ progress: deriveSignInProgress({ ...IDLE_SIGN_IN_FACTS, phase: 'completed' }),
253
431
  };
254
432
 
255
433
  function errorMessage(error: unknown): string {
@@ -272,6 +450,7 @@ export class AccountDialogController {
272
450
  private readonly socketFactory?: SocketIOFactory;
273
451
  private readonly openPopup?: () => PopupWindowHandle | null;
274
452
  private readonly hubBaseUrl: string;
453
+ private readonly platform: CommonsDeliveryPlatform;
275
454
 
276
455
  private readonly listeners = new Set<SnapshotListener>();
277
456
 
@@ -329,6 +508,7 @@ export class AccountDialogController {
329
508
  this.socketFactory = options.socketFactory;
330
509
  this.openPopup = options.openPopup;
331
510
  this.hubBaseUrl = options.hubBaseUrl ?? `https://auth.${CENTRAL_IDP_APEX}`;
511
+ this.platform = options.platform ?? 'unknown';
332
512
  this.snapshot = this.computeSnapshot();
333
513
  }
334
514
 
@@ -466,6 +646,13 @@ export class AccountDialogController {
466
646
  setView(view: AccountDialogView): void {
467
647
  if (this.view === view) return;
468
648
  this.view = view;
649
+ // A `'completed'` flow owns no timers, socket, popup, or token — it is only
650
+ // the terminal "Identity confirmed" the finished surface showed. Moving to
651
+ // another view is a NEW intention, so drop it; otherwise a later `add()`
652
+ // would open on the previous sign-in's terminal state.
653
+ if (this.signIn.phase === 'completed') {
654
+ this.signIn = IDLE_SIGN_IN;
655
+ }
469
656
  this.emit();
470
657
  }
471
658
 
@@ -652,7 +839,7 @@ export class AccountDialogController {
652
839
  */
653
840
  async signInWithOxy(): Promise<void> {
654
841
  this.setView('qr');
655
- this.setSignIn({ ...IDLE_SIGN_IN, phase: 'starting' });
842
+ this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'starting' });
656
843
  try {
657
844
  const session = await this.oxyServices.signInWithSharedIdentity();
658
845
  if (session) {
@@ -676,7 +863,7 @@ export class AccountDialogController {
676
863
  async showQr(): Promise<void> {
677
864
  this.cancelSignIn();
678
865
  this.setView('qr');
679
- await this.startDeviceFlowSession();
866
+ await this.startDeviceFlowSession({ deliver: true });
680
867
  }
681
868
 
682
869
  /**
@@ -703,10 +890,16 @@ export class AccountDialogController {
703
890
  this.setView('qr');
704
891
  const popup = this.openPopup?.() ?? null;
705
892
  if (!popup) {
706
- await this.startDeviceFlowSession();
893
+ await this.startDeviceFlowSession({ deliver: true });
707
894
  return;
708
895
  }
709
- const handle = await this.startDeviceFlowSession();
896
+ // The hub popup IS the primary surface here, chosen explicitly by the user —
897
+ // so this flow does NOT run automatic Commons delivery (ringing the user's
898
+ // phone because they asked for a passkey would be exactly the "menu of
899
+ // methods" the one-primary-action rule forbids). The underlying request is
900
+ // the same `AuthSession`, and its Commons route stays the QR the view
901
+ // renders beneath the popup.
902
+ const handle = await this.startDeviceFlowSession({ deliver: false });
710
903
  if (!handle) {
711
904
  popup.close();
712
905
  return;
@@ -729,42 +922,157 @@ export class AccountDialogController {
729
922
  * same `startCommonsSignIn` → poll/socket wiring either way. Returns the
730
923
  * handle on success (already reflected in `signIn`), or `null` on failure
731
924
  * (already set as `signIn.error`).
925
+ *
926
+ * @param opts.deliver - Whether to run automatic Commons delivery selection
927
+ * ({@link resolveDeliveryRoute}). `true` for the normal one-primary-action
928
+ * entry; `false` when the caller already owns the primary surface (the
929
+ * passkey hub popup), where the request's Commons route is simply the QR.
732
930
  */
733
- private async startDeviceFlowSession(): Promise<CommonsSignInHandle | null> {
931
+ private async startDeviceFlowSession(opts: { deliver: boolean }): Promise<CommonsSignInHandle | null> {
734
932
  if (!this.clientId) {
735
- this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: 'This app is not configured for sign-in (missing clientId).' });
933
+ this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: 'This app is not configured for sign-in (missing clientId).' });
736
934
  return null;
737
935
  }
738
- this.setSignIn({ ...IDLE_SIGN_IN, phase: 'starting' });
936
+ this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'starting' });
739
937
  try {
740
938
  const handle = await this.oxyServices.startCommonsSignIn({ clientId: this.clientId });
741
939
  this.signInToken = handle.sessionToken;
742
940
  this.setSignIn({
941
+ ...IDLE_SIGN_IN_FACTS,
743
942
  phase: 'waiting',
744
943
  authorizeCode: handle.authorizeCode,
745
944
  qrPayload: handle.qrPayload,
746
945
  expiresAt: handle.expiresAt,
747
- error: null,
946
+ // No route yet: the surface shows "Preparing request" until the primary
947
+ // route is resolved below. It is never guessed in the meantime.
948
+ route: opts.deliver ? null : 'qr',
748
949
  });
749
950
  // Primary path: an instant `auth_update` wake over the `/auth-session`
750
951
  // socket. The poll below is only the fallback for when the socket can't
751
952
  // connect, so it now runs at the slow fallback cadence.
752
953
  this.openAuthSessionSocket(handle.sessionToken);
753
954
  this.scheduleNextPoll(handle.sessionToken);
754
- // Same-device convenience: if Commons is confirmed installed (native
755
- // only — stays `'unknown'` on web, where this never opens anything),
756
- // deep-link straight into its approve screen with the same
757
- // `oxycommons://approve?...` payload the QR encodes. The QR + polling
758
- // stay live as the fallback, so a user who dismisses the app-open still
759
- // completes the sign-in by scanning.
760
- void this.deepLinkIntoCommonsIfAvailable(handle.qrPayload);
955
+ if (opts.deliver) {
956
+ // Non-blocking on purpose: the QR/authorizeCode are already renderable
957
+ // and the popup caller can navigate immediately, while the route (a
958
+ // local probe plus at most one delivery round-trip) resolves behind it.
959
+ void this.resolveDeliveryRoute(handle);
960
+ }
761
961
  return handle;
762
962
  } catch (error) {
763
- this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: errorMessage(error) });
963
+ this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: errorMessage(error) });
764
964
  return null;
765
965
  }
766
966
  }
767
967
 
968
+ /**
969
+ * Choose and carry out the ONE primary delivery route for the active request
970
+ * (issue #691, "Automatic delivery selection").
971
+ *
972
+ * The controller gathers the facts — is a verified Commons link openable on
973
+ * THIS device, and how many known Commons installations did Oxy actually push
974
+ * to — and hands them to the pure {@link selectCommonsDelivery}. The selector
975
+ * owns the decision; this method owns only the observations and the single
976
+ * action the chosen route implies. It never cascades: a route that yields
977
+ * nothing resolves to QR *before* a route is chosen, never after.
978
+ */
979
+ private async resolveDeliveryRoute(handle: CommonsSignInHandle): Promise<void> {
980
+ if (!this.isAwaitingApproval(handle.sessionToken)) return;
981
+
982
+ // Fact 1 — a VERIFIED Commons link openable on this very device. Native
983
+ // only; on web `commonsAvailability` stays `'unknown'` (a browser cannot be
984
+ // asked whether a custom scheme is registered) and this is simply `false`.
985
+ if (
986
+ this.canOpenApp &&
987
+ (this.commonsAvailability === 'unknown' || this.commonsAvailability === 'checking')
988
+ ) {
989
+ await this.resolveCommonsAvailability();
990
+ if (!this.isAwaitingApproval(handle.sessionToken)) return;
991
+ }
992
+ const commonsAvailable = this.commonsAvailability === 'available';
993
+
994
+ // Fact 2 — how many known Commons installations the server pushed to.
995
+ const pushTargets = await this.deliverToKnownCommons(handle.authorizeCode, commonsAvailable);
996
+ if (!this.isAwaitingApproval(handle.sessionToken)) return;
997
+
998
+ const route = selectCommonsDelivery({ platform: this.platform, commonsAvailable, pushTargets });
999
+ // The only route that has an action to perform on this device — and the only
1000
+ // one that can fail here. `'await-push'` was already dispatched server-side;
1001
+ // `'qr'` is rendered by the surface from `qrPayload`.
1002
+ const routeFailed = route === 'open-commons' ? !this.openCommonsLink(handle.qrPayload) : false;
1003
+ this.patchSignIn({ route, routeFailed });
1004
+ }
1005
+
1006
+ /**
1007
+ * Whether `sessionToken` is still THE request this surface is waiting on.
1008
+ *
1009
+ * Guards every step of the asynchronous route resolution: a cancelled,
1010
+ * superseded, failed, or already-approved flow must neither open Commons nor
1011
+ * mutate the surface — an approval that lands mid-resolution would otherwise
1012
+ * be followed by a pointless app switch.
1013
+ */
1014
+ private isAwaitingApproval(sessionToken: string): boolean {
1015
+ return this.signInToken === sessionToken && this.signIn.phase === 'waiting';
1016
+ }
1017
+
1018
+ /**
1019
+ * Ask Oxy to deliver the pending request to the identity's known Commons
1020
+ * installations, returning how many it reached (`0` when delivery is not
1021
+ * applicable, not permitted, or reached nobody).
1022
+ *
1023
+ * Two hard rules, both from the issue:
1024
+ * - **Never push from an unauthenticated surface.** `deliverCommonsSignIn`
1025
+ * is bearer-required precisely because a request that merely carries a
1026
+ * typed-in username must never be able to ring somebody's phone. Without a
1027
+ * planted bearer the call is not made AT ALL — not made-and-failed.
1028
+ * - **Zero targets and a failed delivery are the same normal outcome.** Both
1029
+ * return `0`, which resolves the primary route to QR silently. Neither is
1030
+ * surfaced as an error: there is nothing the user did wrong and nothing for
1031
+ * them to fix.
1032
+ */
1033
+ private async deliverToKnownCommons(authorizeCode: string, commonsAvailable: boolean): Promise<number> {
1034
+ // Route 1 (mobile with a verified local Commons link) reaches the identity
1035
+ // on this very device — pushing as well would notify a second surface for a
1036
+ // request the user is about to confirm here.
1037
+ if (this.platform === 'mobile' && commonsAvailable) return 0;
1038
+ if (!this.isAuthenticated()) return 0;
1039
+ try {
1040
+ const result = await this.oxyServices.deliverCommonsSignIn(authorizeCode);
1041
+ // `delivered: false` with `targets > 0` is a transport failure, not a
1042
+ // reachable install — the shared helper owns that rule so the surface
1043
+ // can never park a user on "check your phone" for a push that failed.
1044
+ return pushTargetsFromDelivery(result);
1045
+ } catch (error) {
1046
+ logger.debug(
1047
+ '[AccountDialogController] Commons delivery unavailable (QR route)',
1048
+ { component: 'AccountDialogController' },
1049
+ error,
1050
+ );
1051
+ return 0;
1052
+ }
1053
+ }
1054
+
1055
+ /**
1056
+ * Open the verified Commons link for the `'open-commons'` route. Returns
1057
+ * whether the navigation was actually handed off, so a missing opener or a
1058
+ * throwing one becomes an observable `routeFailed` instead of a silent dead
1059
+ * end the user stares at.
1060
+ */
1061
+ private openCommonsLink(qrPayload: string): boolean {
1062
+ if (!this.openUrl) return false;
1063
+ try {
1064
+ this.openUrl(qrPayload);
1065
+ return true;
1066
+ } catch (error) {
1067
+ logger.debug(
1068
+ '[AccountDialogController] Commons deep link failed',
1069
+ { component: 'AccountDialogController' },
1070
+ error,
1071
+ );
1072
+ return false;
1073
+ }
1074
+ }
1075
+
768
1076
  /**
769
1077
  * Poll {@link PopupWindowHandle.closed} so a user who dismisses the hub
770
1078
  * popup without completing sign-in gets prompt feedback (there is no DOM
@@ -778,7 +1086,12 @@ export class AccountDialogController {
778
1086
  if (!popup.closed) return;
779
1087
  this.clearPopupWatchTimer();
780
1088
  if (this.signIn.phase === 'starting' || this.signIn.phase === 'waiting') {
1089
+ // Closing the surface cancels the REQUEST too, not just this listener.
1090
+ const pendingCode = this.signIn.authorizeCode;
781
1091
  this.failSignIn('Sign-in was cancelled.');
1092
+ if (pendingCode) {
1093
+ void this.withdrawRequest(pendingCode);
1094
+ }
782
1095
  }
783
1096
  }, 1000);
784
1097
  }
@@ -835,32 +1148,48 @@ export class AccountDialogController {
835
1148
  }
836
1149
 
837
1150
  /**
838
- * When Commons is confirmed installed, deep-link straight into its approve
839
- * screen via the injected `openUrl` with the same `oxycommons://approve?...`
840
- * payload the QR encodes. Best-effort and non-blocking — the QR/polling
841
- * fallback stays live regardless of the outcome here.
1151
+ * Tear down the active sign-in device flow (timers + socket + popup + token),
1152
+ * WITHDRAW the request server-side, and reset to idle.
1153
+ *
1154
+ * Cancellation has to converge in both directions: the surface closing must
1155
+ * cancel the request, not just stop listening to it. Without the withdrawal a
1156
+ * dismissed QR would stay approvable until it expired, so a later scan of a
1157
+ * stale code could authorize a session nobody is waiting for.
842
1158
  */
843
- private async deepLinkIntoCommonsIfAvailable(qrPayload: string): Promise<void> {
844
- if (!this.openUrl) return;
845
- if (this.commonsAvailability === 'unknown' || this.commonsAvailability === 'checking') {
846
- // The eager `start()` probe hasn't resolved yet (or was never run, e.g.
847
- // `showQr` called without a prior `start()`) — resolve it now rather
848
- // than skipping the deep link.
849
- await this.resolveCommonsAvailability();
850
- }
851
- if (this.commonsAvailability === 'available') {
852
- this.openUrl(qrPayload);
853
- }
854
- }
855
-
856
- /** Tear down the active sign-in device flow (timers + socket + popup + token) and reset to idle. */
857
1159
  cancelSignIn(): void {
1160
+ // Capture before the teardown clears it, and only for a request that can
1161
+ // still be approved — a completed/failed flow has nothing to withdraw.
1162
+ const pendingCode =
1163
+ this.signIn.phase === 'starting' || this.signIn.phase === 'waiting'
1164
+ ? this.signIn.authorizeCode
1165
+ : null;
858
1166
  this.clearPollTimer();
859
1167
  this.closeAuthSessionSocket();
860
1168
  this.closeActivePopup();
861
1169
  this.signInToken = null;
862
1170
  if (this.signIn !== IDLE_SIGN_IN) {
863
- this.setSignIn(IDLE_SIGN_IN);
1171
+ this.setSignIn(IDLE_SIGN_IN_FACTS);
1172
+ }
1173
+ if (pendingCode) {
1174
+ void this.withdrawRequest(pendingCode);
1175
+ }
1176
+ }
1177
+
1178
+ /**
1179
+ * Best-effort server-side withdrawal of a request this surface abandoned
1180
+ * (`POST /auth/session/deny/:authorizeCode`). Fire-and-forget by design: the
1181
+ * local teardown already happened, and a race with an approval that just
1182
+ * landed legitimately rejects here — neither outcome is worth surfacing.
1183
+ */
1184
+ private async withdrawRequest(authorizeCode: string): Promise<void> {
1185
+ try {
1186
+ await this.oxyServices.denyCommonsSignIn(authorizeCode);
1187
+ } catch (error) {
1188
+ logger.debug(
1189
+ '[AccountDialogController] request withdrawal failed',
1190
+ { component: 'AccountDialogController' },
1191
+ error,
1192
+ );
864
1193
  }
865
1194
  }
866
1195
 
@@ -895,6 +1224,18 @@ export class AccountDialogController {
895
1224
  try {
896
1225
  const status = await this.oxyServices.pollCommonsSignIn(sessionToken);
897
1226
  if (this.signInToken !== sessionToken) return; // cancelled mid-request
1227
+ // Delivery PROGRESS first: it is reported alongside every status, and
1228
+ // recording it before the terminal branches means a poll that also
1229
+ // carries the approval still leaves an honest trail behind it.
1230
+ this.recordDeliveryProgress(status.pushSentAt, status.openedAt);
1231
+ const purpose = status.purpose === 'oauth_authorization' ? 'oauth_authorization' : 'device_sign_in';
1232
+ if (status.authorized && purpose === 'oauth_authorization') {
1233
+ // OAuth-bound sessions mint no sessionId on approval — they finalize
1234
+ // into an authorization code. The account dialog only starts device
1235
+ // sign-in today; stop rather than poll until expiry.
1236
+ this.failSignIn('This sign-in flow cannot be completed here. Use the app\'s OAuth sign-in instead.');
1237
+ return;
1238
+ }
898
1239
  if (status.authorized && status.sessionId) {
899
1240
  this.clearPollTimer();
900
1241
  await this.claimAndComplete(status.sessionId, sessionToken);
@@ -920,8 +1261,23 @@ export class AccountDialogController {
920
1261
  }
921
1262
  }
922
1263
 
1264
+ /**
1265
+ * Record server-reported delivery progress on the active flow.
1266
+ *
1267
+ * Monotone and additive: a timestamp is only ever adopted, never replaced or
1268
+ * cleared, so an older API build (or a partial payload) that omits a field can
1269
+ * at most fail to advance the surface — it can never walk it backwards.
1270
+ * Emits only on a real change, so a steady poll does not churn the snapshot.
1271
+ */
1272
+ private recordDeliveryProgress(pushSentAt: string | null, openedAt: string | null): void {
1273
+ const nextPushSentAt = this.signIn.pushSentAt ?? pushSentAt ?? null;
1274
+ const nextOpenedAt = this.signIn.openedAt ?? openedAt ?? null;
1275
+ if (nextPushSentAt === this.signIn.pushSentAt && nextOpenedAt === this.signIn.openedAt) return;
1276
+ this.patchSignIn({ pushSentAt: nextPushSentAt, openedAt: nextOpenedAt });
1277
+ }
1278
+
923
1279
  private async claimAndComplete(sessionId: string, sessionToken: string): Promise<void> {
924
- this.setSignIn({ ...this.signIn, phase: 'authorized' });
1280
+ this.patchSignIn({ phase: 'authorized' });
925
1281
  let claimed: {
926
1282
  accessToken: string;
927
1283
  sessionId: string;
@@ -979,7 +1335,9 @@ export class AccountDialogController {
979
1335
  this.clearPollTimer();
980
1336
  this.closeAuthSessionSocket();
981
1337
  this.closeActivePopup();
982
- this.signIn = IDLE_SIGN_IN;
1338
+ // Terminal SUCCESS, not idle: the surface gets one honest frame to show
1339
+ // "Identity confirmed" before it closes. Cleared on the next view change.
1340
+ this.signIn = COMPLETED_SIGN_IN;
983
1341
  this.view = 'accounts';
984
1342
  this.emit();
985
1343
  this.onSignedIn?.(user);
@@ -1017,7 +1375,7 @@ export class AccountDialogController {
1017
1375
  this.closeAuthSessionSocket();
1018
1376
  this.closeActivePopup();
1019
1377
  this.signInToken = null;
1020
- this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: message });
1378
+ this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: message });
1021
1379
  }
1022
1380
 
1023
1381
  private clearPollTimer(): void {
@@ -1097,11 +1455,44 @@ export class AccountDialogController {
1097
1455
  // Snapshot plumbing
1098
1456
  // =========================================================================
1099
1457
 
1100
- private setSignIn(next: SignInFlowState): void {
1101
- this.signIn = next;
1458
+ /**
1459
+ * Replace the device-flow state from its FACTS, re-deriving `progress`. The
1460
+ * only writer of `this.signIn` besides the two terminal constants — which is
1461
+ * what keeps `progress` impossible to set by hand, and therefore impossible
1462
+ * to advance without a fact behind it.
1463
+ */
1464
+ private setSignIn(facts: SignInFlowFacts): void {
1465
+ this.signIn = { ...facts, progress: deriveSignInProgress(facts) };
1102
1466
  this.emit();
1103
1467
  }
1104
1468
 
1469
+ /** Update a subset of the device-flow facts, re-deriving `progress`. */
1470
+ private patchSignIn(patch: Partial<SignInFlowFacts>): void {
1471
+ const {
1472
+ phase,
1473
+ authorizeCode,
1474
+ qrPayload,
1475
+ expiresAt,
1476
+ error,
1477
+ route,
1478
+ routeFailed,
1479
+ pushSentAt,
1480
+ openedAt,
1481
+ } = this.signIn;
1482
+ this.setSignIn({
1483
+ phase,
1484
+ authorizeCode,
1485
+ qrPayload,
1486
+ expiresAt,
1487
+ error,
1488
+ route,
1489
+ routeFailed,
1490
+ pushSentAt,
1491
+ openedAt,
1492
+ ...patch,
1493
+ });
1494
+ }
1495
+
1105
1496
  private computeSnapshot(): AccountDialogSnapshot {
1106
1497
  const state = this.sessionClient.getState();
1107
1498
  return {