@oxyhq/core 12.10.6 → 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 (126) 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/identityPin.js +256 -0
  33. package/dist/cjs/session/identitySession.js +135 -0
  34. package/dist/cjs/session/projectSessionState.js +54 -15
  35. package/dist/cjs/session/refresh.js +73 -8
  36. package/dist/cjs/utils/commonsDelivery.js +59 -0
  37. package/dist/cjs/utils/oauthPkce.js +33 -3
  38. package/dist/esm/.tsbuildinfo +1 -1
  39. package/dist/esm/boot/sessionColdBoot.js +142 -37
  40. package/dist/esm/i18n/locales/ar-SA.json +13 -1
  41. package/dist/esm/i18n/locales/ca-ES.json +13 -1
  42. package/dist/esm/i18n/locales/de-DE.json +13 -1
  43. package/dist/esm/i18n/locales/en-US.json +13 -4
  44. package/dist/esm/i18n/locales/es-ES.json +13 -4
  45. package/dist/esm/i18n/locales/fr-FR.json +13 -1
  46. package/dist/esm/i18n/locales/it-IT.json +13 -1
  47. package/dist/esm/i18n/locales/ja-JP.json +13 -1
  48. package/dist/esm/i18n/locales/ko-KR.json +13 -1
  49. package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
  50. package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
  51. package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
  52. package/dist/esm/i18n/locales/locales/en-US.json +13 -4
  53. package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
  54. package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
  55. package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
  56. package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
  57. package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
  58. package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
  59. package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
  60. package/dist/esm/i18n/locales/pt-PT.json +13 -1
  61. package/dist/esm/i18n/locales/zh-CN.json +13 -1
  62. package/dist/esm/index.js +14 -1
  63. package/dist/esm/mixins/OxyServices.auth.js +257 -17
  64. package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
  65. package/dist/esm/mixins/OxyServices.notifications.js +60 -0
  66. package/dist/esm/mixins/index.js +5 -0
  67. package/dist/esm/session/SessionClient.js +40 -4
  68. package/dist/esm/session/accountDialogController.js +288 -41
  69. package/dist/esm/session/identityPin.js +249 -0
  70. package/dist/esm/session/identitySession.js +131 -0
  71. package/dist/esm/session/projectSessionState.js +54 -15
  72. package/dist/esm/session/refresh.js +73 -8
  73. package/dist/esm/utils/commonsDelivery.js +54 -0
  74. package/dist/esm/utils/oauthPkce.js +31 -2
  75. package/dist/types/.tsbuildinfo +1 -1
  76. package/dist/types/boot/sessionColdBoot.d.ts +38 -1
  77. package/dist/types/index.d.ts +12 -4
  78. package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
  79. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
  80. package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
  81. package/dist/types/mixins/index.d.ts +2 -1
  82. package/dist/types/session/SessionClient.d.ts +28 -0
  83. package/dist/types/session/accountDialogController.d.ts +187 -9
  84. package/dist/types/session/identityPin.d.ts +87 -0
  85. package/dist/types/session/identitySession.d.ts +105 -0
  86. package/dist/types/session/projectSessionState.d.ts +21 -19
  87. package/dist/types/session/refresh.d.ts +32 -1
  88. package/dist/types/utils/commonsDelivery.d.ts +105 -0
  89. package/dist/types/utils/oauthPkce.d.ts +21 -1
  90. package/package.json +2 -2
  91. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
  92. package/src/boot/sessionColdBoot.ts +183 -37
  93. package/src/i18n/locales/ar-SA.json +13 -1
  94. package/src/i18n/locales/ca-ES.json +13 -1
  95. package/src/i18n/locales/de-DE.json +13 -1
  96. package/src/i18n/locales/en-US.json +13 -4
  97. package/src/i18n/locales/es-ES.json +13 -4
  98. package/src/i18n/locales/fr-FR.json +13 -1
  99. package/src/i18n/locales/it-IT.json +13 -1
  100. package/src/i18n/locales/ja-JP.json +13 -1
  101. package/src/i18n/locales/ko-KR.json +13 -1
  102. package/src/i18n/locales/pt-PT.json +13 -1
  103. package/src/i18n/locales/zh-CN.json +13 -1
  104. package/src/index.ts +49 -0
  105. package/src/mixins/OxyServices.auth.ts +464 -24
  106. package/src/mixins/OxyServices.deviceBoot.ts +54 -2
  107. package/src/mixins/OxyServices.notifications.ts +142 -0
  108. package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
  109. package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
  110. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
  111. package/src/mixins/index.ts +6 -0
  112. package/src/session/SessionClient.ts +57 -4
  113. package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
  114. package/src/session/__tests__/accountDialogController.test.ts +812 -1
  115. package/src/session/__tests__/identityPin.test.ts +131 -0
  116. package/src/session/__tests__/identitySession.test.ts +194 -0
  117. package/src/session/__tests__/projectSessionState.test.ts +88 -0
  118. package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
  119. package/src/session/accountDialogController.ts +435 -44
  120. package/src/session/identityPin.ts +310 -0
  121. package/src/session/identitySession.ts +206 -0
  122. package/src/session/projectSessionState.ts +61 -13
  123. package/src/session/refresh.ts +103 -9
  124. package/src/utils/__tests__/oauthPkce.test.ts +51 -0
  125. package/src/utils/commonsDelivery.ts +132 -0
  126. package/src/utils/oauthPkce.ts +56 -3
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Shape of an Expo push token: `ExponentPushToken[…]` (what
3
+ * `getExpoPushTokenAsync()` returns today) or the equivalent `ExpoPushToken[…]`
4
+ * spelling. Deliberately plain ASCII — `@oxyhq/core` ships to Hermes, where
5
+ * Unicode property escapes throw at runtime.
6
+ */
7
+ const EXPO_PUSH_TOKEN_PATTERN = /^Expo(nent)?PushToken\[[^[\]\s]+\]$/;
8
+ export function OxyServicesNotificationsMixin(Base) {
9
+ return class extends Base {
10
+ /**
11
+ * Register this installation's **Expo** push token for the authenticated
12
+ * identity (`POST /notifications/push-token`, bearer required).
13
+ *
14
+ * Idempotent server-side: re-registering the same token for the same
15
+ * identity refreshes the existing row rather than creating a duplicate, so
16
+ * callers can safely re-register on every cold boot.
17
+ *
18
+ * @throws When `expoPushToken` is not an Expo push token — a raw APNs/FCM
19
+ * device token fails here rather than becoming a silently undeliverable
20
+ * registration.
21
+ */
22
+ async registerPushToken(input) {
23
+ try {
24
+ if (!EXPO_PUSH_TOKEN_PATTERN.test(input.expoPushToken)) {
25
+ throw new Error('registerPushToken expects an Expo push token ("ExponentPushToken[...]", from getExpoPushTokenAsync). ' +
26
+ 'A raw APNs/FCM device token from getDevicePushTokenAsync cannot be delivered to.');
27
+ }
28
+ await this.makeRequest('POST', '/notifications/push-token', {
29
+ token: input.expoPushToken,
30
+ platform: input.platform,
31
+ // Omitted entirely when absent so the body stays exactly what the
32
+ // endpoint has always received (the server reads presence).
33
+ ...(input.deviceId ? { deviceId: input.deviceId } : {}),
34
+ ...(input.clientId ? { clientId: input.clientId } : {}),
35
+ }, { cache: false });
36
+ }
37
+ catch (error) {
38
+ throw this.handleError(error);
39
+ }
40
+ }
41
+ /**
42
+ * Retire an **Expo** push token for the authenticated identity
43
+ * (`DELETE /notifications/push-token`, bearer required).
44
+ *
45
+ * Call it when the user turns notifications off, when the vault identity is
46
+ * replaced, and on sign-out — otherwise the installation keeps receiving
47
+ * approval requests for an identity it no longer holds.
48
+ *
49
+ * @param expoPushToken - The exact token previously registered.
50
+ */
51
+ async unregisterPushToken(expoPushToken) {
52
+ try {
53
+ await this.makeRequest('DELETE', '/notifications/push-token', { token: expoPushToken }, { cache: false });
54
+ }
55
+ catch (error) {
56
+ throw this.handleError(error);
57
+ }
58
+ }
59
+ };
60
+ }
@@ -24,6 +24,7 @@ import { OxyServicesUtilityMixin } from './OxyServices.utility.js';
24
24
  import { OxyServicesFeaturesMixin } from './OxyServices.features.js';
25
25
  import { OxyServicesTopicsMixin } from './OxyServices.topics.js';
26
26
  import { OxyServicesContactsMixin } from './OxyServices.contacts.js';
27
+ import { OxyServicesNotificationsMixin } from './OxyServices.notifications.js';
27
28
  import { OxyServicesAppDataMixin } from './OxyServices.appData.js';
28
29
  import { OxyServicesCivicMixin } from './OxyServices.civic.js';
29
30
  import { OxyServicesNodesMixin } from './OxyServices.nodes.js';
@@ -71,6 +72,10 @@ const MIXIN_PIPELINE = [
71
72
  OxyServicesFeaturesMixin,
72
73
  OxyServicesTopicsMixin,
73
74
  OxyServicesContactsMixin,
75
+ // Push-token registration: the one SDK-owned register/unregister pair every
76
+ // Oxy app uses, and what lets a "Sign in with Oxy" request be delivered to a
77
+ // known Commons installation instead of falling back to a QR.
78
+ OxyServicesNotificationsMixin,
74
79
  OxyServicesAppDataMixin,
75
80
  // Civic / Commons "Oxy ID" (public signed cards, Oxy ID QR payload)
76
81
  OxyServicesCivicMixin,
@@ -30,6 +30,15 @@ export class SessionClient {
30
30
  getState() {
31
31
  return this.state;
32
32
  }
33
+ /**
34
+ * The account this client's bearer is pinned to, or `null` when it follows the
35
+ * device's active account (the default). Resolvers are expected to be a plain
36
+ * synchronous read of already-resolved state (see
37
+ * {@link SessionClientOptions.getPinnedAccountId}).
38
+ */
39
+ pinnedAccountId() {
40
+ return this.options.getPinnedAccountId?.() ?? null;
41
+ }
33
42
  subscribe(listener) {
34
43
  this.listeners.add(listener);
35
44
  return () => {
@@ -118,15 +127,26 @@ export class SessionClient {
118
127
  }
119
128
  const previousState = this.state;
120
129
  this.state = next;
130
+ const pinnedAccountId = this.pinnedAccountId();
121
131
  // Plant the sync-supplied active token (it is for `next.activeAccountId`)
122
132
  // now — before the notify below — so the bearer matches the new active
123
133
  // account when subscribers observe it. Guarded on difference to avoid a
124
134
  // redundant token-change notification on an unchanged token (bootstrap
125
- // restate).
126
- if (activeToken && next.activeAccountId !== null && activeToken !== this.host.getAccessToken()) {
135
+ // restate). An identity-bound client only accepts it when the active account
136
+ // IS its pinned account otherwise the token belongs to somebody else's
137
+ // switch and must never displace the pinned bearer.
138
+ if (activeToken &&
139
+ next.activeAccountId !== null &&
140
+ (pinnedAccountId === null || next.activeAccountId === pinnedAccountId) &&
141
+ activeToken !== this.host.getAccessToken()) {
127
142
  this.host.setTokens(activeToken);
128
143
  }
129
- const transport = this.options.transport;
144
+ // The transport's entire job is converging the bearer on `activeAccountId`,
145
+ // which is precisely what an identity-bound client must not do: its token is
146
+ // minted for the pinned account by the cold boot / re-mint lane. Bypass it
147
+ // while pinned (also removing the mint-before-notify gate, which exists only
148
+ // to keep the bearer and the ACTIVE account in step).
149
+ const transport = pinnedAccountId === null ? this.options.transport : null;
130
150
  const activeAccountId = next.activeAccountId;
131
151
  // Mint before notifying when the bearer does not already belong to the new
132
152
  // active account: no bearer at all, an opaque bearer, OR a bearer for a
@@ -198,12 +218,15 @@ export class SessionClient {
198
218
  // socket push): `applyState` no-ops without planting, but the token still
199
219
  // needs planting. Guard on the sync's active account STILL being the current
200
220
  // active account so a stale response cannot adopt a token for an account a
201
- // newer state already switched away from.
221
+ // newer state already switched away from — and, when pinned, on that account
222
+ // being the PINNED one (same rule as the applied path in `applyState`).
223
+ const pinnedAccountId = this.pinnedAccountId();
202
224
  if (!applied &&
203
225
  sync.activeToken &&
204
226
  this.state &&
205
227
  sync.state.activeAccountId !== null &&
206
228
  sync.state.activeAccountId === this.state.activeAccountId &&
229
+ (pinnedAccountId === null || sync.state.activeAccountId === pinnedAccountId) &&
207
230
  sync.activeToken.accessToken !== this.host.getAccessToken()) {
208
231
  this.host.setTokens(sync.activeToken.accessToken);
209
232
  }
@@ -237,12 +260,19 @@ export class SessionClient {
237
260
  * sign-in. This adds, then switches to the target so the UI lands on the
238
261
  * account the user just authenticated.
239
262
  *
263
+ * An identity-bound (pinned) client only ADDS: its own session is minted for
264
+ * the pinned account explicitly, so switching the device would gratuitously
265
+ * re-elect the active account under every OTHER app on this device — a
266
+ * mutation a pinned client must never make.
267
+ *
240
268
  * @param accountId - The signed-in account id (e.g. `session.user.id`). When
241
269
  * omitted, falls back to the host's current-account ref. If neither
242
270
  * resolves, the add still applies and no switch is performed.
243
271
  */
244
272
  async registerAndActivate(accountId) {
245
273
  await this.addCurrentAccount();
274
+ if (this.pinnedAccountId() !== null)
275
+ return;
246
276
  const target = accountId ?? this.host.getCurrentAccountId();
247
277
  if (target && this.state?.activeAccountId !== target) {
248
278
  await this.switchAccount(target);
@@ -346,6 +376,12 @@ export class SessionClient {
346
376
  const applied = this.applyState(payload, 'push');
347
377
  if (!applied)
348
378
  return;
379
+ // An identity-bound client tracks the pushed state but NEVER chases the
380
+ // device's active account: re-fetching here would only pull an
381
+ // `activeToken` for somebody else's switch, which the plant guards would
382
+ // then discard. Skip the round-trip entirely.
383
+ if (this.pinnedAccountId() !== null)
384
+ return;
349
385
  // A push changed the active account on another device/tab — re-fetch state
350
386
  // to plant the access token for the newly-active account. When this tab is
351
387
  // still signed out, applyState mints via ensureActiveToken first; bootstrap
@@ -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 {