@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
@@ -313,6 +313,20 @@ const MAX_INLINE_SCRIPT_HASHES = 8;
313
313
  * react-native-web's runtime stylesheet, and a style hash would silently switch
314
314
  * that off and render every Oxy web app unstyled.)
315
315
  */
316
+ /** Passkeys for this origin itself; every other powerful feature off. */
317
+ const SENSITIVE_PERMISSIONS_POLICY = [
318
+ 'publickey-credentials-get=(self)',
319
+ 'publickey-credentials-create=(self)',
320
+ 'camera=()',
321
+ 'microphone=()',
322
+ 'geolocation=()',
323
+ 'payment=()',
324
+ 'usb=()',
325
+ 'serial=()',
326
+ 'hid=()',
327
+ 'bluetooth=()',
328
+ 'display-capture=()',
329
+ ].join(', ');
316
330
  export function buildOxyPagesHeaders(options = {}) {
317
331
  const hashes = [
318
332
  ...new Set((options.html ?? []).flatMap(extractInlineScripts).map(inlineScriptCspHash)),
@@ -328,6 +342,8 @@ export function buildOxyPagesHeaders(options = {}) {
328
342
  for (const name of ['script-src', 'connect-src']) {
329
343
  directives[name] = (directives[name] ?? []).filter((source) => !beacon.has(source));
330
344
  }
345
+ directives['base-uri'] = ["'none'"];
346
+ directives['form-action'] = ["'none'"];
331
347
  }
332
348
  const csp = formatOxyCspPolicy(directives);
333
349
  const lines = [
@@ -335,13 +351,15 @@ export function buildOxyPagesHeaders(options = {}) {
335
351
  ` Content-Security-Policy: ${csp}`,
336
352
  ' X-Frame-Options: DENY',
337
353
  ' X-Content-Type-Options: nosniff',
338
- ' Referrer-Policy: strict-origin-when-cross-origin',
354
+ ` Referrer-Policy: ${options.sensitive ? 'no-referrer' : 'strict-origin-when-cross-origin'}`,
339
355
  ];
340
356
  if (options.hsts !== false) {
341
357
  lines.push(' Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
342
358
  }
343
359
  if (options.sensitive) {
344
360
  lines.push(' Cache-Control: public, max-age=0, must-revalidate, no-transform');
361
+ lines.push(` Permissions-Policy: ${SENSITIVE_PERMISSIONS_POLICY}`);
362
+ lines.push(' Cross-Origin-Resource-Policy: same-origin');
345
363
  }
346
364
  lines.push('');
347
365
  return lines.join('\n');
@@ -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
@@ -57,7 +51,6 @@
57
51
  */
58
52
  import { logger } from '../logger/index.js';
59
53
  import { extractErrorStatus } from '../utils/errorUtils.js';
60
- import { IDENTITY_WEB_ORIGIN } from '../utils/authWebUrl.js';
61
54
  import { getSocketIO } from './socketLoader.js';
62
55
  import { resolveActiveContext } from './deviceDirectory.js';
63
56
  import { pushTargetsFromDelivery, selectCommonsDelivery, } from '../utils/commonsDelivery.js';
@@ -125,8 +118,8 @@ const IDLE_SIGN_IN_FACTS = {
125
118
  */
126
119
  const COMPLETED_SIGN_IN_FACTS = { ...IDLE_SIGN_IN_FACTS, phase: 'completed' };
127
120
  /** The full flow state for `facts`, stamped with the attempt it belongs to. */
128
- function buildSignIn(facts, attempt) {
129
- return { ...facts, progress: deriveSignInProgress(facts), attempt };
121
+ function buildSignIn(facts, attempt, inline) {
122
+ return { ...facts, progress: deriveSignInProgress(facts), attempt, inline };
130
123
  }
131
124
  function errorMessage(error) {
132
125
  return error instanceof Error ? error.message : String(error);
@@ -158,7 +151,7 @@ export class AccountDialogController {
158
151
  * alongside the three flags above.
159
152
  */
160
153
  this.exclusiveMutationInFlight = false;
161
- this.signIn = buildSignIn(IDLE_SIGN_IN_FACTS, 0);
154
+ this.signIn = buildSignIn(IDLE_SIGN_IN_FACTS, 0, false);
162
155
  this.commonsAvailability = 'unknown';
163
156
  // --- Sign-in device-flow bookkeeping ---
164
157
  /**
@@ -187,10 +180,6 @@ export class AccountDialogController {
187
180
  * ABANDONED request never blocks the next request's first poll.
188
181
  */
189
182
  this.pollInFlightToken = null;
190
- /** The popup opened by {@link startPasskeyHubSignIn}, or `null`. */
191
- this.activePopup = null;
192
- /** Watches {@link activePopup}'s `closed` state; see {@link watchPopup}. */
193
- this.popupWatchTimer = null;
194
183
  // --- Store plumbing ---
195
184
  this.unsubscribeSession = null;
196
185
  this.unsubscribeTokens = null;
@@ -207,8 +196,6 @@ export class AccountDialogController {
207
196
  this.openUrl = options.openUrl;
208
197
  this.canOpenApp = options.canOpenApp;
209
198
  this.socketFactory = options.socketFactory;
210
- this.openPopup = options.openPopup;
211
- this.identityOrigin = options.identityOrigin ?? options.hubBaseUrl ?? IDENTITY_WEB_ORIGIN;
212
199
  this.platform = options.platform ?? 'unknown';
213
200
  this.snapshot = this.computeSnapshot();
214
201
  }
@@ -286,7 +273,6 @@ export class AccountDialogController {
286
273
  }
287
274
  this.clearPollTimer();
288
275
  this.closeAuthSessionSocket();
289
- this.closeActivePopup();
290
276
  this.signInToken = null;
291
277
  this.listeners.clear();
292
278
  }
@@ -351,12 +337,12 @@ export class AccountDialogController {
351
337
  if (this.view === view)
352
338
  return;
353
339
  this.view = view;
354
- // A `'completed'` flow owns no timers, socket, popup, or token — it is only
340
+ // A `'completed'` flow owns no timers, socket, or token — it is only
355
341
  // the terminal "Identity confirmed" the finished surface showed. Moving to
356
342
  // another view is a NEW intention, so drop it; otherwise a later `add()`
357
343
  // would open on the previous sign-in's terminal state.
358
344
  if (this.signIn.phase === 'completed') {
359
- this.signIn = buildSignIn(IDLE_SIGN_IN_FACTS, this.signInAttempt);
345
+ this.signIn = this.stampSignIn(IDLE_SIGN_IN_FACTS);
360
346
  }
361
347
  this.emit();
362
348
  }
@@ -673,82 +659,33 @@ export class AccountDialogController {
673
659
  await this.startDeviceFlowSession(attempt, { deliver: true });
674
660
  }
675
661
  /**
676
- * Start a NEW attempt the same way the last one was started — "Try again"
677
- * repeats the user's choice rather than silently switching method. A failed
678
- * passkey-hub attempt reopens the hub popup; a failed "Sign in with Oxy"
679
- * retries that; an explicit QR request shows the QR.
662
+ * The sign-in entry's EMBEDDED QR: a request whose only route is the QR,
663
+ * started without leaving the current view.
680
664
  *
681
- * Call it straight from the press handler: the hub route opens its popup
682
- * synchronously, before this method's first `await`, so the browser still
683
- * attributes it to the click.
665
+ * Unlike {@link showQr} it runs no delivery selection. The surface starts it
666
+ * by itself, on mount, so it must not push to the person's phone or open
667
+ * Commons — nobody asked for either. Any other sign-in the person then
668
+ * chooses supersedes it (and withdraws its request) like any new attempt.
669
+ */
670
+ async startInlineQr() {
671
+ const attempt = this.beginSignInAttempt('inline-qr');
672
+ await this.startDeviceFlowSession(attempt, { deliver: false });
673
+ }
674
+ /**
675
+ * Start a NEW attempt the same way the last one was started — "Try again"
676
+ * repeats the user's choice rather than silently switching method: a failed
677
+ * "Sign in with Oxy" retries that; an explicit QR request shows the QR.
684
678
  */
685
679
  retrySignIn() {
686
680
  switch (this.signInMethod) {
687
- case 'passkey-hub':
688
- return this.startPasskeyHubSignIn();
689
681
  case 'qr':
690
682
  return this.showQr();
683
+ case 'inline-qr':
684
+ return this.startInlineQr();
691
685
  default:
692
686
  return this.signInWithOxy();
693
687
  }
694
688
  }
695
- /**
696
- * Web-only: sign in (or create an account) with a passkey at the web identity
697
- * carrier. The ceremony runs on `id.oxy.so`, never on the calling origin, for
698
- * two reasons: a credential minted with `WEBAUTHN_RP_ID=oxy.so` can only be
699
- * asserted from `oxy.so`/a subdomain/loopback (a browser-enforced boundary),
700
- * and only the identity origin may unseal or create the account's identity
701
- * (one identity, two carriers). Opens a popup at `<identityOrigin>/continue`,
702
- * scoped to the SAME device-flow
703
- * session {@link showQr} would create (same `authorizeCode`/`sessionToken`
704
- * pair), and let the SAME poll/socket/claim engine complete it once the hub
705
- * authorizes the session (`POST /auth/session/authorize-code/:authorizeCode`,
706
- * bearer-authed, server-side — this method never sees or transmits the
707
- * secret `sessionToken`).
708
- *
709
- * The popup MUST be opened synchronously, before any `await`, or the
710
- * browser's popup blocker loses the click-gesture attribution and silently
711
- * blocks it — so {@link openPopup} is invoked as the very first step, before
712
- * the device-flow session (which needs an async call) even exists. If the
713
- * popup is blocked (or no `openPopup` was injected), this falls back to
714
- * `showQr`'s plain QR rendering — no dead end.
715
- */
716
- async startPasskeyHubSignIn() {
717
- const attempt = this.beginSignInAttempt('passkey-hub');
718
- this.setView('qr');
719
- const popup = this.openPopup?.() ?? null;
720
- if (!popup) {
721
- await this.startDeviceFlowSession(attempt, { deliver: true });
722
- return;
723
- }
724
- // Owned by the attempt from the moment it exists, so cancelling while the
725
- // session is still being created closes it instead of leaving a blank
726
- // window behind.
727
- this.activePopup = popup;
728
- // The hub popup IS the primary surface here, chosen explicitly by the user —
729
- // so this flow does NOT run automatic Commons delivery (ringing the user's
730
- // phone because they asked for a passkey would be exactly the "menu of
731
- // methods" the one-primary-action rule forbids). The underlying request is
732
- // the same `AuthSession`, and its Commons route stays the QR the view
733
- // renders beneath the popup.
734
- const handle = await this.startDeviceFlowSession(attempt, { deliver: false });
735
- // `null` means the attempt failed (its teardown closed the popup) or was
736
- // abandoned (the cancel closed it).
737
- if (!handle)
738
- return;
739
- // The user may have closed the popup during the async session creation
740
- // above, before any watcher was attached to catch it — guard rather than
741
- // navigate a dead window (browsers vary on whether that throws).
742
- if (popup.closed) {
743
- const pendingCode = this.signIn.authorizeCode;
744
- this.failSignIn('cancelled', 'Sign-in was cancelled.');
745
- if (pendingCode)
746
- void this.withdrawRequest(pendingCode);
747
- return;
748
- }
749
- popup.location.href = `${this.identityOrigin}/continue?code=${encodeURIComponent(handle.authorizeCode)}`;
750
- this.watchPopup(popup);
751
- }
752
689
  /**
753
690
  * End whatever attempt is running (withdrawing its request) and begin a new
754
691
  * one. Returns the new attempt's identity for the caller to re-check after
@@ -764,9 +701,9 @@ export class AccountDialogController {
764
701
  return attempt === this.signInAttempt;
765
702
  }
766
703
  /**
767
- * Shared device-flow session creation for both {@link showQr} (renders the
768
- * QR) and {@link startPasskeyHubSignIn} (also opens the hub popup) — the
769
- * same `startCommonsSignIn` → poll/socket wiring either way. Returns the
704
+ * Shared device-flow session creation for {@link signInWithOxy},
705
+ * {@link showQr} and {@link startInlineQr} — the same `startCommonsSignIn` →
706
+ * poll/socket wiring every time. Returns the
770
707
  * handle on success (already reflected in `signIn`), or `null` when the
771
708
  * attempt failed (already set as `signIn.failure`) or was abandoned while the
772
709
  * request was being created.
@@ -777,8 +714,8 @@ export class AccountDialogController {
777
714
  * request, a socket, and a poll behind it.
778
715
  * @param opts.deliver - Whether to run automatic Commons delivery selection
779
716
  * ({@link resolveDeliveryRoute}). `true` for the normal one-primary-action
780
- * entry; `false` when the caller already owns the primary surface (the
781
- * passkey hub popup), where the request's Commons route is simply the QR.
717
+ * entry; `false` for the embedded QR, which the screen starts by itself,
718
+ * where the request's Commons route is simply the QR.
782
719
  */
783
720
  async startDeviceFlowSession(attempt, opts) {
784
721
  if (!this.isCurrentAttempt(attempt))
@@ -821,9 +758,9 @@ export class AccountDialogController {
821
758
  void this.openAuthSessionSocket(handle.sessionToken);
822
759
  this.scheduleNextPoll(handle.sessionToken);
823
760
  if (opts.deliver) {
824
- // Non-blocking on purpose: the QR/authorizeCode are already renderable
825
- // and the popup caller can navigate immediately, while the route (a
826
- // local probe plus at most one delivery round-trip) resolves behind it.
761
+ // Non-blocking on purpose: the QR/authorizeCode are already renderable,
762
+ // while the route (a local probe plus at most one delivery round-trip)
763
+ // resolves behind it.
827
764
  void this.resolveDeliveryRoute(handle);
828
765
  }
829
766
  return handle;
@@ -927,49 +864,6 @@ export class AccountDialogController {
927
864
  return false;
928
865
  }
929
866
  }
930
- /**
931
- * Poll {@link PopupWindowHandle.closed} so a user who dismisses the hub
932
- * popup without completing sign-in gets prompt feedback (there is no DOM
933
- * event for a cross-origin popup closing — polling is the only mechanism).
934
- * Cleared by {@link closeActivePopup}, which every sign-in exit path already
935
- * calls.
936
- */
937
- watchPopup(popup) {
938
- this.clearPopupWatchTimer();
939
- this.popupWatchTimer = setInterval(() => {
940
- if (!popup.closed)
941
- return;
942
- this.clearPopupWatchTimer();
943
- if (this.signIn.phase === 'starting' || this.signIn.phase === 'waiting') {
944
- // Closing the surface cancels the REQUEST too, not just this listener.
945
- const pendingCode = this.signIn.authorizeCode;
946
- this.failSignIn('cancelled', 'Sign-in was cancelled.');
947
- if (pendingCode) {
948
- void this.withdrawRequest(pendingCode);
949
- }
950
- }
951
- }, 1000);
952
- }
953
- clearPopupWatchTimer() {
954
- if (this.popupWatchTimer !== null) {
955
- clearInterval(this.popupWatchTimer);
956
- this.popupWatchTimer = null;
957
- }
958
- }
959
- /** Tear down the popup watch timer and close the popup, if any. Idempotent. */
960
- closeActivePopup() {
961
- this.clearPopupWatchTimer();
962
- const popup = this.activePopup;
963
- this.activePopup = null;
964
- if (!popup || popup.closed)
965
- return;
966
- try {
967
- popup.close();
968
- }
969
- catch (error) {
970
- logger.debug('[AccountDialogController] popup close failed', { component: 'AccountDialogController' }, error);
971
- }
972
- }
973
867
  /**
974
868
  * Resolve whether Commons is installed on this device via the injected
975
869
  * `canOpenApp` probe, updating {@link commonsAvailability} as durable,
@@ -999,7 +893,7 @@ export class AccountDialogController {
999
893
  this.emit();
1000
894
  }
1001
895
  /**
1002
- * Tear down the active sign-in device flow (timers + socket + popup + token),
896
+ * Tear down the active sign-in device flow (timers + socket + token),
1003
897
  * WITHDRAW the request server-side, and reset to idle.
1004
898
  *
1005
899
  * Cancellation has to converge in both directions: the surface closing must
@@ -1022,7 +916,6 @@ export class AccountDialogController {
1022
916
  : null;
1023
917
  this.clearPollTimer();
1024
918
  this.closeAuthSessionSocket();
1025
- this.closeActivePopup();
1026
919
  this.signInToken = null;
1027
920
  if (this.signIn.phase !== 'idle') {
1028
921
  this.setSignIn(IDLE_SIGN_IN_FACTS);
@@ -1221,10 +1114,9 @@ export class AccountDialogController {
1221
1114
  this.signInToken = null;
1222
1115
  this.clearPollTimer();
1223
1116
  this.closeAuthSessionSocket();
1224
- this.closeActivePopup();
1225
1117
  // Terminal SUCCESS, not idle: the surface gets one honest frame to show
1226
1118
  // "Identity confirmed" before it closes. Cleared on the next view change.
1227
- this.signIn = buildSignIn(COMPLETED_SIGN_IN_FACTS, this.signInAttempt);
1119
+ this.signIn = this.stampSignIn(COMPLETED_SIGN_IN_FACTS);
1228
1120
  this.view = 'accounts';
1229
1121
  this.emit();
1230
1122
  }
@@ -1251,7 +1143,6 @@ export class AccountDialogController {
1251
1143
  failSignIn(failure, message) {
1252
1144
  this.clearPollTimer();
1253
1145
  this.closeAuthSessionSocket();
1254
- this.closeActivePopup();
1255
1146
  this.signInToken = null;
1256
1147
  this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: message, failure });
1257
1148
  }
@@ -1343,9 +1234,13 @@ export class AccountDialogController {
1343
1234
  * to advance without a fact behind it.
1344
1235
  */
1345
1236
  setSignIn(facts) {
1346
- this.signIn = buildSignIn(facts, this.signInAttempt);
1237
+ this.signIn = this.stampSignIn(facts);
1347
1238
  this.emit();
1348
1239
  }
1240
+ /** `facts` as the current attempt's state. */
1241
+ stampSignIn(facts) {
1242
+ return buildSignIn(facts, this.signInAttempt, this.signInMethod === 'inline-qr');
1243
+ }
1349
1244
  /** Update a subset of the device-flow facts, re-deriving `progress`. */
1350
1245
  patchSignIn(patch) {
1351
1246
  const { phase, authorizeCode, qrPayload, expiresAt, error, failure, route, routeFailed, pushSentAt, openedAt, } = this.signIn;
@@ -15,9 +15,10 @@
15
15
  */
16
16
  export const CENTRAL_IDP_APEX = 'oxy.so';
17
17
  /**
18
- * The web identity carrier's origin — where a person signs in or creates an
19
- * account with a passkey, and where their self-custody identity is kept sealed
20
- * under that passkey (one identity, two carriers). Apps open it; they never
21
- * handle the identity themselves.
18
+ * The IdP's origin, `auth.oxy.so` — the OAuth authorize/consent surface AND the
19
+ * web identity carrier: where a person signs in or creates an account with a
20
+ * passkey from another origin's popup, and the one origin that may create,
21
+ * open, recover or move their self-custody identity (one identity, two
22
+ * carriers). Apps open it; they never handle the identity themselves.
22
23
  */
23
- export const IDENTITY_WEB_ORIGIN = `https://id.${CENTRAL_IDP_APEX}`;
24
+ export const AUTH_WEB_ORIGIN = `https://auth.${CENTRAL_IDP_APEX}`;
@@ -15,10 +15,11 @@
15
15
  * crypto already uses — so these helpers run identically on web, Node, and
16
16
  * React Native. No `require()`, so the ESM build stays bundler-clean.
17
17
  */
18
+ import { AUTH_WEB_ORIGIN } from './authWebUrl.js';
18
19
  import { isNodeJS, isReactNative, loadExpoCrypto, loadNodeCrypto, sha256 } from '@oxy.so/protocol';
19
20
  import { logger } from '../logger/index.js';
20
21
  /** The central Oxy IdP authorization endpoint used by default. */
21
- export const OXY_AUTHORIZE_URL = 'https://auth.oxy.so/authorize';
22
+ export const OXY_AUTHORIZE_URL = `${AUTH_WEB_ORIGIN}/authorize`;
22
23
  /** Default OAuth scope requested for a "Sign in with Oxy" third-party flow. */
23
24
  export const DEFAULT_OAUTH_SCOPE = 'openid profile';
24
25
  /**
@@ -137,6 +138,9 @@ export function buildOAuthAuthorizeUrl(params) {
137
138
  if (params.responseMode) {
138
139
  url.searchParams.set('response_mode', params.responseMode);
139
140
  }
141
+ if (params.screen) {
142
+ url.searchParams.set('screen', params.screen);
143
+ }
140
144
  return url.toString();
141
145
  }
142
146
  /** `sessionStorage` key for the OAuth CSRF `state` across an authorize redirect. */