@oxyhq/core 12.1.1 → 12.2.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 (66) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/i18n/locales/ar-SA.json +2 -1
  3. package/dist/cjs/i18n/locales/ca-ES.json +2 -1
  4. package/dist/cjs/i18n/locales/de-DE.json +2 -1
  5. package/dist/cjs/i18n/locales/en-US.json +2 -1
  6. package/dist/cjs/i18n/locales/es-ES.json +2 -1
  7. package/dist/cjs/i18n/locales/fr-FR.json +2 -1
  8. package/dist/cjs/i18n/locales/it-IT.json +2 -1
  9. package/dist/cjs/i18n/locales/ja-JP.json +2 -1
  10. package/dist/cjs/i18n/locales/ko-KR.json +2 -1
  11. package/dist/cjs/i18n/locales/locales/ar-SA.json +2 -1
  12. package/dist/cjs/i18n/locales/locales/ca-ES.json +2 -1
  13. package/dist/cjs/i18n/locales/locales/de-DE.json +2 -1
  14. package/dist/cjs/i18n/locales/locales/en-US.json +2 -1
  15. package/dist/cjs/i18n/locales/locales/es-ES.json +2 -1
  16. package/dist/cjs/i18n/locales/locales/fr-FR.json +2 -1
  17. package/dist/cjs/i18n/locales/locales/it-IT.json +2 -1
  18. package/dist/cjs/i18n/locales/locales/ja-JP.json +2 -1
  19. package/dist/cjs/i18n/locales/locales/ko-KR.json +2 -1
  20. package/dist/cjs/i18n/locales/locales/pt-PT.json +2 -1
  21. package/dist/cjs/i18n/locales/locales/zh-CN.json +2 -1
  22. package/dist/cjs/i18n/locales/pt-PT.json +2 -1
  23. package/dist/cjs/i18n/locales/zh-CN.json +2 -1
  24. package/dist/cjs/session/accountDialogController.js +112 -2
  25. package/dist/esm/.tsbuildinfo +1 -1
  26. package/dist/esm/i18n/locales/ar-SA.json +2 -1
  27. package/dist/esm/i18n/locales/ca-ES.json +2 -1
  28. package/dist/esm/i18n/locales/de-DE.json +2 -1
  29. package/dist/esm/i18n/locales/en-US.json +2 -1
  30. package/dist/esm/i18n/locales/es-ES.json +2 -1
  31. package/dist/esm/i18n/locales/fr-FR.json +2 -1
  32. package/dist/esm/i18n/locales/it-IT.json +2 -1
  33. package/dist/esm/i18n/locales/ja-JP.json +2 -1
  34. package/dist/esm/i18n/locales/ko-KR.json +2 -1
  35. package/dist/esm/i18n/locales/locales/ar-SA.json +2 -1
  36. package/dist/esm/i18n/locales/locales/ca-ES.json +2 -1
  37. package/dist/esm/i18n/locales/locales/de-DE.json +2 -1
  38. package/dist/esm/i18n/locales/locales/en-US.json +2 -1
  39. package/dist/esm/i18n/locales/locales/es-ES.json +2 -1
  40. package/dist/esm/i18n/locales/locales/fr-FR.json +2 -1
  41. package/dist/esm/i18n/locales/locales/it-IT.json +2 -1
  42. package/dist/esm/i18n/locales/locales/ja-JP.json +2 -1
  43. package/dist/esm/i18n/locales/locales/ko-KR.json +2 -1
  44. package/dist/esm/i18n/locales/locales/pt-PT.json +2 -1
  45. package/dist/esm/i18n/locales/locales/zh-CN.json +2 -1
  46. package/dist/esm/i18n/locales/pt-PT.json +2 -1
  47. package/dist/esm/i18n/locales/zh-CN.json +2 -1
  48. package/dist/esm/session/accountDialogController.js +112 -2
  49. package/dist/types/.tsbuildinfo +1 -1
  50. package/dist/types/index.d.ts +1 -1
  51. package/dist/types/session/accountDialogController.d.ts +81 -1
  52. package/package.json +4 -4
  53. package/src/i18n/locales/ar-SA.json +2 -1
  54. package/src/i18n/locales/ca-ES.json +2 -1
  55. package/src/i18n/locales/de-DE.json +2 -1
  56. package/src/i18n/locales/en-US.json +2 -1
  57. package/src/i18n/locales/es-ES.json +2 -1
  58. package/src/i18n/locales/fr-FR.json +2 -1
  59. package/src/i18n/locales/it-IT.json +2 -1
  60. package/src/i18n/locales/ja-JP.json +2 -1
  61. package/src/i18n/locales/ko-KR.json +2 -1
  62. package/src/i18n/locales/pt-PT.json +2 -1
  63. package/src/i18n/locales/zh-CN.json +2 -1
  64. package/src/index.ts +1 -0
  65. package/src/session/__tests__/accountDialogController.test.ts +133 -1
  66. package/src/session/accountDialogController.ts +145 -2
@@ -99,7 +99,7 @@ export { deviceStateToClientSessions, activeSessionIdOf, activeUserOf, accountId
99
99
  export { projectSwitchableAccounts, switchableAccountIds, } from './session/accountProjection';
100
100
  export type { SwitchableAccount, SwitchableAccountUser, ProjectSwitchableAccountsInput, } from './session/accountProjection';
101
101
  export { AccountDialogController, createAccountDialogController, } from './session/accountDialogController';
102
- export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, CommonsAvailability, SignInFlowPhase, SignInFlowState, } from './session/accountDialogController';
102
+ export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, CommonsAvailability, PopupWindowHandle, SignInFlowPhase, SignInFlowState, } from './session/accountDialogController';
103
103
  export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, } from './session/authStateStore';
104
104
  export type { PersistedAuthState, AuthStateStore, NativeKeyValueStorage, } from './session/authStateStore';
105
105
  export { refreshPersistedSession, refreshDeviceSecretArm, createAuthRefreshHandler, installAuthRefreshHandler, startTokenRefreshScheduler, TOKEN_REFRESH_LEAD_MS, } from './session/refresh';
@@ -24,6 +24,12 @@
24
24
  * - `commonsAvailability` — whether Commons is installed on this device
25
25
  * (native only, via the injected `canOpenApp` probe), so the QR view can
26
26
  * offer a "Get Commons" fallback instead of a same-device dead end.
27
+ * - `startPasskeyHubSignIn` — on a non-Oxy web origin (where a WebAuthn
28
+ * ceremony can't run locally, the RP-ID is bound to oxy.so), open a popup
29
+ * at the auth.oxy.so passkey hub instead, scoped to the SAME device-flow
30
+ * session `showQr` creates, and complete it via the SAME poll/socket/claim
31
+ * engine. Falls back to `showQr`'s plain QR rendering if the popup is
32
+ * blocked.
27
33
  *
28
34
  * Sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff —
29
35
  * password, social login, and 2FA were removed ecosystem-wide. Account
@@ -51,6 +57,21 @@ export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add' | 'signup';
51
57
  export type CommonsAvailability = 'unknown' | 'checking' | 'available' | 'unavailable';
52
58
  /** Lifecycle phase of the "Sign in with Oxy" device flow. */
53
59
  export type SignInFlowPhase = 'idle' | 'starting' | 'waiting' | 'authorized' | 'error';
60
+ /**
61
+ * Minimal structural handle over a popup `Window` — just enough for the
62
+ * cross-origin passkey hub flow ({@link AccountDialogController.startPasskeyHubSignIn}):
63
+ * navigate it once the device-flow session's `authorizeCode` is known, detect
64
+ * the user closing it early (`closed`), and close it programmatically on
65
+ * completion/failure. Satisfied directly by `window.open()`'s return value on
66
+ * web; native has no popup concept and never injects an opener.
67
+ */
68
+ export interface PopupWindowHandle {
69
+ readonly closed: boolean;
70
+ close(): void;
71
+ location: {
72
+ href: string;
73
+ };
74
+ }
54
75
  /** State of the "Sign in with Oxy" (shared-key / QR) device flow. */
55
76
  export interface SignInFlowState {
56
77
  phase: SignInFlowPhase;
@@ -166,6 +187,20 @@ export interface AccountDialogControllerOptions {
166
187
  * `showQr` behaves exactly as before (render QR only).
167
188
  */
168
189
  canOpenApp?: (url: string) => Promise<boolean>;
190
+ /**
191
+ * Open a new, empty popup window SYNCHRONOUSLY (before any `await`) so the
192
+ * browser attributes it to the click gesture that triggered it, returning a
193
+ * handle to navigate once the device-flow session is known — or `null` if
194
+ * the popup was blocked. Injected by the provider (web only:
195
+ * `window.open('', ...)`; absent on native, where the hub-popup flow does
196
+ * not apply). Used by {@link AccountDialogController.startPasskeyHubSignIn}.
197
+ */
198
+ openPopup?: () => PopupWindowHandle | null;
199
+ /**
200
+ * Base origin of the auth.oxy.so passkey hub (defaults to
201
+ * `https://auth.${CENTRAL_IDP_APEX}`). Overridable for local/staging testing.
202
+ */
203
+ hubBaseUrl?: string;
169
204
  }
170
205
  type SnapshotListener = (snapshot: AccountDialogSnapshot) => void;
171
206
  export declare class AccountDialogController {
@@ -180,6 +215,8 @@ export declare class AccountDialogController {
180
215
  private readonly openUrl?;
181
216
  private readonly canOpenApp?;
182
217
  private readonly socketFactory?;
218
+ private readonly openPopup?;
219
+ private readonly hubBaseUrl;
183
220
  private readonly listeners;
184
221
  private view;
185
222
  private graph;
@@ -204,6 +241,10 @@ export declare class AccountDialogController {
204
241
  * single-use token concurrently.
205
242
  */
206
243
  private pollInFlight;
244
+ /** The popup opened by {@link startPasskeyHubSignIn}, or `null`. */
245
+ private activePopup;
246
+ /** Watches {@link activePopup}'s `closed` state; see {@link watchPopup}. */
247
+ private popupWatchTimer;
207
248
  private unsubscribeSession;
208
249
  private unsubscribeTokens;
209
250
  /** Last-observed SDK auth readiness (a planted bearer). Drives the fetch edge. */
@@ -303,6 +344,45 @@ export declare class AccountDialogController {
303
344
  * session committed. Requires `clientId`.
304
345
  */
305
346
  showQr(): Promise<void>;
347
+ /**
348
+ * Web-only: "Sign in with a passkey" on a non-Oxy origin cannot run the
349
+ * WebAuthn ceremony locally — a credential minted with `WEBAUTHN_RP_ID=oxy.so`
350
+ * can only be asserted from `oxy.so`/a subdomain/loopback, a browser-enforced
351
+ * boundary `isOxyRpOrigin()` (consumer-side) already gates on. Instead, open
352
+ * a popup at the auth.oxy.so passkey hub, scoped to the SAME device-flow
353
+ * session {@link showQr} would create (same `authorizeCode`/`sessionToken`
354
+ * pair), and let the SAME poll/socket/claim engine complete it once the hub
355
+ * authorizes the session (`POST /auth/session/authorize-code/:authorizeCode`,
356
+ * bearer-authed, server-side — this method never sees or transmits the
357
+ * secret `sessionToken`).
358
+ *
359
+ * The popup MUST be opened synchronously, before any `await`, or the
360
+ * browser's popup blocker loses the click-gesture attribution and silently
361
+ * blocks it — so {@link openPopup} is invoked as the very first step, before
362
+ * the device-flow session (which needs an async call) even exists. If the
363
+ * popup is blocked (or no `openPopup` was injected), this falls back to
364
+ * `showQr`'s plain QR rendering — no dead end.
365
+ */
366
+ startPasskeyHubSignIn(): Promise<void>;
367
+ /**
368
+ * Shared device-flow session creation for both {@link showQr} (renders the
369
+ * QR) and {@link startPasskeyHubSignIn} (also opens the hub popup) — the
370
+ * same `startCommonsSignIn` → poll/socket wiring either way. Returns the
371
+ * handle on success (already reflected in `signIn`), or `null` on failure
372
+ * (already set as `signIn.error`).
373
+ */
374
+ private startDeviceFlowSession;
375
+ /**
376
+ * Poll {@link PopupWindowHandle.closed} so a user who dismisses the hub
377
+ * popup without completing sign-in gets prompt feedback (there is no DOM
378
+ * event for a cross-origin popup closing — polling is the only mechanism).
379
+ * Cleared by {@link closeActivePopup}, which every sign-in exit path already
380
+ * calls.
381
+ */
382
+ private watchPopup;
383
+ private clearPopupWatchTimer;
384
+ /** Tear down the popup watch timer and close the popup, if any. Idempotent. */
385
+ private closeActivePopup;
306
386
  /**
307
387
  * Resolve whether Commons is installed on this device via the injected
308
388
  * `canOpenApp` probe, updating {@link commonsAvailability} as durable,
@@ -323,7 +403,7 @@ export declare class AccountDialogController {
323
403
  * fallback stays live regardless of the outcome here.
324
404
  */
325
405
  private deepLinkIntoCommonsIfAvailable;
326
- /** Tear down the active sign-in device flow (timers + socket + token) and reset to idle. */
406
+ /** Tear down the active sign-in device flow (timers + socket + popup + token) and reset to idle. */
327
407
  cancelSignIn(): void;
328
408
  private scheduleNextPoll;
329
409
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "12.1.1",
3
+ "version": "12.2.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -117,7 +117,7 @@
117
117
  "invariant": "^2.2.4",
118
118
  "jwt-decode": "^4.0.0",
119
119
  "socket.io-client": "^4.8.1",
120
- "tldts": "^7.4.5",
120
+ "tldts": "^7.4.8",
121
121
  "zod": "^3.25.64"
122
122
  },
123
123
  "peerDependencies": {
@@ -147,10 +147,10 @@
147
147
  "@types/elliptic": "^6.4.18",
148
148
  "@types/express": "^4.17.21",
149
149
  "@types/invariant": "^2.2.34",
150
- "@types/node": "^20.19.9",
150
+ "@types/node": "^20.19.43",
151
151
  "expo-crypto": "~56.0.3",
152
152
  "expo-secure-store": "~56.0.4",
153
- "express": "^4.21.2",
153
+ "express": "^4.22.2",
154
154
  "express-rate-limit": "^7.5.0",
155
155
  "release-it": "^19.0.6",
156
156
  "typescript": "^5.9.2"
@@ -61,7 +61,8 @@
61
61
  "usernameAvailable": "متاح",
62
62
  "usernameTaken": "اسم المستخدم هذا مستخدم بالفعل",
63
63
  "backToSignInLink": "لديك حساب بالفعل؟ سجّل الدخول",
64
- "notAvailableHere": "‏إنشاء الحساب غير متاح في هذا التطبيق بعد أنشئ هويتك باستخدام Commons، أو تفضّل بزيارة oxy.so للتسجيل باستخدام مفتاح مرور.",
64
+ "hubExplainer": "أنشئ هوية Oxy الخاصة بك في نافذة آمنة، ثم عد إلى هنا مباشرة.",
65
+ "continueInWindow": "المتابعة في نافذة جديدة",
65
66
  "welcome": {
66
67
  "title": "مرحبًا بك في Oxy!",
67
68
  "subtitle": "أنشئ حسابك في خطوات قليلة",
@@ -61,7 +61,8 @@
61
61
  "usernameAvailable": "Disponible",
62
62
  "usernameTaken": "Aquest nom d'usuari ja està en ús",
63
63
  "backToSignInLink": "Ja tens compte? Inicia sessió",
64
- "notAvailableHere": "La creació de comptes encara no està disponible en aquesta app: crea la teva identitat amb Commons o visita oxy.so per registrar-te amb una clau d'accés.",
64
+ "hubExplainer": "Crea la teva Oxy ID en una finestra segura i torna de seguida.",
65
+ "continueInWindow": "Continua en una finestra nova",
65
66
  "welcome": {
66
67
  "title": "Benvingut a Oxy!",
67
68
  "subtitle": "Crea el teu compte en pocs passos",
@@ -61,7 +61,8 @@
61
61
  "usernameAvailable": "Verfügbar",
62
62
  "usernameTaken": "Dieser Benutzername ist bereits vergeben",
63
63
  "backToSignInLink": "Sie haben bereits ein Konto? Anmelden",
64
- "notAvailableHere": "Die Kontoerstellung ist in dieser App noch nicht verfügbar – erstellen Sie Ihre Identität mit Commons oder besuchen Sie oxy.so, um sich mit einem Passkey zu registrieren.",
64
+ "hubExplainer": "Erstellen Sie Ihre Oxy-ID in einem sicheren Fenster und kehren Sie dann direkt zurück.",
65
+ "continueInWindow": "In einem neuen Fenster fortfahren",
65
66
  "welcome": {
66
67
  "title": "Willkommen bei Oxy!",
67
68
  "subtitle": "Erstellen Sie Ihr Konto in nur wenigen Schritten",
@@ -68,7 +68,8 @@
68
68
  "usernameAvailable": "Available",
69
69
  "usernameTaken": "This username is taken",
70
70
  "backToSignInLink": "Already have an account? Sign in",
71
- "notAvailableHere": "Account creation isn't available in this app yet — create your identity with Commons, or visit oxy.so to sign up with a passkey.",
71
+ "hubExplainer": "Create your Oxy ID in a secure window, then come right back.",
72
+ "continueInWindow": "Continue in a new window",
72
73
  "welcome": {
73
74
  "title": "Welcome to Oxy!",
74
75
  "subtitle": "Let's create your account in just a few steps",
@@ -68,7 +68,8 @@
68
68
  "usernameAvailable": "Disponible",
69
69
  "usernameTaken": "Este usuario ya está en uso",
70
70
  "backToSignInLink": "¿Ya tienes cuenta? Inicia sesión",
71
- "notAvailableHere": "La creación de cuentas aún no está disponible en esta app: crea tu identidad con Commons o visita oxy.so para registrarte con una clave de acceso.",
71
+ "hubExplainer": "Crea tu Oxy ID en una ventana segura y vuelve enseguida.",
72
+ "continueInWindow": "Continuar en una ventana nueva",
72
73
  "welcome": {
73
74
  "title": "¡Te damos la bienvenida a Oxy!",
74
75
  "subtitle": "Crea tu cuenta en pocos pasos",
@@ -61,7 +61,8 @@
61
61
  "usernameAvailable": "Disponible",
62
62
  "usernameTaken": "Ce nom d'utilisateur est déjà pris",
63
63
  "backToSignInLink": "Vous avez déjà un compte ? Connectez-vous",
64
- "notAvailableHere": "La création de compte n'est pas encore disponible dans cette app — créez votre identité avec Commons, ou rendez-vous sur oxy.so pour vous inscrire avec une clé d'accès.",
64
+ "hubExplainer": "Créez votre Oxy ID dans une fenêtre sécurisée, puis revenez directement.",
65
+ "continueInWindow": "Continuer dans une nouvelle fenêtre",
65
66
  "welcome": {
66
67
  "title": "Bienvenue sur Oxy !",
67
68
  "subtitle": "Créez votre compte en quelques étapes",
@@ -61,7 +61,8 @@
61
61
  "usernameAvailable": "Disponibile",
62
62
  "usernameTaken": "Questo nome utente è già in uso",
63
63
  "backToSignInLink": "Hai già un account? Accedi",
64
- "notAvailableHere": "La creazione dell'account non è ancora disponibile in questa app: crea la tua identità con Commons oppure visita oxy.so per registrarti con una passkey.",
64
+ "hubExplainer": "Crea il tuo Oxy ID in una finestra sicura, poi torna subito qui.",
65
+ "continueInWindow": "Continua in una nuova finestra",
65
66
  "welcome": {
66
67
  "title": "Benvenuto su Oxy!",
67
68
  "subtitle": "Crea il tuo account in pochi passaggi",
@@ -61,7 +61,8 @@
61
61
  "usernameAvailable": "利用可能",
62
62
  "usernameTaken": "このユーザー名は既に使用されています",
63
63
  "backToSignInLink": "すでにアカウントをお持ちですか?サインイン",
64
- "notAvailableHere": "このアプリではまだアカウントを作成できません。Commonsでアイデンティティを作成するか、oxy.soにアクセスしてパスキーで登録してください。",
64
+ "hubExplainer": "安全なウィンドウでOxy IDを作成し、すぐに戻ってきてください。",
65
+ "continueInWindow": "新しいウィンドウで続ける",
65
66
  "welcome": {
66
67
  "title": "Oxyへようこそ!",
67
68
  "subtitle": "数ステップでアカウントを作成",
@@ -61,7 +61,8 @@
61
61
  "usernameAvailable": "사용 가능",
62
62
  "usernameTaken": "이미 사용 중인 사용자 이름입니다",
63
63
  "backToSignInLink": "이미 계정이 있으신가요? 로그인",
64
- "notAvailableHere": " 앱에서는 아직 계정을 만들 없습니다. Commons로 아이덴티티를 만들거나 oxy.so에서 패스키로 가입하세요.",
64
+ "hubExplainer": "안전한 창에서 Oxy ID를 만든 바로 돌아오세요.",
65
+ "continueInWindow": "새 창에서 계속하기",
65
66
  "welcome": {
66
67
  "title": "Oxy에 오신 것을 환영합니다!",
67
68
  "subtitle": "몇 단계만으로 계정을 만드세요",
@@ -61,7 +61,8 @@
61
61
  "usernameAvailable": "Disponível",
62
62
  "usernameTaken": "Este nome de utilizador já está em uso",
63
63
  "backToSignInLink": "Já tem uma conta? Inicie sessão",
64
- "notAvailableHere": "A criação de conta ainda não está disponível nesta app: crie a sua identidade com o Commons ou visite oxy.so para se registar com uma chave de acesso.",
64
+ "hubExplainer": "Crie a sua Oxy ID numa janela segura e volte de seguida.",
65
+ "continueInWindow": "Continuar numa nova janela",
65
66
  "welcome": {
66
67
  "title": "Bem-vindo ao Oxy!",
67
68
  "subtitle": "Crie a sua conta em poucos passos",
@@ -61,7 +61,8 @@
61
61
  "usernameAvailable": "可用",
62
62
  "usernameTaken": "该用户名已被占用",
63
63
  "backToSignInLink": "已有账户?登录",
64
- "notAvailableHere": "此应用暂不支持创建账户——请使用 Commons 创建您的身份,或访问 oxy.so 使用通行密钥注册。",
64
+ "hubExplainer": "在安全窗口中创建您的 Oxy ID,然后立即返回。",
65
+ "continueInWindow": "在新窗口中继续",
65
66
  "welcome": {
66
67
  "title": "欢迎使用Oxy!",
67
68
  "subtitle": "只需几步即可创建您的账户",
package/src/index.ts CHANGED
@@ -624,6 +624,7 @@ export type {
624
624
  AccountDialogSnapshot,
625
625
  AccountDialogView,
626
626
  CommonsAvailability,
627
+ PopupWindowHandle,
627
628
  SignInFlowPhase,
628
629
  SignInFlowState,
629
630
  } from './session/accountDialogController';
@@ -126,7 +126,13 @@ interface Harness {
126
126
  onSignedIn: jest.Mock;
127
127
  }
128
128
 
129
- function makeHarness(over: Partial<{ clientId: string | null }> = {}): Harness {
129
+ function makeHarness(
130
+ over: Partial<{
131
+ clientId: string | null;
132
+ openPopup: () => import('../accountDialogController').PopupWindowHandle | null;
133
+ hubBaseUrl: string;
134
+ }> = {},
135
+ ): Harness {
130
136
  const oxy = makeOxy();
131
137
  const sc = new TestSessionClient(host());
132
138
  const commitSession = jest.fn().mockResolvedValue(undefined);
@@ -138,6 +144,8 @@ function makeHarness(over: Partial<{ clientId: string | null }> = {}): Harness {
138
144
  commitSession,
139
145
  onSignedIn,
140
146
  pollIntervalMs: 1000,
147
+ openPopup: over.openPopup,
148
+ hubBaseUrl: over.hubBaseUrl,
141
149
  });
142
150
  return { controller, oxy, sc, commitSession, onSignedIn };
143
151
  }
@@ -641,6 +649,130 @@ describe('AccountDialogController — sign in with Oxy', () => {
641
649
  });
642
650
  });
643
651
 
652
+ /** A controllable fake `PopupWindowHandle` for `startPasskeyHubSignIn` tests. */
653
+ function fakePopup(): import('../accountDialogController').PopupWindowHandle & { setClosed: () => void } {
654
+ let closed = false;
655
+ return {
656
+ get closed() {
657
+ return closed;
658
+ },
659
+ close: jest.fn(() => {
660
+ closed = true;
661
+ }),
662
+ location: { href: '' },
663
+ setClosed: () => {
664
+ closed = true;
665
+ },
666
+ };
667
+ }
668
+
669
+ describe('AccountDialogController — startPasskeyHubSignIn (b2 passkey hub popup)', () => {
670
+ it('opens the popup synchronously, then navigates it to the hub URL with the authorizeCode once the session exists', async () => {
671
+ const popup = fakePopup();
672
+ const openPopup = jest.fn(() => popup);
673
+ const { controller, oxy } = makeHarness({ openPopup, hubBaseUrl: 'https://auth.oxy.so' });
674
+ oxy.startCommonsSignIn.mockResolvedValue({
675
+ sessionToken: 'secret-tok',
676
+ authorizeCode: 'AUTH-CODE',
677
+ qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
678
+ expiresAt: Date.now() + 300_000,
679
+ status: 'pending',
680
+ });
681
+
682
+ await controller.startPasskeyHubSignIn();
683
+
684
+ expect(openPopup).toHaveBeenCalledTimes(1);
685
+ expect(popup.location.href).toBe('https://auth.oxy.so/hub-passkey?code=AUTH-CODE');
686
+ // Same underlying device-flow session showQr would create — the QR view
687
+ // still renders as a fallback/alternative alongside the popup.
688
+ const snap = controller.getSnapshot();
689
+ expect(snap.view).toBe('qr');
690
+ expect(snap.signIn.phase).toBe('waiting');
691
+ expect(snap.signIn.authorizeCode).toBe('AUTH-CODE');
692
+ controller.cancelSignIn();
693
+ });
694
+
695
+ it('falls back to the plain QR flow (no navigation) when the popup is blocked', async () => {
696
+ const openPopup = jest.fn(() => null);
697
+ const { controller, oxy } = makeHarness({ openPopup });
698
+ oxy.startCommonsSignIn.mockResolvedValue({
699
+ sessionToken: 'secret-tok',
700
+ authorizeCode: 'AUTH-CODE',
701
+ qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
702
+ expiresAt: Date.now() + 300_000,
703
+ status: 'pending',
704
+ });
705
+
706
+ await controller.startPasskeyHubSignIn();
707
+
708
+ expect(oxy.startCommonsSignIn).toHaveBeenCalledWith({ clientId: 'oxy_dk_test' });
709
+ const snap = controller.getSnapshot();
710
+ expect(snap.view).toBe('qr');
711
+ expect(snap.signIn.phase).toBe('waiting');
712
+ expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
713
+ controller.cancelSignIn();
714
+ });
715
+
716
+ it('closes the popup without navigating it when device-flow session creation fails', async () => {
717
+ const popup = fakePopup();
718
+ const openPopup = jest.fn(() => popup);
719
+ const { controller, oxy } = makeHarness({ openPopup });
720
+ oxy.startCommonsSignIn.mockRejectedValue(new Error('network down'));
721
+
722
+ await controller.startPasskeyHubSignIn();
723
+
724
+ expect(popup.close).toHaveBeenCalledTimes(1);
725
+ expect(popup.location.href).toBe('');
726
+ expect(controller.getSnapshot().signIn.phase).toBe('error');
727
+ });
728
+
729
+ it('surfaces "cancelled" and closes the popup watcher when the user closes the popup before authorizing', async () => {
730
+ jest.useFakeTimers();
731
+ try {
732
+ const popup = fakePopup();
733
+ const openPopup = jest.fn(() => popup);
734
+ const { controller, oxy } = makeHarness({ openPopup });
735
+ oxy.startCommonsSignIn.mockResolvedValue({
736
+ sessionToken: 'secret-tok',
737
+ authorizeCode: 'AUTH-CODE',
738
+ qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
739
+ expiresAt: Date.now() + 300_000,
740
+ status: 'pending',
741
+ });
742
+ oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
743
+
744
+ await controller.startPasskeyHubSignIn();
745
+ expect(controller.getSnapshot().signIn.phase).toBe('waiting');
746
+
747
+ popup.setClosed();
748
+ await jest.advanceTimersByTimeAsync(1000); // the 1s popup-close watchdog tick
749
+
750
+ const snap = controller.getSnapshot();
751
+ expect(snap.signIn.phase).toBe('error');
752
+ expect(snap.signIn.error).toMatch(/cancelled/i);
753
+ } finally {
754
+ jest.useRealTimers();
755
+ }
756
+ });
757
+
758
+ it('closes the popup again (never navigates it) when clientId is missing', async () => {
759
+ const popup = fakePopup();
760
+ const openPopup = jest.fn(() => popup);
761
+ const { controller, oxy } = makeHarness({ clientId: null, openPopup });
762
+
763
+ await controller.startPasskeyHubSignIn();
764
+
765
+ // openPopup is invoked unconditionally (before the clientId check, since it
766
+ // must run synchronously) — but the popup must be closed again rather than
767
+ // navigated, since the flow can't proceed without a clientId.
768
+ expect(oxy.startCommonsSignIn).not.toHaveBeenCalled();
769
+ expect(popup.close).toHaveBeenCalledTimes(1);
770
+ expect(popup.location.href).toBe('');
771
+ expect(controller.getSnapshot().signIn.phase).toBe('error');
772
+ expect(controller.getSnapshot().signIn.error).toMatch(/clientId/);
773
+ });
774
+ });
775
+
644
776
  describe('AccountDialogController — Commons availability (canOpenApp)', () => {
645
777
  const START_HANDLE = {
646
778
  sessionToken: 'secret-tok',