@oxy.so/core 1.12.1 → 1.16.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 (92) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/crypto/webIdentityCarrier.js +1 -1
  3. package/dist/cjs/i18n/locales/ar-SA.json +33 -88
  4. package/dist/cjs/i18n/locales/ca-ES.json +35 -90
  5. package/dist/cjs/i18n/locales/de-DE.json +33 -88
  6. package/dist/cjs/i18n/locales/en-US.json +45 -141
  7. package/dist/cjs/i18n/locales/es-ES.json +44 -140
  8. package/dist/cjs/i18n/locales/fr-FR.json +33 -88
  9. package/dist/cjs/i18n/locales/it-IT.json +33 -88
  10. package/dist/cjs/i18n/locales/ja-JP.json +33 -88
  11. package/dist/cjs/i18n/locales/ko-KR.json +33 -88
  12. package/dist/cjs/i18n/locales/locales/ar-SA.json +33 -88
  13. package/dist/cjs/i18n/locales/locales/ca-ES.json +35 -90
  14. package/dist/cjs/i18n/locales/locales/de-DE.json +33 -88
  15. package/dist/cjs/i18n/locales/locales/en-US.json +45 -141
  16. package/dist/cjs/i18n/locales/locales/es-ES.json +44 -140
  17. package/dist/cjs/i18n/locales/locales/fr-FR.json +33 -88
  18. package/dist/cjs/i18n/locales/locales/it-IT.json +33 -88
  19. package/dist/cjs/i18n/locales/locales/ja-JP.json +33 -88
  20. package/dist/cjs/i18n/locales/locales/ko-KR.json +33 -88
  21. package/dist/cjs/i18n/locales/locales/pt-PT.json +33 -88
  22. package/dist/cjs/i18n/locales/locales/zh-CN.json +34 -89
  23. package/dist/cjs/i18n/locales/pt-PT.json +33 -88
  24. package/dist/cjs/i18n/locales/zh-CN.json +34 -89
  25. package/dist/cjs/index.js +10 -7
  26. package/dist/cjs/mixins/OxyServices.utility.js +140 -0
  27. package/dist/cjs/server/securityHeaders.js +19 -1
  28. package/dist/cjs/session/accountDialogController.js +37 -142
  29. package/dist/cjs/utils/authWebUrl.js +7 -6
  30. package/dist/cjs/utils/oauthPkce.js +5 -1
  31. package/dist/esm/.tsbuildinfo +1 -1
  32. package/dist/esm/crypto/webIdentityCarrier.js +1 -1
  33. package/dist/esm/i18n/locales/ar-SA.json +33 -88
  34. package/dist/esm/i18n/locales/ca-ES.json +35 -90
  35. package/dist/esm/i18n/locales/de-DE.json +33 -88
  36. package/dist/esm/i18n/locales/en-US.json +45 -141
  37. package/dist/esm/i18n/locales/es-ES.json +44 -140
  38. package/dist/esm/i18n/locales/fr-FR.json +33 -88
  39. package/dist/esm/i18n/locales/it-IT.json +33 -88
  40. package/dist/esm/i18n/locales/ja-JP.json +33 -88
  41. package/dist/esm/i18n/locales/ko-KR.json +33 -88
  42. package/dist/esm/i18n/locales/locales/ar-SA.json +33 -88
  43. package/dist/esm/i18n/locales/locales/ca-ES.json +35 -90
  44. package/dist/esm/i18n/locales/locales/de-DE.json +33 -88
  45. package/dist/esm/i18n/locales/locales/en-US.json +45 -141
  46. package/dist/esm/i18n/locales/locales/es-ES.json +44 -140
  47. package/dist/esm/i18n/locales/locales/fr-FR.json +33 -88
  48. package/dist/esm/i18n/locales/locales/it-IT.json +33 -88
  49. package/dist/esm/i18n/locales/locales/ja-JP.json +33 -88
  50. package/dist/esm/i18n/locales/locales/ko-KR.json +33 -88
  51. package/dist/esm/i18n/locales/locales/pt-PT.json +33 -88
  52. package/dist/esm/i18n/locales/locales/zh-CN.json +34 -89
  53. package/dist/esm/i18n/locales/pt-PT.json +33 -88
  54. package/dist/esm/i18n/locales/zh-CN.json +34 -89
  55. package/dist/esm/index.js +2 -1
  56. package/dist/esm/mixins/OxyServices.utility.js +138 -0
  57. package/dist/esm/server/securityHeaders.js +19 -1
  58. package/dist/esm/session/accountDialogController.js +37 -142
  59. package/dist/esm/utils/authWebUrl.js +6 -5
  60. package/dist/esm/utils/oauthPkce.js +5 -1
  61. package/dist/types/.tsbuildinfo +1 -1
  62. package/dist/types/crypto/webIdentityCarrier.d.ts +1 -1
  63. package/dist/types/index.d.ts +5 -3
  64. package/dist/types/mixins/OxyServices.utility.d.ts +84 -1
  65. package/dist/types/server/requesterAssertion.d.ts +2 -2
  66. package/dist/types/server/securityHeaders.d.ts +9 -12
  67. package/dist/types/session/accountDialogController.d.ts +29 -98
  68. package/dist/types/utils/authWebUrl.d.ts +6 -5
  69. package/dist/types/utils/oauthPkce.d.ts +10 -0
  70. package/package.json +2 -2
  71. package/src/crypto/webIdentityCarrier.ts +1 -1
  72. package/src/i18n/locales/ar-SA.json +33 -88
  73. package/src/i18n/locales/ca-ES.json +35 -90
  74. package/src/i18n/locales/de-DE.json +33 -88
  75. package/src/i18n/locales/en-US.json +45 -141
  76. package/src/i18n/locales/es-ES.json +44 -140
  77. package/src/i18n/locales/fr-FR.json +33 -88
  78. package/src/i18n/locales/it-IT.json +33 -88
  79. package/src/i18n/locales/ja-JP.json +33 -88
  80. package/src/i18n/locales/ko-KR.json +33 -88
  81. package/src/i18n/locales/pt-PT.json +33 -88
  82. package/src/i18n/locales/zh-CN.json +34 -89
  83. package/src/index.ts +9 -3
  84. package/src/mixins/OxyServices.utility.ts +208 -0
  85. package/src/mixins/__tests__/accountEvents.test.ts +158 -0
  86. package/src/server/__tests__/securityHeaders.test.ts +17 -0
  87. package/src/server/securityHeaders.ts +29 -1
  88. package/src/session/__tests__/accountDialogController.test.ts +42 -184
  89. package/src/session/accountDialogController.ts +50 -185
  90. package/src/utils/__tests__/oauthPkce.test.ts +7 -0
  91. package/src/utils/authWebUrl.ts +6 -5
  92. package/src/utils/oauthPkce.ts +16 -1
@@ -36,12 +36,6 @@
36
36
  * - `commonsAvailability` — whether Commons is installed on this device
37
37
  * (native only, via the injected `canOpenApp` probe), so the QR view can
38
38
  * offer a "Get Commons" fallback instead of a same-device dead end.
39
- * - `startPasskeyHubSignIn` — on a non-Oxy web origin (where a WebAuthn
40
- * ceremony can't run locally, the RP-ID is bound to oxy.so), open a popup
41
- * at the auth.oxy.so passkey hub instead, scoped to the SAME device-flow
42
- * session `showQr` creates, and complete it via the SAME poll/socket/claim
43
- * engine. Falls back to `showQr`'s plain QR rendering if the popup is
44
- * blocked.
45
39
  *
46
40
  * Sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff —
47
41
  * password, social login, and 2FA were removed ecosystem-wide. Account
@@ -62,7 +56,6 @@ import type { SessionLoginResponse, MinimalUserData } from '../models/session';
62
56
  import type { User } from '../models/interfaces';
63
57
  import { logger } from '../logger';
64
58
  import { extractErrorStatus } from '../utils/errorUtils';
65
- import { IDENTITY_WEB_ORIGIN } from '../utils/authWebUrl';
66
59
  import type { SessionClient } from './SessionClient';
67
60
  import { getSocketIO, type MinimalSocket, type SocketIOFactory } from './socketLoader';
68
61
  import { resolveActiveContext, type DeviceContext } from './deviceDirectory';
@@ -143,8 +136,6 @@ export type SignInProgress =
143
136
  * WHY a sign-in attempt ended in `'error'` — a machine-readable reason the UI
144
137
  * turns into localized copy (the controller ships no user-facing prose).
145
138
  *
146
- * - `'cancelled'` — the USER walked away (closed the passkey hub popup). A
147
- * voluntary outcome, not a failure to report.
148
139
  * - `'denied'` — the approver declined the request in Commons.
149
140
  * - `'expired'` — the request outlived its server-authoritative expiry.
150
141
  * - `'network'` — Oxy could not be reached (no response, timeout).
@@ -154,7 +145,6 @@ export type SignInProgress =
154
145
  * - `'unknown'` — anything else; the raw message stays on `error` for logs.
155
146
  */
156
147
  export type SignInFailureReason =
157
- | 'cancelled'
158
148
  | 'denied'
159
149
  | 'expired'
160
150
  | 'network'
@@ -163,20 +153,6 @@ export type SignInFailureReason =
163
153
  | 'claim-failed'
164
154
  | 'unknown';
165
155
 
166
- /**
167
- * Minimal structural handle over a popup `Window` — just enough for the
168
- * cross-origin passkey hub flow ({@link AccountDialogController.startPasskeyHubSignIn}):
169
- * navigate it once the device-flow session's `authorizeCode` is known, detect
170
- * the user closing it early (`closed`), and close it programmatically on
171
- * completion/failure. Satisfied directly by `window.open()`'s return value on
172
- * web; native has no popup concept and never injects an opener.
173
- */
174
- export interface PopupWindowHandle {
175
- readonly closed: boolean;
176
- close(): void;
177
- location: { href: string };
178
- }
179
-
180
156
  /**
181
157
  * State of the "Sign in with Oxy" (shared-key / QR) device flow.
182
158
  *
@@ -254,6 +230,14 @@ export interface SignInFlowState {
254
230
  * attempt failing with the same message is).
255
231
  */
256
232
  attempt: number;
233
+ /**
234
+ * `true` when this attempt is the sign-in entry's EMBEDDED QR
235
+ * ({@link AccountDialogController.startInlineQr}): a request the surface
236
+ * started on its own, not one the person asked for. Its failures (an expired
237
+ * code, above all) belong to that QR, which renews itself, and are not news
238
+ * to report.
239
+ */
240
+ inline: boolean;
257
241
  }
258
242
 
259
243
  /**
@@ -261,10 +245,10 @@ export interface SignInFlowState {
261
245
  * values the controller derives. Every mutation of the flow goes through this
262
246
  * shape, which is what makes `progress` (and `attempt`) impossible to set by hand.
263
247
  */
264
- type SignInFlowFacts = Omit<SignInFlowState, 'progress' | 'attempt'>;
248
+ type SignInFlowFacts = Omit<SignInFlowState, 'progress' | 'attempt' | 'inline'>;
265
249
 
266
250
  /** How the current sign-in attempt was started — what "Try again" repeats. */
267
- type SignInMethod = 'oxy' | 'qr' | 'passkey-hub';
251
+ type SignInMethod = 'oxy' | 'qr' | 'inline-qr';
268
252
 
269
253
  /**
270
254
  * Derive the surface-facing progress from the flow's real facts. Pure, total,
@@ -422,28 +406,6 @@ export interface AccountDialogControllerOptions {
422
406
  * `showQr` behaves exactly as before (render QR only).
423
407
  */
424
408
  canOpenApp?: (url: string) => Promise<boolean>;
425
- /**
426
- * Open a new, empty popup window SYNCHRONOUSLY (before any `await`) so the
427
- * browser attributes it to the click gesture that triggered it, returning a
428
- * handle to navigate once the device-flow session is known — or `null` if
429
- * the popup was blocked. Injected by the provider (web only:
430
- * `window.open('', ...)`; absent on native, where the hub-popup flow does
431
- * not apply). Used by {@link AccountDialogController.startPasskeyHubSignIn}.
432
- */
433
- openPopup?: () => PopupWindowHandle | null;
434
- /**
435
- * Origin of the web identity carrier (default `IDENTITY_WEB_ORIGIN`,
436
- * `https://id.oxy.so`) — where a passkey sign-in or sign-up runs
437
- * and the account's identity is kept sealed under the passkey. The popup opens
438
- * `<identityOrigin>/continue?code=…`. Overridable for local/staging testing.
439
- */
440
- identityOrigin?: string;
441
- /**
442
- * @deprecated Alias of {@link identityOrigin}, kept for existing
443
- * configuration. The popup used to open `auth.oxy.so/hub-passkey`; it now opens
444
- * the identity origin's `/continue`, so a value here must be that origin.
445
- */
446
- hubBaseUrl?: string;
447
409
  /**
448
410
  * Which surface the sign-in is initiated from — a FACT supplied by the
449
411
  * consumer, because only the consumer can classify its own environment
@@ -497,8 +459,8 @@ const IDLE_SIGN_IN_FACTS: SignInFlowFacts = {
497
459
  const COMPLETED_SIGN_IN_FACTS: SignInFlowFacts = { ...IDLE_SIGN_IN_FACTS, phase: 'completed' };
498
460
 
499
461
  /** The full flow state for `facts`, stamped with the attempt it belongs to. */
500
- function buildSignIn(facts: SignInFlowFacts, attempt: number): SignInFlowState {
501
- return { ...facts, progress: deriveSignInProgress(facts), attempt };
462
+ function buildSignIn(facts: SignInFlowFacts, attempt: number, inline: boolean): SignInFlowState {
463
+ return { ...facts, progress: deriveSignInProgress(facts), attempt, inline };
502
464
  }
503
465
 
504
466
  function errorMessage(error: unknown): string {
@@ -529,8 +491,6 @@ export class AccountDialogController {
529
491
  private readonly openUrl?: (url: string) => void;
530
492
  private readonly canOpenApp?: (url: string) => Promise<boolean>;
531
493
  private readonly socketFactory?: SocketIOFactory;
532
- private readonly openPopup?: () => PopupWindowHandle | null;
533
- private readonly identityOrigin: string;
534
494
  private readonly platform: CommonsDeliveryPlatform;
535
495
 
536
496
  private readonly listeners = new Set<SnapshotListener>();
@@ -548,7 +508,7 @@ export class AccountDialogController {
548
508
  * alongside the three flags above.
549
509
  */
550
510
  private exclusiveMutationInFlight = false;
551
- private signIn: SignInFlowState = buildSignIn(IDLE_SIGN_IN_FACTS, 0);
511
+ private signIn: SignInFlowState = buildSignIn(IDLE_SIGN_IN_FACTS, 0, false);
552
512
  private commonsAvailability: CommonsAvailability = 'unknown';
553
513
 
554
514
  // --- Sign-in device-flow bookkeeping ---
@@ -578,10 +538,6 @@ export class AccountDialogController {
578
538
  * ABANDONED request never blocks the next request's first poll.
579
539
  */
580
540
  private pollInFlightToken: string | null = null;
581
- /** The popup opened by {@link startPasskeyHubSignIn}, or `null`. */
582
- private activePopup: PopupWindowHandle | null = null;
583
- /** Watches {@link activePopup}'s `closed` state; see {@link watchPopup}. */
584
- private popupWatchTimer: ReturnType<typeof setInterval> | null = null;
585
541
 
586
542
  // --- Store plumbing ---
587
543
  private unsubscribeSession: (() => void) | null = null;
@@ -602,8 +558,6 @@ export class AccountDialogController {
602
558
  this.openUrl = options.openUrl;
603
559
  this.canOpenApp = options.canOpenApp;
604
560
  this.socketFactory = options.socketFactory;
605
- this.openPopup = options.openPopup;
606
- this.identityOrigin = options.identityOrigin ?? options.hubBaseUrl ?? IDENTITY_WEB_ORIGIN;
607
561
  this.platform = options.platform ?? 'unknown';
608
562
  this.snapshot = this.computeSnapshot();
609
563
  }
@@ -686,7 +640,6 @@ export class AccountDialogController {
686
640
  }
687
641
  this.clearPollTimer();
688
642
  this.closeAuthSessionSocket();
689
- this.closeActivePopup();
690
643
  this.signInToken = null;
691
644
  this.listeners.clear();
692
645
  }
@@ -754,12 +707,12 @@ export class AccountDialogController {
754
707
  const view = this.resolveView(requested);
755
708
  if (this.view === view) return;
756
709
  this.view = view;
757
- // A `'completed'` flow owns no timers, socket, popup, or token — it is only
710
+ // A `'completed'` flow owns no timers, socket, or token — it is only
758
711
  // the terminal "Identity confirmed" the finished surface showed. Moving to
759
712
  // another view is a NEW intention, so drop it; otherwise a later `add()`
760
713
  // would open on the previous sign-in's terminal state.
761
714
  if (this.signIn.phase === 'completed') {
762
- this.signIn = buildSignIn(IDLE_SIGN_IN_FACTS, this.signInAttempt);
715
+ this.signIn = this.stampSignIn(IDLE_SIGN_IN_FACTS);
763
716
  }
764
717
  this.emit();
765
718
  }
@@ -1105,82 +1058,35 @@ export class AccountDialogController {
1105
1058
  }
1106
1059
 
1107
1060
  /**
1108
- * Start a NEW attempt the same way the last one was started — "Try again"
1109
- * repeats the user's choice rather than silently switching method. A failed
1110
- * passkey-hub attempt reopens the hub popup; a failed "Sign in with Oxy"
1111
- * retries that; an explicit QR request shows the QR.
1061
+ * The sign-in entry's EMBEDDED QR: a request whose only route is the QR,
1062
+ * started without leaving the current view.
1112
1063
  *
1113
- * Call it straight from the press handler: the hub route opens its popup
1114
- * synchronously, before this method's first `await`, so the browser still
1115
- * attributes it to the click.
1064
+ * Unlike {@link showQr} it runs no delivery selection. The surface starts it
1065
+ * by itself, on mount, so it must not push to the person's phone or open
1066
+ * Commons — nobody asked for either. Any other sign-in the person then
1067
+ * chooses supersedes it (and withdraws its request) like any new attempt.
1068
+ */
1069
+ async startInlineQr(): Promise<void> {
1070
+ const attempt = this.beginSignInAttempt('inline-qr');
1071
+ await this.startDeviceFlowSession(attempt, { deliver: false });
1072
+ }
1073
+
1074
+ /**
1075
+ * Start a NEW attempt the same way the last one was started — "Try again"
1076
+ * repeats the user's choice rather than silently switching method: a failed
1077
+ * "Sign in with Oxy" retries that; an explicit QR request shows the QR.
1116
1078
  */
1117
1079
  retrySignIn(): Promise<void> {
1118
1080
  switch (this.signInMethod) {
1119
- case 'passkey-hub':
1120
- return this.startPasskeyHubSignIn();
1121
1081
  case 'qr':
1122
1082
  return this.showQr();
1083
+ case 'inline-qr':
1084
+ return this.startInlineQr();
1123
1085
  default:
1124
1086
  return this.signInWithOxy();
1125
1087
  }
1126
1088
  }
1127
1089
 
1128
- /**
1129
- * Web-only: sign in (or create an account) with a passkey at the web identity
1130
- * carrier. The ceremony runs on `id.oxy.so`, never on the calling origin, for
1131
- * two reasons: a credential minted with `WEBAUTHN_RP_ID=oxy.so` can only be
1132
- * asserted from `oxy.so`/a subdomain/loopback (a browser-enforced boundary),
1133
- * and only the identity origin may unseal or create the account's identity
1134
- * (one identity, two carriers). Opens a popup at `<identityOrigin>/continue`,
1135
- * scoped to the SAME device-flow
1136
- * session {@link showQr} would create (same `authorizeCode`/`sessionToken`
1137
- * pair), and let the SAME poll/socket/claim engine complete it once the hub
1138
- * authorizes the session (`POST /auth/session/authorize-code/:authorizeCode`,
1139
- * bearer-authed, server-side — this method never sees or transmits the
1140
- * secret `sessionToken`).
1141
- *
1142
- * The popup MUST be opened synchronously, before any `await`, or the
1143
- * browser's popup blocker loses the click-gesture attribution and silently
1144
- * blocks it — so {@link openPopup} is invoked as the very first step, before
1145
- * the device-flow session (which needs an async call) even exists. If the
1146
- * popup is blocked (or no `openPopup` was injected), this falls back to
1147
- * `showQr`'s plain QR rendering — no dead end.
1148
- */
1149
- async startPasskeyHubSignIn(): Promise<void> {
1150
- const attempt = this.beginSignInAttempt('passkey-hub');
1151
- this.setView('qr');
1152
- const popup = this.openPopup?.() ?? null;
1153
- if (!popup) {
1154
- await this.startDeviceFlowSession(attempt, { deliver: true });
1155
- return;
1156
- }
1157
- // Owned by the attempt from the moment it exists, so cancelling while the
1158
- // session is still being created closes it instead of leaving a blank
1159
- // window behind.
1160
- this.activePopup = popup;
1161
- // The hub popup IS the primary surface here, chosen explicitly by the user —
1162
- // so this flow does NOT run automatic Commons delivery (ringing the user's
1163
- // phone because they asked for a passkey would be exactly the "menu of
1164
- // methods" the one-primary-action rule forbids). The underlying request is
1165
- // the same `AuthSession`, and its Commons route stays the QR the view
1166
- // renders beneath the popup.
1167
- const handle = await this.startDeviceFlowSession(attempt, { deliver: false });
1168
- // `null` means the attempt failed (its teardown closed the popup) or was
1169
- // abandoned (the cancel closed it).
1170
- if (!handle) return;
1171
- // The user may have closed the popup during the async session creation
1172
- // above, before any watcher was attached to catch it — guard rather than
1173
- // navigate a dead window (browsers vary on whether that throws).
1174
- if (popup.closed) {
1175
- const pendingCode = this.signIn.authorizeCode;
1176
- this.failSignIn('cancelled', 'Sign-in was cancelled.');
1177
- if (pendingCode) void this.withdrawRequest(pendingCode);
1178
- return;
1179
- }
1180
- popup.location.href = `${this.identityOrigin}/continue?code=${encodeURIComponent(handle.authorizeCode)}`;
1181
- this.watchPopup(popup);
1182
- }
1183
-
1184
1090
  /**
1185
1091
  * End whatever attempt is running (withdrawing its request) and begin a new
1186
1092
  * one. Returns the new attempt's identity for the caller to re-check after
@@ -1198,9 +1104,9 @@ export class AccountDialogController {
1198
1104
  }
1199
1105
 
1200
1106
  /**
1201
- * Shared device-flow session creation for both {@link showQr} (renders the
1202
- * QR) and {@link startPasskeyHubSignIn} (also opens the hub popup) — the
1203
- * same `startCommonsSignIn` → poll/socket wiring either way. Returns the
1107
+ * Shared device-flow session creation for {@link signInWithOxy},
1108
+ * {@link showQr} and {@link startInlineQr} — the same `startCommonsSignIn` →
1109
+ * poll/socket wiring every time. Returns the
1204
1110
  * handle on success (already reflected in `signIn`), or `null` when the
1205
1111
  * attempt failed (already set as `signIn.failure`) or was abandoned while the
1206
1112
  * request was being created.
@@ -1211,8 +1117,8 @@ export class AccountDialogController {
1211
1117
  * request, a socket, and a poll behind it.
1212
1118
  * @param opts.deliver - Whether to run automatic Commons delivery selection
1213
1119
  * ({@link resolveDeliveryRoute}). `true` for the normal one-primary-action
1214
- * entry; `false` when the caller already owns the primary surface (the
1215
- * passkey hub popup), where the request's Commons route is simply the QR.
1120
+ * entry; `false` for the embedded QR, which the screen starts by itself,
1121
+ * where the request's Commons route is simply the QR.
1216
1122
  */
1217
1123
  private async startDeviceFlowSession(
1218
1124
  attempt: number,
@@ -1255,9 +1161,9 @@ export class AccountDialogController {
1255
1161
  void this.openAuthSessionSocket(handle.sessionToken);
1256
1162
  this.scheduleNextPoll(handle.sessionToken);
1257
1163
  if (opts.deliver) {
1258
- // Non-blocking on purpose: the QR/authorizeCode are already renderable
1259
- // and the popup caller can navigate immediately, while the route (a
1260
- // local probe plus at most one delivery round-trip) resolves behind it.
1164
+ // Non-blocking on purpose: the QR/authorizeCode are already renderable,
1165
+ // while the route (a local probe plus at most one delivery round-trip)
1166
+ // resolves behind it.
1261
1167
  void this.resolveDeliveryRoute(handle);
1262
1168
  }
1263
1169
  return handle;
@@ -1371,49 +1277,6 @@ export class AccountDialogController {
1371
1277
  }
1372
1278
  }
1373
1279
 
1374
- /**
1375
- * Poll {@link PopupWindowHandle.closed} so a user who dismisses the hub
1376
- * popup without completing sign-in gets prompt feedback (there is no DOM
1377
- * event for a cross-origin popup closing — polling is the only mechanism).
1378
- * Cleared by {@link closeActivePopup}, which every sign-in exit path already
1379
- * calls.
1380
- */
1381
- private watchPopup(popup: PopupWindowHandle): void {
1382
- this.clearPopupWatchTimer();
1383
- this.popupWatchTimer = setInterval(() => {
1384
- if (!popup.closed) return;
1385
- this.clearPopupWatchTimer();
1386
- if (this.signIn.phase === 'starting' || this.signIn.phase === 'waiting') {
1387
- // Closing the surface cancels the REQUEST too, not just this listener.
1388
- const pendingCode = this.signIn.authorizeCode;
1389
- this.failSignIn('cancelled', 'Sign-in was cancelled.');
1390
- if (pendingCode) {
1391
- void this.withdrawRequest(pendingCode);
1392
- }
1393
- }
1394
- }, 1000);
1395
- }
1396
-
1397
- private clearPopupWatchTimer(): void {
1398
- if (this.popupWatchTimer !== null) {
1399
- clearInterval(this.popupWatchTimer);
1400
- this.popupWatchTimer = null;
1401
- }
1402
- }
1403
-
1404
- /** Tear down the popup watch timer and close the popup, if any. Idempotent. */
1405
- private closeActivePopup(): void {
1406
- this.clearPopupWatchTimer();
1407
- const popup = this.activePopup;
1408
- this.activePopup = null;
1409
- if (!popup || popup.closed) return;
1410
- try {
1411
- popup.close();
1412
- } catch (error) {
1413
- logger.debug('[AccountDialogController] popup close failed', { component: 'AccountDialogController' }, error);
1414
- }
1415
- }
1416
-
1417
1280
  /**
1418
1281
  * Resolve whether Commons is installed on this device via the injected
1419
1282
  * `canOpenApp` probe, updating {@link commonsAvailability} as durable,
@@ -1446,7 +1309,7 @@ export class AccountDialogController {
1446
1309
  }
1447
1310
 
1448
1311
  /**
1449
- * Tear down the active sign-in device flow (timers + socket + popup + token),
1312
+ * Tear down the active sign-in device flow (timers + socket + token),
1450
1313
  * WITHDRAW the request server-side, and reset to idle.
1451
1314
  *
1452
1315
  * Cancellation has to converge in both directions: the surface closing must
@@ -1470,7 +1333,6 @@ export class AccountDialogController {
1470
1333
  : null;
1471
1334
  this.clearPollTimer();
1472
1335
  this.closeAuthSessionSocket();
1473
- this.closeActivePopup();
1474
1336
  this.signInToken = null;
1475
1337
  if (this.signIn.phase !== 'idle') {
1476
1338
  this.setSignIn(IDLE_SIGN_IN_FACTS);
@@ -1678,10 +1540,9 @@ export class AccountDialogController {
1678
1540
  this.signInToken = null;
1679
1541
  this.clearPollTimer();
1680
1542
  this.closeAuthSessionSocket();
1681
- this.closeActivePopup();
1682
- // Terminal SUCCESS, not idle: the surface gets one honest frame to show
1543
+ // Terminal SUCCESS, not idle: the surface gets one honest frame to show
1683
1544
  // "Identity confirmed" before it closes. Cleared on the next view change.
1684
- this.signIn = buildSignIn(COMPLETED_SIGN_IN_FACTS, this.signInAttempt);
1545
+ this.signIn = this.stampSignIn(COMPLETED_SIGN_IN_FACTS);
1685
1546
  this.view = 'accounts';
1686
1547
  this.emit();
1687
1548
  }
@@ -1712,7 +1573,6 @@ export class AccountDialogController {
1712
1573
  private failSignIn(failure: SignInFailureReason, message: string): void {
1713
1574
  this.clearPollTimer();
1714
1575
  this.closeAuthSessionSocket();
1715
- this.closeActivePopup();
1716
1576
  this.signInToken = null;
1717
1577
  this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: message, failure });
1718
1578
  }
@@ -1802,10 +1662,15 @@ export class AccountDialogController {
1802
1662
  * to advance without a fact behind it.
1803
1663
  */
1804
1664
  private setSignIn(facts: SignInFlowFacts): void {
1805
- this.signIn = buildSignIn(facts, this.signInAttempt);
1665
+ this.signIn = this.stampSignIn(facts);
1806
1666
  this.emit();
1807
1667
  }
1808
1668
 
1669
+ /** `facts` as the current attempt's state. */
1670
+ private stampSignIn(facts: SignInFlowFacts): SignInFlowState {
1671
+ return buildSignIn(facts, this.signInAttempt, this.signInMethod === 'inline-qr');
1672
+ }
1673
+
1809
1674
  /** Update a subset of the device-flow facts, re-deriving `progress`. */
1810
1675
  private patchSignIn(patch: Partial<SignInFlowFacts>): void {
1811
1676
  const {
@@ -122,6 +122,13 @@ describe('buildOAuthAuthorizeUrl', () => {
122
122
  expect(OXY_AUTHORIZE_URL).toBe('https://auth.oxy.so/authorize');
123
123
  });
124
124
 
125
+ it('names the IdP screen to land on only when asked', () => {
126
+ expect(new URL(buildOAuthAuthorizeUrl(base)).searchParams.has('screen')).toBe(false);
127
+ for (const screen of ['signin', 'signup', 'recover'] as const) {
128
+ expect(new URL(buildOAuthAuthorizeUrl({ ...base, screen })).searchParams.get('screen')).toBe(screen);
129
+ }
130
+ });
131
+
125
132
  it('includes every required OAuth authorization-code + PKCE parameter', () => {
126
133
  const params = new URL(buildOAuthAuthorizeUrl(base)).searchParams;
127
134
  expect(params.get('client_id')).toBe(base.clientId);
@@ -17,9 +17,10 @@
17
17
  export const CENTRAL_IDP_APEX = 'oxy.so';
18
18
 
19
19
  /**
20
- * The web identity carrier's origin — where a person signs in or creates an
21
- * account with a passkey, and where their self-custody identity is kept sealed
22
- * under that passkey (one identity, two carriers). Apps open it; they never
23
- * handle the identity themselves.
20
+ * The IdP's origin, `auth.oxy.so` — the OAuth authorize/consent surface AND the
21
+ * web identity carrier: where a person signs in or creates an account with a
22
+ * passkey from another origin's popup, and the one origin that may create,
23
+ * open, recover or move their self-custody identity (one identity, two
24
+ * carriers). Apps open it; they never handle the identity themselves.
24
25
  */
25
- export const IDENTITY_WEB_ORIGIN = `https://id.${CENTRAL_IDP_APEX}`;
26
+ export const AUTH_WEB_ORIGIN = `https://auth.${CENTRAL_IDP_APEX}`;
@@ -16,11 +16,12 @@
16
16
  * React Native. No `require()`, so the ESM build stays bundler-clean.
17
17
  */
18
18
 
19
+ import { AUTH_WEB_ORIGIN } from './authWebUrl';
19
20
  import { isNodeJS, isReactNative, loadExpoCrypto, loadNodeCrypto, sha256 } from '@oxy.so/protocol';
20
21
  import { logger } from '../logger';
21
22
 
22
23
  /** The central Oxy IdP authorization endpoint used by default. */
23
- export const OXY_AUTHORIZE_URL = 'https://auth.oxy.so/authorize';
24
+ export const OXY_AUTHORIZE_URL = `${AUTH_WEB_ORIGIN}/authorize`;
24
25
 
25
26
  /** Default OAuth scope requested for a "Sign in with Oxy" third-party flow. */
26
27
  export const DEFAULT_OAUTH_SCOPE = 'openid profile';
@@ -86,8 +87,19 @@ export interface BuildOAuthAuthorizeUrlParams {
86
87
  * a token, device secret, or the PKCE verifier, which the opener keeps.
87
88
  */
88
89
  responseMode?: 'web_message';
90
+ /**
91
+ * Which of the IdP's screens a person with no session there lands on:
92
+ * `signin` (sign in with a passkey), `signup` (create an account with its
93
+ * root), `recover` (recover it from the recovery phrase). Omitted, the IdP
94
+ * offers its Commons request first. An Oxy app sends this when it sends the
95
+ * person to auth.oxy.so for what only that origin can do, in the same tab.
96
+ */
97
+ screen?: OxyAuthScreen;
89
98
  }
90
99
 
100
+ /** The IdP screens {@link BuildOAuthAuthorizeUrlParams.screen} can open. */
101
+ export type OxyAuthScreen = 'signin' | 'signup' | 'recover';
102
+
91
103
  /**
92
104
  * Cryptographically-secure random bytes, cross-platform.
93
105
  *
@@ -215,6 +227,9 @@ export function buildOAuthAuthorizeUrl(params: BuildOAuthAuthorizeUrlParams): st
215
227
  if (params.responseMode) {
216
228
  url.searchParams.set('response_mode', params.responseMode);
217
229
  }
230
+ if (params.screen) {
231
+ url.searchParams.set('screen', params.screen);
232
+ }
218
233
 
219
234
  return url.toString();
220
235
  }