@oxyhq/core 12.0.0 → 12.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/i18n/locales/ar-SA.json +26 -2
  3. package/dist/cjs/i18n/locales/ca-ES.json +26 -2
  4. package/dist/cjs/i18n/locales/de-DE.json +26 -2
  5. package/dist/cjs/i18n/locales/en-US.json +25 -3
  6. package/dist/cjs/i18n/locales/es-ES.json +25 -3
  7. package/dist/cjs/i18n/locales/fr-FR.json +26 -2
  8. package/dist/cjs/i18n/locales/it-IT.json +26 -2
  9. package/dist/cjs/i18n/locales/ja-JP.json +26 -2
  10. package/dist/cjs/i18n/locales/ko-KR.json +26 -2
  11. package/dist/cjs/i18n/locales/locales/ar-SA.json +26 -2
  12. package/dist/cjs/i18n/locales/locales/ca-ES.json +26 -2
  13. package/dist/cjs/i18n/locales/locales/de-DE.json +26 -2
  14. package/dist/cjs/i18n/locales/locales/en-US.json +25 -3
  15. package/dist/cjs/i18n/locales/locales/es-ES.json +25 -3
  16. package/dist/cjs/i18n/locales/locales/fr-FR.json +26 -2
  17. package/dist/cjs/i18n/locales/locales/it-IT.json +26 -2
  18. package/dist/cjs/i18n/locales/locales/ja-JP.json +26 -2
  19. package/dist/cjs/i18n/locales/locales/ko-KR.json +26 -2
  20. package/dist/cjs/i18n/locales/locales/pt-PT.json +26 -2
  21. package/dist/cjs/i18n/locales/locales/zh-CN.json +26 -2
  22. package/dist/cjs/i18n/locales/pt-PT.json +26 -2
  23. package/dist/cjs/i18n/locales/zh-CN.json +26 -2
  24. package/dist/cjs/session/accountDialogController.js +83 -21
  25. package/dist/esm/.tsbuildinfo +1 -1
  26. package/dist/esm/i18n/locales/ar-SA.json +26 -2
  27. package/dist/esm/i18n/locales/ca-ES.json +26 -2
  28. package/dist/esm/i18n/locales/de-DE.json +26 -2
  29. package/dist/esm/i18n/locales/en-US.json +25 -3
  30. package/dist/esm/i18n/locales/es-ES.json +25 -3
  31. package/dist/esm/i18n/locales/fr-FR.json +26 -2
  32. package/dist/esm/i18n/locales/it-IT.json +26 -2
  33. package/dist/esm/i18n/locales/ja-JP.json +26 -2
  34. package/dist/esm/i18n/locales/ko-KR.json +26 -2
  35. package/dist/esm/i18n/locales/locales/ar-SA.json +26 -2
  36. package/dist/esm/i18n/locales/locales/ca-ES.json +26 -2
  37. package/dist/esm/i18n/locales/locales/de-DE.json +26 -2
  38. package/dist/esm/i18n/locales/locales/en-US.json +25 -3
  39. package/dist/esm/i18n/locales/locales/es-ES.json +25 -3
  40. package/dist/esm/i18n/locales/locales/fr-FR.json +26 -2
  41. package/dist/esm/i18n/locales/locales/it-IT.json +26 -2
  42. package/dist/esm/i18n/locales/locales/ja-JP.json +26 -2
  43. package/dist/esm/i18n/locales/locales/ko-KR.json +26 -2
  44. package/dist/esm/i18n/locales/locales/pt-PT.json +26 -2
  45. package/dist/esm/i18n/locales/locales/zh-CN.json +26 -2
  46. package/dist/esm/i18n/locales/pt-PT.json +26 -2
  47. package/dist/esm/i18n/locales/zh-CN.json +26 -2
  48. package/dist/esm/session/accountDialogController.js +83 -21
  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 -16
  52. package/package.json +1 -1
  53. package/src/i18n/locales/ar-SA.json +26 -2
  54. package/src/i18n/locales/ca-ES.json +26 -2
  55. package/src/i18n/locales/de-DE.json +26 -2
  56. package/src/i18n/locales/en-US.json +25 -3
  57. package/src/i18n/locales/es-ES.json +25 -3
  58. package/src/i18n/locales/fr-FR.json +26 -2
  59. package/src/i18n/locales/it-IT.json +26 -2
  60. package/src/i18n/locales/ja-JP.json +26 -2
  61. package/src/i18n/locales/ko-KR.json +26 -2
  62. package/src/i18n/locales/pt-PT.json +26 -2
  63. package/src/i18n/locales/zh-CN.json +26 -2
  64. package/src/index.ts +1 -0
  65. package/src/session/__tests__/accountDialogController.test.ts +79 -2
  66. package/src/session/__tests__/accountProjection.test.ts +30 -0
  67. package/src/session/accountDialogController.ts +128 -27
@@ -43,11 +43,25 @@
43
43
  "currentAccount": "현재",
44
44
  "or": "또는",
45
45
  "viewAllAccounts": "{{count}}개 더 보기",
46
+ "createAccountLink": "Oxy가 처음이신가요? 계정 만들기",
46
47
  "status": {
47
- "accountSwitched": "{{name}} 사용 중"
48
+ "accountSwitched": "{{name}} 사용 중",
49
+ "signingIn": "로그인 중…"
48
50
  }
49
51
  },
50
52
  "signup": {
53
+ "commonsHeadline": "Commons에서 나의 아이덴티티 만들기",
54
+ "commonsExplainer": "Commons는 완전한 자기 보관 방식의 Oxy 아이덴티티입니다. 하나의 앱으로 어디서나 사용하세요.",
55
+ "createInCommons": "Commons에서 나의 아이덴티티 만들기",
56
+ "passkeyAlternative": "Commons가 없으신가요? 이 기기에서 패스키를 만드세요",
57
+ "createAccount": "계정 만들기",
58
+ "creating": "만드는 중…",
59
+ "usernamePlaceholder": "사용자 이름 선택",
60
+ "usernameChecking": "확인 중…",
61
+ "usernameAvailable": "사용 가능",
62
+ "usernameTaken": "이미 사용 중인 사용자 이름입니다",
63
+ "backToSignInLink": "이미 계정이 있으신가요? 로그인",
64
+ "notAvailableHere": "이 앱에서는 아직 계정을 만들 수 없습니다. Commons로 아이덴티티를 만들거나 oxy.so에서 패스키로 가입하세요.",
51
65
  "welcome": {
52
66
  "title": "Oxy에 오신 것을 환영합니다!",
53
67
  "subtitle": "몇 단계만으로 계정을 만드세요",
@@ -94,6 +108,15 @@
94
108
  "legalReminder": "계정을 만들면 서비스 약관 및 개인정보 보호정책에 동의하는 것입니다."
95
109
  }
96
110
  },
111
+ "accountSwitcher": {
112
+ "qrHeadline": "Oxy ID로 로그인",
113
+ "signInWithOxy": "Oxy로 로그인",
114
+ "useIdentityOnDevice": "Commons가 없으신가요? 이 기기의 패스키를 사용하세요",
115
+ "passkeySigningIn": "로그인 중…",
116
+ "getCommons": "Commons 다운로드",
117
+ "commonsNotInstalled": "Commons가 없으신가요? 앱을 다운로드하여 Oxy ID로 로그인하세요.",
118
+ "showQrAnyway": "다른 기기에 Commons가 있습니다"
119
+ },
97
120
  "common": {
98
121
  "actions": {
99
122
  "back": "뒤로",
@@ -105,7 +128,8 @@
105
128
  "verify": "확인",
106
129
  "resetPassword": "비밀번호 재설정",
107
130
  "signedOut": "로그아웃됨",
108
- "close": "닫기"
131
+ "close": "닫기",
132
+ "tryAgain": "다시 시도"
109
133
  },
110
134
  "links": {
111
135
  "recoverAccount": "계정 복구",
@@ -43,11 +43,25 @@
43
43
  "currentAccount": "Atual",
44
44
  "or": "ou",
45
45
  "viewAllAccounts": "Ver mais {{count}}",
46
+ "createAccountLink": "Novo no Oxy? Crie uma conta",
46
47
  "status": {
47
- "accountSwitched": "Agora a usar {{name}}"
48
+ "accountSwitched": "Agora a usar {{name}}",
49
+ "signingIn": "A iniciar sessão…"
48
50
  }
49
51
  },
50
52
  "signup": {
53
+ "commonsHeadline": "Crie a sua identidade no Commons",
54
+ "commonsExplainer": "O Commons é a sua identidade Oxy com autocustódia total: uma app que funciona em todo o lado.",
55
+ "createInCommons": "Crie a sua identidade no Commons",
56
+ "passkeyAlternative": "Não tem o Commons? Crie uma chave de acesso neste dispositivo",
57
+ "createAccount": "Criar conta",
58
+ "creating": "A criar…",
59
+ "usernamePlaceholder": "Escolha um nome de utilizador",
60
+ "usernameChecking": "A verificar…",
61
+ "usernameAvailable": "Disponível",
62
+ "usernameTaken": "Este nome de utilizador já está em uso",
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.",
51
65
  "welcome": {
52
66
  "title": "Bem-vindo ao Oxy!",
53
67
  "subtitle": "Crie a sua conta em poucos passos",
@@ -94,6 +108,15 @@
94
108
  "legalReminder": "Ao criar uma conta, concorda com os nossos Termos de Serviço e Política de Privacidade."
95
109
  }
96
110
  },
111
+ "accountSwitcher": {
112
+ "qrHeadline": "Inicie sessão com a sua identidade Oxy",
113
+ "signInWithOxy": "Iniciar sessão com Oxy",
114
+ "useIdentityOnDevice": "Não tem o Commons? Use uma chave de acesso neste dispositivo",
115
+ "passkeySigningIn": "A iniciar sessão…",
116
+ "getCommons": "Obter o Commons",
117
+ "commonsNotInstalled": "Não tem o Commons? Obtenha a app para iniciar sessão com o seu Oxy ID.",
118
+ "showQrAnyway": "Tenho o Commons noutro dispositivo"
119
+ },
97
120
  "common": {
98
121
  "actions": {
99
122
  "back": "Voltar",
@@ -105,7 +128,8 @@
105
128
  "verify": "Verificar",
106
129
  "resetPassword": "Repor palavra-passe",
107
130
  "signedOut": "Sessão terminada",
108
- "close": "Fechar"
131
+ "close": "Fechar",
132
+ "tryAgain": "Tentar novamente"
109
133
  },
110
134
  "links": {
111
135
  "recoverAccount": "Recuperar a sua conta",
@@ -43,11 +43,25 @@
43
43
  "currentAccount": "当前",
44
44
  "or": "或",
45
45
  "viewAllAccounts": "查看更多{{count}}个",
46
+ "createAccountLink": "初次使用 Oxy?创建账户",
46
47
  "status": {
47
- "accountSwitched": "正在使用{{name}}"
48
+ "accountSwitched": "正在使用{{name}}",
49
+ "signingIn": "正在登录…"
48
50
  }
49
51
  },
50
52
  "signup": {
53
+ "commonsHeadline": "在 Commons 中创建您的身份",
54
+ "commonsExplainer": "Commons 是您完全自主保管的 Oxy 身份——一个应用,随处可用。",
55
+ "createInCommons": "在 Commons 中创建您的身份",
56
+ "passkeyAlternative": "没有 Commons?改为在此设备上创建通行密钥",
57
+ "createAccount": "创建账户",
58
+ "creating": "正在创建…",
59
+ "usernamePlaceholder": "选择一个用户名",
60
+ "usernameChecking": "正在检查…",
61
+ "usernameAvailable": "可用",
62
+ "usernameTaken": "该用户名已被占用",
63
+ "backToSignInLink": "已有账户?登录",
64
+ "notAvailableHere": "此应用暂不支持创建账户——请使用 Commons 创建您的身份,或访问 oxy.so 使用通行密钥注册。",
51
65
  "welcome": {
52
66
  "title": "欢迎使用Oxy!",
53
67
  "subtitle": "只需几步即可创建您的账户",
@@ -94,6 +108,15 @@
94
108
  "legalReminder": "创建账户即表示您同意我们的服务条款和隐私政策。"
95
109
  }
96
110
  },
111
+ "accountSwitcher": {
112
+ "qrHeadline": "使用您的 Oxy 身份登录",
113
+ "signInWithOxy": "使用 Oxy 登录",
114
+ "useIdentityOnDevice": "没有 Commons?改用此设备上的通行密钥",
115
+ "passkeySigningIn": "正在登录…",
116
+ "getCommons": "获取 Commons",
117
+ "commonsNotInstalled": "没有 Commons?获取应用以使用您的 Oxy ID 登录。",
118
+ "showQrAnyway": "我在另一台设备上有 Commons"
119
+ },
97
120
  "common": {
98
121
  "actions": {
99
122
  "back": "返回",
@@ -105,7 +128,8 @@
105
128
  "verify": "验证",
106
129
  "resetPassword": "重置密码",
107
130
  "signedOut": "已退出",
108
- "close": "关闭"
131
+ "close": "关闭",
132
+ "tryAgain": "重试"
109
133
  },
110
134
  "links": {
111
135
  "recoverAccount": "恢复您的账户",
package/src/index.ts CHANGED
@@ -623,6 +623,7 @@ export type {
623
623
  AccountDialogControllerOptions,
624
624
  AccountDialogSnapshot,
625
625
  AccountDialogView,
626
+ CommonsAvailability,
626
627
  SignInFlowPhase,
627
628
  SignInFlowState,
628
629
  } from './session/accountDialogController';
@@ -152,6 +152,7 @@ describe('AccountDialogController — initial + views', () => {
152
152
  expect(snap.loading).toBe(false);
153
153
  expect(snap.switchingAccountId).toBeNull();
154
154
  expect(snap.signIn.phase).toBe('idle');
155
+ expect(snap.commonsAvailability).toBe('unknown');
155
156
  });
156
157
 
157
158
  it('setView / add / close move between views and notify subscribers', () => {
@@ -169,6 +170,19 @@ describe('AccountDialogController — initial + views', () => {
169
170
  expect(seen).toEqual(['add', 'signin', 'accounts']);
170
171
  });
171
172
 
173
+ it('startSignup moves to the signup view and notifies subscribers', () => {
174
+ const { controller } = makeHarness();
175
+ const seen: string[] = [];
176
+ controller.subscribe((s) => seen.push(s.view));
177
+
178
+ controller.startSignup();
179
+ expect(controller.getSnapshot().view).toBe('signup');
180
+ controller.close();
181
+ expect(controller.getSnapshot().view).toBe('accounts');
182
+
183
+ expect(seen).toEqual(['signup', 'accounts']);
184
+ });
185
+
172
186
  it('getSnapshot returns a stable reference until a change occurs', () => {
173
187
  const { controller } = makeHarness();
174
188
  const a = controller.getSnapshot();
@@ -391,6 +405,54 @@ describe('AccountDialogController — switchTo (uniform switch)', () => {
391
405
  expect(commitSession.mock.calls[0][0]).toMatchObject({ sessionId: 'sess-org', accessToken: 'access-org' });
392
406
  });
393
407
 
408
+ it('commits a graph switch via the IN-PLACE commitSwitchedSession — never the hub-syncing commitSession', async () => {
409
+ // PROBLEM 2: an account switch must not run the cross-origin hub-sync
410
+ // full-page redirect. When both funnels are wired, the mint-switch must use
411
+ // commitSwitchedSession (in-place) and NEVER commitSession (which may
412
+ // redirect on an official web origin).
413
+ const oxy = makeOxy();
414
+ const sc = new TestSessionClient(host());
415
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
416
+ jest.spyOn(sc, 'switchAccount').mockResolvedValue(undefined);
417
+ oxy.switchToAccount.mockResolvedValue({
418
+ sessionId: 'sess-org',
419
+ deviceId: 'device-1',
420
+ expiresAt: '2030-01-01T00:00:00Z',
421
+ accessToken: 'access-org',
422
+ user: user('org1'),
423
+ });
424
+ const commitSession = jest.fn().mockResolvedValue(undefined);
425
+ const commitSwitchedSession = jest.fn().mockResolvedValue(undefined);
426
+ const controller = new AccountDialogController({
427
+ oxyServices: oxy as unknown as OxyServices,
428
+ sessionClient: sc,
429
+ clientId: 'oxy_dk_test',
430
+ commitSession,
431
+ commitSwitchedSession,
432
+ });
433
+
434
+ await controller.switchTo('org1');
435
+
436
+ expect(commitSwitchedSession).toHaveBeenCalledTimes(1);
437
+ expect(commitSwitchedSession.mock.calls[0][0]).toMatchObject({ sessionId: 'sess-org', accessToken: 'access-org' });
438
+ expect(commitSession).not.toHaveBeenCalled();
439
+ });
440
+
441
+ it('surfaces a failed switch as snapshot.error instead of silently no-op\'ing', async () => {
442
+ // PROBLEM 1: switching into an account the server refuses (e.g. a 403 for a
443
+ // personal-kind target) must tell the user why — the error is recorded on
444
+ // the snapshot, the switch does not throw, and switchingAccountId resets.
445
+ const { controller, oxy, sc } = makeHarness();
446
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
447
+ oxy.switchToAccount.mockRejectedValue(new Error('Cannot switch into a personal account'));
448
+
449
+ await expect(controller.switchTo('org1')).resolves.toBeUndefined();
450
+
451
+ const snap = controller.getSnapshot();
452
+ expect(snap.error).toBe('Cannot switch into a personal account');
453
+ expect(snap.switchingAccountId).toBeNull();
454
+ });
455
+
394
456
  it('falls back to SessionClient.registerAndActivate when no commitSession is supplied', async () => {
395
457
  const oxy = makeOxy();
396
458
  const sc = new TestSessionClient(host());
@@ -579,7 +641,7 @@ describe('AccountDialogController — sign in with Oxy', () => {
579
641
  });
580
642
  });
581
643
 
582
- describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
644
+ describe('AccountDialogController — Commons availability (canOpenApp)', () => {
583
645
  const START_HANDLE = {
584
646
  sessionToken: 'secret-tok',
585
647
  authorizeCode: 'AUTH-CODE',
@@ -621,6 +683,7 @@ describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
621
683
  expect(snap.view).toBe('qr');
622
684
  expect(snap.signIn.phase).toBe('waiting');
623
685
  expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
686
+ expect(snap.commonsAvailability).toBe('available');
624
687
  controller.cancelSignIn();
625
688
  });
626
689
 
@@ -635,6 +698,7 @@ describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
635
698
  expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
636
699
  expect(openUrl).not.toHaveBeenCalled();
637
700
  expect(controller.getSnapshot().signIn.phase).toBe('waiting');
701
+ expect(controller.getSnapshot().commonsAvailability).toBe('unavailable');
638
702
  controller.cancelSignIn();
639
703
  });
640
704
 
@@ -647,10 +711,11 @@ describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
647
711
 
648
712
  expect(openUrl).not.toHaveBeenCalled();
649
713
  expect(controller.getSnapshot().signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
714
+ expect(controller.getSnapshot().commonsAvailability).toBe('unknown');
650
715
  controller.cancelSignIn();
651
716
  });
652
717
 
653
- it('swallows a canOpenApp probe rejection and keeps the QR fallback', async () => {
718
+ it('swallows a canOpenApp probe rejection, keeps the QR fallback, and records unavailable', async () => {
654
719
  const openUrl = jest.fn();
655
720
  const canOpenApp = jest.fn().mockRejectedValue(new Error('probe boom'));
656
721
  const { controller } = makeController({ openUrl, canOpenApp });
@@ -660,8 +725,20 @@ describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
660
725
 
661
726
  expect(openUrl).not.toHaveBeenCalled();
662
727
  expect(controller.getSnapshot().signIn.phase).toBe('waiting');
728
+ expect(controller.getSnapshot().commonsAvailability).toBe('unavailable');
663
729
  controller.cancelSignIn();
664
730
  });
731
+
732
+ it('start() eagerly resolves commonsAvailability without requiring a QR flow', async () => {
733
+ const canOpenApp = jest.fn().mockResolvedValue(true);
734
+ const { controller } = makeController({ canOpenApp });
735
+
736
+ controller.start();
737
+ await flush();
738
+
739
+ expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
740
+ expect(controller.getSnapshot().commonsAvailability).toBe('available');
741
+ });
665
742
  });
666
743
 
667
744
  describe('AccountDialogController — /auth-session socket (instant QR wake)', () => {
@@ -164,6 +164,36 @@ describe('projectSwitchableAccounts', () => {
164
164
  });
165
165
  expect(rows.every((r) => !r.isCurrent)).toBe(true);
166
166
  });
167
+
168
+ it('keeps the OPERATOR full set when acting-as a sub-account (no collapse to the active account)', () => {
169
+ // nate operates albert: albert is the active on-device account, nate is also
170
+ // on-device, and the graph is OPERATOR-anchored (the server returns nate's
171
+ // full forest regardless of which account is active). The projection must
172
+ // faithfully union — switching in only changes which row is `isCurrent`,
173
+ // never which accounts are listed. This is the switcher-collapse regression.
174
+ const rows = projectSwitchableAccounts({
175
+ state: state([{ accountId: 'nate', sessionId: 's-nate' }, { accountId: 'albert', sessionId: 's-albert' }], 'albert'),
176
+ graph: [
177
+ graphNode('nate', { kind: 'personal', relationship: 'self' }),
178
+ graphNode('albert', { relationship: 'owner' }),
179
+ graphNode('oxy', { relationship: 'owner' }),
180
+ graphNode('faircoin', { relationship: 'owner' }),
181
+ ],
182
+ profilesById: mapOf(user('nate'), user('albert'), user('oxy'), user('faircoin')),
183
+ resolveAvatarUrl: noAvatar,
184
+ });
185
+
186
+ // The full operable set is present even though a leaf sub-account is active.
187
+ expect(rows.map((r) => r.accountId).sort()).toEqual(['albert', 'faircoin', 'nate', 'oxy']);
188
+ // Exactly the active sub-account is flagged current; operator + siblings stay.
189
+ expect(rows.find((r) => r.accountId === 'albert')?.isCurrent).toBe(true);
190
+ expect(rows.filter((r) => r.isCurrent)).toHaveLength(1);
191
+ // The operator's own personal account is not dropped by acting-as.
192
+ expect(rows.find((r) => r.accountId === 'nate')).toBeDefined();
193
+ // Graph-only siblings (operable but not yet on this device) still appear.
194
+ expect(rows.find((r) => r.accountId === 'oxy')?.onDevice).toBe(false);
195
+ expect(rows.find((r) => r.accountId === 'faircoin')?.onDevice).toBe(false);
196
+ });
167
197
  });
168
198
 
169
199
  describe('switchableAccountIds', () => {
@@ -12,17 +12,23 @@
12
12
  * - the unified account list (via {@link projectSwitchableAccounts}), fetched
13
13
  * from `SessionClient` state ∪ `oxyServices.listAccounts()` and hydrated
14
14
  * with `oxyServices.getUsersByIds()`;
15
- * - the dialog `view` state machine (`accounts` | `signin` | `qr` | `add`);
15
+ * - the dialog `view` state machine (`accounts` | `signin` | `qr` | `add` |
16
+ * `signup`);
16
17
  * - `switchTo` (the uniform switch: `SessionClient.switchAccount` for an
17
18
  * account already on the device, `oxyServices.switchToAccount` to mint on
18
19
  * first entry into a graph account — reusing the existing SDK primitives, no
19
20
  * new switch path);
20
21
  * - the "Sign in with Oxy" device flow (same-device shared-keychain via
21
22
  * `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
22
- * via `startCommonsSignIn` → poll → `claimSessionByToken`).
23
+ * via `startCommonsSignIn` → poll → `claimSessionByToken`);
24
+ * - `commonsAvailability` — whether Commons is installed on this device
25
+ * (native only, via the injected `canOpenApp` probe), so the QR view can
26
+ * offer a "Get Commons" fallback instead of a same-device dead end.
23
27
  *
24
28
  * Sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff —
25
- * password, social login, and 2FA were removed ecosystem-wide.
29
+ * password, social login, and 2FA were removed ecosystem-wide. Account
30
+ * creation (`signup` view) is the same two identity backends: a passkey
31
+ * ceremony on web, or a Commons-created identity.
26
32
  */
27
33
 
28
34
  import type { OxyServices } from '../OxyServices';
@@ -40,7 +46,20 @@ import {
40
46
  import type { AccountNode } from '../mixins/OxyServices.accounts';
41
47
 
42
48
  /** The dialog's top-level view. */
43
- export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add';
49
+ export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add' | 'signup';
50
+
51
+ /**
52
+ * Whether Commons is installed on this device, as resolved by the injected
53
+ * `canOpenApp` probe:
54
+ * - `'unknown'` — not yet probed, OR no probe was injected (web — there is
55
+ * no API to ask a browser whether a custom URL scheme is registered, so
56
+ * this stays `'unknown'` forever there and the QR view renders
57
+ * unconditionally, no gating).
58
+ * - `'checking'` — the probe is in flight.
59
+ * - `'available'` / `'unavailable'` — the probe's resolved terminal answer
60
+ * (native only). A probe error is treated as `'unavailable'` (fail-closed).
61
+ */
62
+ export type CommonsAvailability = 'unknown' | 'checking' | 'available' | 'unavailable';
44
63
 
45
64
  /** Lifecycle phase of the "Sign in with Oxy" device flow. */
46
65
  export type SignInFlowPhase = 'idle' | 'starting' | 'waiting' | 'authorized' | 'error';
@@ -80,6 +99,8 @@ export interface AccountDialogSnapshot {
80
99
  switchingAccountId: string | null;
81
100
  /** The "Sign in with Oxy" device-flow state. */
82
101
  signIn: SignInFlowState;
102
+ /** Whether Commons is installed on this device. See {@link CommonsAvailability}. */
103
+ commonsAvailability: CommonsAvailability;
83
104
  }
84
105
 
85
106
  /** Construction options for {@link AccountDialogController}. */
@@ -98,15 +119,36 @@ export interface AccountDialogControllerOptions {
98
119
  /** Locale for display-name resolution. */
99
120
  locale?: string;
100
121
  /**
101
- * Commit a freshly-authorized session (device flow / shared identity / minted
102
- * graph switch) into the host's session set — device-first registration +
103
- * durable persist + profile hydration. The consumer supplies its provider's
104
- * commit path (`useOxy().handleWebSession` / the auth-sdk equivalent). Called
105
- * AFTER the SDK has planted the access token. When omitted the controller
106
- * falls back to `SessionClient.registerAndActivate` (registration + activation
107
- * only — no provider-side durable persist/hydration).
122
+ * Commit a freshly-authorized SIGN-IN session (device flow / shared identity)
123
+ * into the host's session set — device-first registration + durable persist +
124
+ * profile hydration. The consumer supplies its provider's commit path
125
+ * (`useOxy().handleWebSession` / the auth-sdk equivalent). Called AFTER the SDK
126
+ * has planted the access token. When omitted the controller falls back to
127
+ * `SessionClient.registerAndActivate` (registration + activation only — no
128
+ * provider-side durable persist/hydration).
129
+ *
130
+ * This is the SIGN-IN commit: on an official web origin it may run the
131
+ * cross-origin hub-sync (a full-page redirect to `auth.oxy.so/sync`) that
132
+ * bootstraps silent OAuth restore on OTHER origins. A first sign-in on a web
133
+ * origin legitimately needs that. An account SWITCH does NOT — see
134
+ * {@link commitSwitchedSession}.
108
135
  */
109
136
  commitSession?: (session: SessionLoginResponse) => Promise<void>;
137
+ /**
138
+ * Commit a minted graph SWITCH session into the host's session set — same
139
+ * device-first registration + durable persist + profile hydration as
140
+ * {@link commitSession}, but IN-PLACE: it must NOT trigger the cross-origin
141
+ * hub-sync redirect. Switching into an account you already operate reuses the
142
+ * device credential that was already hub-synced at the original sign-in, so
143
+ * re-syncing is redundant and a full-page redirect on switch is the exact
144
+ * regression this separation prevents. Cross-tab/app propagation of the switch
145
+ * still happens instantly via the server's device-scoped `session_state` /
146
+ * `session_accounts_changed` socket broadcast — no navigation required.
147
+ *
148
+ * When omitted the controller falls back to {@link commitSession} (if wired)
149
+ * and then to `SessionClient.registerAndActivate`.
150
+ */
151
+ commitSwitchedSession?: (session: SessionLoginResponse) => Promise<void>;
110
152
  /** Notified after a completed sign-in (bearer planted + session committed). */
111
153
  onSignedIn?: (user: MinimalUserData) => void;
112
154
  /**
@@ -179,6 +221,7 @@ export class AccountDialogController {
179
221
  private readonly clientId: string | null;
180
222
  private readonly locale?: string;
181
223
  private readonly commitSession?: (session: SessionLoginResponse) => Promise<void>;
224
+ private readonly commitSwitchedSession?: (session: SessionLoginResponse) => Promise<void>;
182
225
  private readonly onSignedIn?: (user: MinimalUserData) => void;
183
226
  private readonly pollIntervalMs: number;
184
227
  private readonly openUrl?: (url: string) => void;
@@ -195,6 +238,7 @@ export class AccountDialogController {
195
238
  private error: string | null = null;
196
239
  private switchingAccountId: string | null = null;
197
240
  private signIn: SignInFlowState = IDLE_SIGN_IN;
241
+ private commonsAvailability: CommonsAvailability = 'unknown';
198
242
 
199
243
  // --- Sign-in device-flow bookkeeping ---
200
244
  /** The secret device-flow token of the active QR flow (never surfaced). */
@@ -228,6 +272,7 @@ export class AccountDialogController {
228
272
  this.clientId = options.clientId ?? null;
229
273
  this.locale = options.locale;
230
274
  this.commitSession = options.commitSession;
275
+ this.commitSwitchedSession = options.commitSwitchedSession;
231
276
  this.onSignedIn = options.onSignedIn;
232
277
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
233
278
  this.openUrl = options.openUrl;
@@ -288,6 +333,11 @@ export class AccountDialogController {
288
333
  // bearer is already planted (warm start); when signed out (cold boot before
289
334
  // restore) it re-projects from device state and makes NO private call.
290
335
  void this.refresh();
336
+ // Eager, cached Commons-availability probe (native only — a no-op when no
337
+ // `canOpenApp` was injected). It's a cheap local OS check, so by the time a
338
+ // user actually opens the sign-in entry it has almost always resolved —
339
+ // `showQr`'s own lazy probe below is only the safety net for the rare race.
340
+ void this.resolveCommonsAvailability();
291
341
  }
292
342
 
293
343
  /**
@@ -378,6 +428,11 @@ export class AccountDialogController {
378
428
  this.setView('add');
379
429
  }
380
430
 
431
+ /** Switch to the "create account" view (passkey / Commons signup entry). */
432
+ startSignup(): void {
433
+ this.setView('signup');
434
+ }
435
+
381
436
  // =========================================================================
382
437
  // Account list
383
438
  // =========================================================================
@@ -523,6 +578,10 @@ export class AccountDialogController {
523
578
  accessToken: result.accessToken,
524
579
  },
525
580
  result.user,
581
+ // A switch is IN-PLACE: commit without the hub-sync redirect (the
582
+ // device is already known/synced). Cross-tab/app propagation rides the
583
+ // server's `session_state` socket broadcast, not a navigation.
584
+ { fromSwitch: true },
526
585
  );
527
586
  }
528
587
  // Re-project + refetch immediately; the subscription also fires.
@@ -591,34 +650,65 @@ export class AccountDialogController {
591
650
  // connect, so it now runs at the slow fallback cadence.
592
651
  this.openAuthSessionSocket(handle.sessionToken);
593
652
  this.scheduleNextPoll(handle.sessionToken);
594
- // Same-device convenience: if Commons is installed (native only — `canOpenApp`
595
- // is undefined/false on web), deep-link straight into its approve screen with
596
- // the same `oxycommons://approve?...` payload the QR encodes. The QR + polling
653
+ // Same-device convenience: if Commons is confirmed installed (native
654
+ // only stays `'unknown'` on web, where this never opens anything),
655
+ // deep-link straight into its approve screen with the same
656
+ // `oxycommons://approve?...` payload the QR encodes. The QR + polling
597
657
  // stay live as the fallback, so a user who dismisses the app-open still
598
658
  // completes the sign-in by scanning.
599
- void this.maybeOpenCommons(handle.qrPayload);
659
+ void this.deepLinkIntoCommonsIfAvailable(handle.qrPayload);
600
660
  } catch (error) {
601
661
  this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: errorMessage(error) });
602
662
  }
603
663
  }
604
664
 
605
665
  /**
606
- * When a `canOpenApp` probe is injected and reports Commons installed, open the
607
- * approve deep link via the injected `openUrl`. Best-effort and non-blocking: a
608
- * probe/open failure is logged and swallowed the QR/polling fallback remains.
666
+ * Resolve whether Commons is installed on this device via the injected
667
+ * `canOpenApp` probe, updating {@link commonsAvailability} as durable,
668
+ * observable snapshot state. Native onlya no-op when `canOpenApp` was
669
+ * not injected (web), where `commonsAvailability` stays `'unknown'` forever
670
+ * and the QR view renders unconditionally (no gating).
671
+ *
672
+ * Replaces the old `maybeOpenCommons` fire-and-forget probe, whose outcome
673
+ * was only ever reflected by whether Commons silently opened — a probe
674
+ * failure or "not installed" answer was swallowed into a debug log with no
675
+ * way for the UI to react. `commonsAvailability` fixes that.
609
676
  */
610
- private async maybeOpenCommons(qrPayload: string): Promise<void> {
611
- if (!this.canOpenApp || !this.openUrl) return;
677
+ private async resolveCommonsAvailability(): Promise<void> {
678
+ if (!this.canOpenApp) return;
679
+ this.commonsAvailability = 'checking';
680
+ this.emit();
681
+ let available = false;
612
682
  try {
613
- if (await this.canOpenApp(COMMONS_APP_SCHEME)) {
614
- this.openUrl(qrPayload);
615
- }
683
+ available = await this.canOpenApp(COMMONS_APP_SCHEME);
616
684
  } catch (error) {
617
685
  logger.debug(
618
- '[AccountDialogController] Commons deep-link probe failed (QR fallback active)',
686
+ '[AccountDialogController] Commons availability probe failed',
619
687
  { component: 'AccountDialogController' },
620
688
  error,
621
689
  );
690
+ available = false; // fail-closed — treat a probe error as "not installed"
691
+ }
692
+ this.commonsAvailability = available ? 'available' : 'unavailable';
693
+ this.emit();
694
+ }
695
+
696
+ /**
697
+ * When Commons is confirmed installed, deep-link straight into its approve
698
+ * screen via the injected `openUrl` with the same `oxycommons://approve?...`
699
+ * payload the QR encodes. Best-effort and non-blocking — the QR/polling
700
+ * fallback stays live regardless of the outcome here.
701
+ */
702
+ private async deepLinkIntoCommonsIfAvailable(qrPayload: string): Promise<void> {
703
+ if (!this.openUrl) return;
704
+ if (this.commonsAvailability === 'unknown' || this.commonsAvailability === 'checking') {
705
+ // The eager `start()` probe hasn't resolved yet (or was never run, e.g.
706
+ // `showQr` called without a prior `start()`) — resolve it now rather
707
+ // than skipping the deep link.
708
+ await this.resolveCommonsAvailability();
709
+ }
710
+ if (this.commonsAvailability === 'available') {
711
+ this.openUrl(qrPayload);
622
712
  }
623
713
  }
624
714
 
@@ -755,15 +845,25 @@ export class AccountDialogController {
755
845
 
756
846
  /**
757
847
  * Register a token-planted session into the device set. Prefers the
758
- * consumer's `commitSession` (durable persist + hydration); falls back to
848
+ * consumer's commit funnel (durable persist + hydration); falls back to
759
849
  * `SessionClient.registerAndActivate` (registration + activation only).
850
+ *
851
+ * A SWITCH (`opts.fromSwitch`) uses the IN-PLACE `commitSwitchedSession` funnel
852
+ * so it never runs the cross-origin hub-sync redirect; a SIGN-IN uses
853
+ * `commitSession` (which may hub-sync on an official web origin). When the
854
+ * switch funnel is not wired it falls back to the sign-in funnel, then to
855
+ * `registerAndActivate`.
760
856
  */
761
857
  private async commitAuthorizedSession(
762
858
  session: SessionLoginResponse,
763
859
  user: MinimalUserData,
860
+ opts?: { fromSwitch?: boolean },
764
861
  ): Promise<void> {
765
- if (this.commitSession) {
766
- await this.commitSession(session);
862
+ const commit = opts?.fromSwitch
863
+ ? this.commitSwitchedSession ?? this.commitSession
864
+ : this.commitSession;
865
+ if (commit) {
866
+ await commit(session);
767
867
  } else {
768
868
  await this.sessionClient.registerAndActivate(user.id);
769
869
  }
@@ -875,6 +975,7 @@ export class AccountDialogController {
875
975
  error: this.error,
876
976
  switchingAccountId: this.switchingAccountId,
877
977
  signIn: this.signIn,
978
+ commonsAvailability: this.commonsAvailability,
878
979
  };
879
980
  }
880
981