@oxyhq/core 5.2.0 → 5.3.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 (117) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/CrossDomainAuth.js +32 -42
  3. package/dist/cjs/i18n/locales/ar-SA.json +1 -0
  4. package/dist/cjs/i18n/locales/ca-ES.json +1 -0
  5. package/dist/cjs/i18n/locales/de-DE.json +1 -0
  6. package/dist/cjs/i18n/locales/en-US.json +1 -0
  7. package/dist/cjs/i18n/locales/es-ES.json +1 -0
  8. package/dist/cjs/i18n/locales/fr-FR.json +1 -0
  9. package/dist/cjs/i18n/locales/it-IT.json +1 -0
  10. package/dist/cjs/i18n/locales/ja-JP.json +1 -0
  11. package/dist/cjs/i18n/locales/ko-KR.json +1 -0
  12. package/dist/cjs/i18n/locales/locales/ar-SA.json +1 -0
  13. package/dist/cjs/i18n/locales/locales/ca-ES.json +1 -0
  14. package/dist/cjs/i18n/locales/locales/de-DE.json +1 -0
  15. package/dist/cjs/i18n/locales/locales/en-US.json +1 -0
  16. package/dist/cjs/i18n/locales/locales/es-ES.json +1 -0
  17. package/dist/cjs/i18n/locales/locales/fr-FR.json +1 -0
  18. package/dist/cjs/i18n/locales/locales/it-IT.json +1 -0
  19. package/dist/cjs/i18n/locales/locales/ja-JP.json +1 -0
  20. package/dist/cjs/i18n/locales/locales/ko-KR.json +1 -0
  21. package/dist/cjs/i18n/locales/locales/pt-PT.json +1 -0
  22. package/dist/cjs/i18n/locales/locales/zh-CN.json +1 -0
  23. package/dist/cjs/i18n/locales/pt-PT.json +1 -0
  24. package/dist/cjs/i18n/locales/zh-CN.json +1 -0
  25. package/dist/cjs/index.js +24 -2
  26. package/dist/cjs/mixins/OxyServices.sso.js +36 -0
  27. package/dist/cjs/mixins/OxyServices.user.js +24 -0
  28. package/dist/cjs/server/index.js +13 -1
  29. package/dist/cjs/session/SessionClient.js +142 -0
  30. package/dist/cjs/session/createSessionClient.js +26 -0
  31. package/dist/cjs/session/projectSessionState.js +75 -0
  32. package/dist/cjs/session/sessionClientHost.js +30 -0
  33. package/dist/cjs/session/socketLoader.js +55 -0
  34. package/dist/cjs/utils/cache.js +2 -0
  35. package/dist/cjs/utils/ssoBounce.js +9 -9
  36. package/dist/cjs/utils/ssoEstablish.js +110 -0
  37. package/dist/esm/.tsbuildinfo +1 -1
  38. package/dist/esm/CrossDomainAuth.js +32 -42
  39. package/dist/esm/i18n/locales/ar-SA.json +1 -0
  40. package/dist/esm/i18n/locales/ca-ES.json +1 -0
  41. package/dist/esm/i18n/locales/de-DE.json +1 -0
  42. package/dist/esm/i18n/locales/en-US.json +1 -0
  43. package/dist/esm/i18n/locales/es-ES.json +1 -0
  44. package/dist/esm/i18n/locales/fr-FR.json +1 -0
  45. package/dist/esm/i18n/locales/it-IT.json +1 -0
  46. package/dist/esm/i18n/locales/ja-JP.json +1 -0
  47. package/dist/esm/i18n/locales/ko-KR.json +1 -0
  48. package/dist/esm/i18n/locales/locales/ar-SA.json +1 -0
  49. package/dist/esm/i18n/locales/locales/ca-ES.json +1 -0
  50. package/dist/esm/i18n/locales/locales/de-DE.json +1 -0
  51. package/dist/esm/i18n/locales/locales/en-US.json +1 -0
  52. package/dist/esm/i18n/locales/locales/es-ES.json +1 -0
  53. package/dist/esm/i18n/locales/locales/fr-FR.json +1 -0
  54. package/dist/esm/i18n/locales/locales/it-IT.json +1 -0
  55. package/dist/esm/i18n/locales/locales/ja-JP.json +1 -0
  56. package/dist/esm/i18n/locales/locales/ko-KR.json +1 -0
  57. package/dist/esm/i18n/locales/locales/pt-PT.json +1 -0
  58. package/dist/esm/i18n/locales/locales/zh-CN.json +1 -0
  59. package/dist/esm/i18n/locales/pt-PT.json +1 -0
  60. package/dist/esm/i18n/locales/zh-CN.json +1 -0
  61. package/dist/esm/index.js +14 -0
  62. package/dist/esm/mixins/OxyServices.sso.js +36 -0
  63. package/dist/esm/mixins/OxyServices.user.js +24 -0
  64. package/dist/esm/server/index.js +10 -0
  65. package/dist/esm/session/SessionClient.js +138 -0
  66. package/dist/esm/session/createSessionClient.js +23 -0
  67. package/dist/esm/session/projectSessionState.js +69 -0
  68. package/dist/esm/session/sessionClientHost.js +27 -0
  69. package/dist/esm/session/socketLoader.js +19 -0
  70. package/dist/esm/utils/cache.js +2 -0
  71. package/dist/esm/utils/ssoBounce.js +9 -9
  72. package/dist/esm/utils/ssoEstablish.js +107 -0
  73. package/dist/types/.tsbuildinfo +1 -1
  74. package/dist/types/CrossDomainAuth.d.ts +33 -13
  75. package/dist/types/index.d.ts +7 -0
  76. package/dist/types/mixins/OxyServices.sso.d.ts +24 -0
  77. package/dist/types/mixins/OxyServices.user.d.ts +14 -0
  78. package/dist/types/server/index.d.ts +2 -0
  79. package/dist/types/session/SessionClient.d.ts +55 -0
  80. package/dist/types/session/createSessionClient.d.ts +23 -0
  81. package/dist/types/session/projectSessionState.d.ts +43 -0
  82. package/dist/types/session/sessionClientHost.d.ts +18 -0
  83. package/dist/types/session/socketLoader.d.ts +9 -0
  84. package/dist/types/utils/ssoBounce.d.ts +9 -9
  85. package/dist/types/utils/ssoEstablish.d.ts +85 -0
  86. package/package.json +1 -1
  87. package/src/CrossDomainAuth.ts +33 -44
  88. package/src/__tests__/crossDomainAuth.test.ts +33 -16
  89. package/src/i18n/locales/ar-SA.json +1 -0
  90. package/src/i18n/locales/ca-ES.json +1 -0
  91. package/src/i18n/locales/de-DE.json +1 -0
  92. package/src/i18n/locales/en-US.json +1 -0
  93. package/src/i18n/locales/es-ES.json +1 -0
  94. package/src/i18n/locales/fr-FR.json +1 -0
  95. package/src/i18n/locales/it-IT.json +1 -0
  96. package/src/i18n/locales/ja-JP.json +1 -0
  97. package/src/i18n/locales/ko-KR.json +1 -0
  98. package/src/i18n/locales/pt-PT.json +1 -0
  99. package/src/i18n/locales/zh-CN.json +1 -0
  100. package/src/index.ts +24 -0
  101. package/src/mixins/OxyServices.sso.ts +55 -0
  102. package/src/mixins/OxyServices.user.ts +26 -0
  103. package/src/mixins/__tests__/sso.test.ts +41 -0
  104. package/src/server/index.ts +12 -0
  105. package/src/session/SessionClient.ts +175 -0
  106. package/src/session/__tests__/SessionClient.rest.test.ts +79 -0
  107. package/src/session/__tests__/SessionClient.socket.test.ts +132 -0
  108. package/src/session/__tests__/SessionClient.state.test.ts +64 -0
  109. package/src/session/__tests__/sessionIntegration.test.ts +202 -0
  110. package/src/session/createSessionClient.ts +31 -0
  111. package/src/session/projectSessionState.ts +83 -0
  112. package/src/session/sessionClientHost.ts +32 -0
  113. package/src/session/socketLoader.ts +28 -0
  114. package/src/utils/__tests__/ssoEstablish.test.ts +204 -0
  115. package/src/utils/cache.ts +2 -0
  116. package/src/utils/ssoBounce.ts +9 -9
  117. package/src/utils/ssoEstablish.ts +174 -0
@@ -1,11 +1,19 @@
1
1
  /**
2
2
  * Cross-Domain Authentication Helper
3
3
  *
4
- * Provides a simplified API for cross-domain SSO authentication that automatically
5
- * selects the best authentication method based on browser capabilities:
4
+ * Provides a simplified API for cross-domain SSO authentication. The
5
+ * automatic sign-in path uses a full-page redirect through the central IdP
6
+ * (`auth.oxy.so`) — a tokenless, universal mechanism that works in every
7
+ * browser.
6
8
  *
7
- * 1. FedCM (if supported) - Modern, Google-style browser-native auth
8
- * 2. Redirect (fallback) - Tokenless central SSO full-page redirect
9
+ * FedCM (`signInWithFedCM`) is intentionally NOT part of the automatic
10
+ * (`'auto'`) path: it is a Chrome-only browser API, and a misconfigured or
11
+ * unreachable FedCM endpoint fails fast and silently, which — combined with a
12
+ * caller's auth-guard effect re-invoking `signIn()` whenever the user is still
13
+ * unauthenticated — produced a real production incident (an accelerating
14
+ * `autoSignIn` → FedCM-fails → redirect retry loop). `signInWithFedCM` remains
15
+ * available for callers that want to opt into it EXPLICITLY
16
+ * (`signIn({ method: 'fedcm' })`).
9
17
  *
10
18
  * Usage:
11
19
  * ```typescript
@@ -13,7 +21,7 @@
13
21
  *
14
22
  * const auth = new CrossDomainAuth(oxyServices);
15
23
  *
16
- * // Automatic method selection
24
+ * // Automatic method selection (always redirect)
17
25
  * const session = await auth.signIn();
18
26
  *
19
27
  * // Or use a specific method
@@ -26,11 +34,11 @@ export class CrossDomainAuth {
26
34
  this.oxyServices = oxyServices;
27
35
  }
28
36
  /**
29
- * Sign in with automatic method selection
37
+ * Sign in with automatic method selection.
30
38
  *
31
- * Tries methods in this order:
32
- * 1. FedCM (if supported and not in private browsing)
33
- * 2. Redirect (always works)
39
+ * Auto mode always uses the full-page redirect (see the class doc comment
40
+ * for why FedCM was removed from this path). Pass `{ method: 'fedcm' }` to
41
+ * opt into FedCM explicitly.
34
42
  *
35
43
  * @param options - Authentication options
36
44
  * @returns Session with user data and access token
@@ -48,22 +56,18 @@ export class CrossDomainAuth {
48
56
  return this.autoSignIn(options);
49
57
  }
50
58
  /**
51
- * Automatic sign-in with progressive enhancement
59
+ * Automatic sign-in.
60
+ *
61
+ * Goes straight to the full-page redirect — the sole automatic method.
62
+ * FedCM is deliberately NOT attempted here (see the class doc comment):
63
+ * it is Chrome-only, and its fast/silent failure mode combined with a
64
+ * caller's auth-guard effect re-invoking `signIn()` produced a real
65
+ * production sign-in loop. Use `signIn({ method: 'fedcm' })` to opt in
66
+ * explicitly.
52
67
  *
53
68
  * @private
54
69
  */
55
70
  async autoSignIn(options) {
56
- // 1. Try FedCM first (best UX, most modern)
57
- if (this.isFedCMSupported()) {
58
- try {
59
- options.onMethodSelected?.('fedcm');
60
- return await this.signInWithFedCM(options);
61
- }
62
- catch (error) {
63
- logger.warn('FedCM failed, falling back to redirect', { component: 'CrossDomainAuth', method: 'autoSignIn' }, error);
64
- }
65
- }
66
- // 2. Fallback to redirect (always works)
67
71
  options.onMethodSelected?.('redirect');
68
72
  this.signInWithRedirect(options);
69
73
  return null;
@@ -100,25 +104,13 @@ export class CrossDomainAuth {
100
104
  /**
101
105
  * Silent sign-in (check for existing session)
102
106
  *
103
- * Tries to automatically sign in without user interaction.
104
- * Works with FedCM and iframe-based silent auth.
107
+ * Tries to automatically sign in without user interaction, via the
108
+ * iframe-based silent auth against the per-apex `/auth/silent` IdP host.
109
+ * FedCM is deliberately NOT attempted here (see the class doc comment).
105
110
  *
106
111
  * @returns Session if user is already signed in, null otherwise
107
112
  */
108
113
  async silentSignIn() {
109
- // Try FedCM silent sign-in first (if supported)
110
- if (this.isFedCMSupported()) {
111
- try {
112
- const session = await this.oxyServices.silentSignInWithFedCM();
113
- if (session) {
114
- return session;
115
- }
116
- }
117
- catch (error) {
118
- logger.debug('FedCM silent sign-in did not resolve', { component: 'CrossDomainAuth', method: 'silentSignIn' }, error);
119
- }
120
- }
121
- // Fallback to iframe-based silent auth
122
114
  try {
123
115
  return await this.oxyServices.silentSignIn();
124
116
  }
@@ -147,15 +139,13 @@ export class CrossDomainAuth {
147
139
  /**
148
140
  * Get recommended authentication method for current environment
149
141
  *
142
+ * Redirect is the sole recommended automatic method — it works in every
143
+ * browser, unlike FedCM (Chrome-only). Callers that want FedCM must opt in
144
+ * explicitly via `signIn({ method: 'fedcm' })`.
145
+ *
150
146
  * @returns Recommended method name and reason
151
147
  */
152
148
  getRecommendedMethod() {
153
- if (this.isFedCMSupported()) {
154
- return {
155
- method: 'fedcm',
156
- reason: 'FedCM is supported - provides best UX with browser-native auth',
157
- };
158
- }
159
149
  if (typeof window !== 'undefined') {
160
150
  return {
161
151
  method: 'redirect',
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "قائمة الحساب",
209
209
  "manage": "إدارة حساب Oxy الخاص بك",
210
+ "viewProfile": "عرض الملف الشخصي",
210
211
  "addAnother": "إضافة حساب آخر",
211
212
  "signOutAll": "تسجيل الخروج من جميع الحسابات",
212
213
  "open": "قائمة الحساب",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Menú del compte",
209
209
  "manage": "Gestiona el teu compte d'Oxy",
210
+ "viewProfile": "Mostra el perfil",
210
211
  "addAnother": "Afegeix un altre compte",
211
212
  "signOutAll": "Tanca la sessió de tots els comptes",
212
213
  "open": "Menú del compte",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Kontomenü",
209
209
  "manage": "Ihr Oxy-Konto verwalten",
210
+ "viewProfile": "Profil ansehen",
210
211
  "addAnother": "Weiteres Konto hinzufügen",
211
212
  "signOutAll": "Von allen Konten abmelden",
212
213
  "open": "Kontomenü",
@@ -1555,6 +1555,7 @@
1555
1555
  "accountMenu": {
1556
1556
  "label": "Account menu",
1557
1557
  "manage": "Manage your Oxy Account",
1558
+ "viewProfile": "View profile",
1558
1559
  "addAnother": "Add another account",
1559
1560
  "signOutAll": "Sign out of all accounts",
1560
1561
  "open": "Account menu",
@@ -1555,6 +1555,7 @@
1555
1555
  "accountMenu": {
1556
1556
  "label": "Menú de cuenta",
1557
1557
  "manage": "Gestiona tu cuenta de Oxy",
1558
+ "viewProfile": "Ver perfil",
1558
1559
  "addAnother": "Añadir otra cuenta",
1559
1560
  "signOutAll": "Cerrar sesión en todas las cuentas",
1560
1561
  "open": "Menú de cuenta",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Menu du compte",
209
209
  "manage": "Gérer votre compte Oxy",
210
+ "viewProfile": "Voir le profil",
210
211
  "addAnother": "Ajouter un autre compte",
211
212
  "signOutAll": "Se déconnecter de tous les comptes",
212
213
  "open": "Menu du compte",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Menu account",
209
209
  "manage": "Gestisci il tuo account Oxy",
210
+ "viewProfile": "Visualizza profilo",
210
211
  "addAnother": "Aggiungi un altro account",
211
212
  "signOutAll": "Esci da tutti gli account",
212
213
  "open": "Menu account",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "アカウントメニュー",
209
209
  "manage": "Oxy アカウントを管理",
210
+ "viewProfile": "プロフィールを表示",
210
211
  "addAnother": "別のアカウントを追加",
211
212
  "signOutAll": "すべてのアカウントからログアウト",
212
213
  "open": "アカウントメニュー",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "계정 메뉴",
209
209
  "manage": "Oxy 계정 관리",
210
+ "viewProfile": "프로필 보기",
210
211
  "addAnother": "다른 계정 추가",
211
212
  "signOutAll": "모든 계정에서 로그아웃",
212
213
  "open": "계정 메뉴",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "قائمة الحساب",
209
209
  "manage": "إدارة حساب Oxy الخاص بك",
210
+ "viewProfile": "عرض الملف الشخصي",
210
211
  "addAnother": "إضافة حساب آخر",
211
212
  "signOutAll": "تسجيل الخروج من جميع الحسابات",
212
213
  "open": "قائمة الحساب",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Menú del compte",
209
209
  "manage": "Gestiona el teu compte d'Oxy",
210
+ "viewProfile": "Mostra el perfil",
210
211
  "addAnother": "Afegeix un altre compte",
211
212
  "signOutAll": "Tanca la sessió de tots els comptes",
212
213
  "open": "Menú del compte",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Kontomenü",
209
209
  "manage": "Ihr Oxy-Konto verwalten",
210
+ "viewProfile": "Profil ansehen",
210
211
  "addAnother": "Weiteres Konto hinzufügen",
211
212
  "signOutAll": "Von allen Konten abmelden",
212
213
  "open": "Kontomenü",
@@ -1555,6 +1555,7 @@
1555
1555
  "accountMenu": {
1556
1556
  "label": "Account menu",
1557
1557
  "manage": "Manage your Oxy Account",
1558
+ "viewProfile": "View profile",
1558
1559
  "addAnother": "Add another account",
1559
1560
  "signOutAll": "Sign out of all accounts",
1560
1561
  "open": "Account menu",
@@ -1555,6 +1555,7 @@
1555
1555
  "accountMenu": {
1556
1556
  "label": "Menú de cuenta",
1557
1557
  "manage": "Gestiona tu cuenta de Oxy",
1558
+ "viewProfile": "Ver perfil",
1558
1559
  "addAnother": "Añadir otra cuenta",
1559
1560
  "signOutAll": "Cerrar sesión en todas las cuentas",
1560
1561
  "open": "Menú de cuenta",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Menu du compte",
209
209
  "manage": "Gérer votre compte Oxy",
210
+ "viewProfile": "Voir le profil",
210
211
  "addAnother": "Ajouter un autre compte",
211
212
  "signOutAll": "Se déconnecter de tous les comptes",
212
213
  "open": "Menu du compte",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Menu account",
209
209
  "manage": "Gestisci il tuo account Oxy",
210
+ "viewProfile": "Visualizza profilo",
210
211
  "addAnother": "Aggiungi un altro account",
211
212
  "signOutAll": "Esci da tutti gli account",
212
213
  "open": "Menu account",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "アカウントメニュー",
209
209
  "manage": "Oxy アカウントを管理",
210
+ "viewProfile": "プロフィールを表示",
210
211
  "addAnother": "別のアカウントを追加",
211
212
  "signOutAll": "すべてのアカウントからログアウト",
212
213
  "open": "アカウントメニュー",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "계정 메뉴",
209
209
  "manage": "Oxy 계정 관리",
210
+ "viewProfile": "프로필 보기",
210
211
  "addAnother": "다른 계정 추가",
211
212
  "signOutAll": "모든 계정에서 로그아웃",
212
213
  "open": "계정 메뉴",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Menu da conta",
209
209
  "manage": "Gerir a sua conta Oxy",
210
+ "viewProfile": "Ver perfil",
210
211
  "addAnother": "Adicionar outra conta",
211
212
  "signOutAll": "Terminar sessão em todas as contas",
212
213
  "open": "Menu da conta",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "账户菜单",
209
209
  "manage": "管理您的 Oxy 账户",
210
+ "viewProfile": "查看个人资料",
210
211
  "addAnother": "添加其他账户",
211
212
  "signOutAll": "退出所有账户",
212
213
  "open": "账户菜单",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Menu da conta",
209
209
  "manage": "Gerir a sua conta Oxy",
210
+ "viewProfile": "Ver perfil",
210
211
  "addAnother": "Adicionar outra conta",
211
212
  "signOutAll": "Terminar sessão em todas as contas",
212
213
  "open": "Menu da conta",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "账户菜单",
209
209
  "manage": "管理您的 Oxy 账户",
210
+ "viewProfile": "查看个人资料",
210
211
  "addAnother": "添加其他账户",
211
212
  "signOutAll": "退出所有账户",
212
213
  "open": "账户菜单",
package/dist/esm/index.js CHANGED
@@ -126,9 +126,23 @@ export { autoDetectAuthWebUrl, registrableApex } from './utils/fapiAutoDetect.js
126
126
  export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './utils/authWebUrl.js';
127
127
  export { parseSsoReturnFragment, consumeSsoReturn } from './utils/ssoReturn.js';
128
128
  export { generateSsoState } from './mixins/OxyServices.sso.js';
129
+ // Post-claim durable-session establish hop (web device-flow / QR sign-in).
130
+ export { establishIdpSessionAfterClaim } from './utils/ssoEstablish.js';
129
131
  // SSO bounce — per-origin sessionStorage keys, bounce URL builder, predicates
130
132
  export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoAttemptedKey, ssoPriorSessionKey, ssoSignedOutKey, ssoCallbackBootstrapKey, ssoNavigate, getSsoCallbackBootstrapScript, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, silentRestoreSuppressed, allowSsoBounce, } from './utils/ssoBounce.js';
131
133
  export { runColdBoot } from './utils/coldBoot.js';
134
+ // ---------------------------------------------------------------------------
135
+ // Session sync (device-scoped multi-account session client)
136
+ // ---------------------------------------------------------------------------
137
+ export { SessionClient } from './session/SessionClient.js';
138
+ // Shared SessionClient integration layer: the host adapter, the pure
139
+ // DeviceSessionState projection helpers, and the client factory are defined
140
+ // ONCE here so `@oxyhq/services` and `@oxyhq/auth` both reuse them instead of
141
+ // duplicating a local copy. Each consumer supplies its own `TokenTransport`
142
+ // (native vs. web mint strategies differ) to `createSessionClient`.
143
+ export { createSessionClientHost } from './session/sessionClientHost.js';
144
+ export { createSessionClient } from './session/createSessionClient.js';
145
+ export { deviceStateToClientSessions, activeSessionIdOf, activeUserOf, accountIdsOf, } from './session/projectSessionState.js';
132
146
  // API response contracts (request/response Zod schemas + inferred types) live in
133
147
  // `@oxyhq/contracts` — the single source of truth shared by the backend and every
134
148
  // client SDK. Import them directly from `@oxyhq/contracts`; `@oxyhq/core` does NOT
@@ -164,5 +164,41 @@ export function OxyServicesSsoMixin(Base) {
164
164
  };
165
165
  return session;
166
166
  }
167
+ /**
168
+ * Mint a server-formed `/sso/establish` URL for the caller's OWN session,
169
+ * bound to an approved RP `origin`.
170
+ *
171
+ * Bearer-authenticated (the session id is taken from the caller's own
172
+ * bearer, server-side — never from any argument). The server validates that
173
+ * `origin` is an approved client origin (and matches the request `Origin`),
174
+ * derives the per-apex IdP host (`auth.<apex>`), mints a short-lived HS256
175
+ * establish-token, and returns a fully-formed
176
+ * `https://<auth-host>/sso/establish?et=…&return_to=<origin>/__oxy/sso-callback&state=<state>`.
177
+ *
178
+ * Used AFTER a web device-flow claim to plant the durable first-party
179
+ * `fedcm_session` cookie so a reload can re-mint a token (see
180
+ * {@link establishIdpSessionAfterClaim}). Cache-free (a POST is never
181
+ * cached, but `cache: false` is explicit).
182
+ *
183
+ * @param origin - The RP origin (`window.location.origin`) to establish for.
184
+ * @param state - The CSRF state echoed back in the callback fragment; the
185
+ * caller persists the SAME value under `ssoStateKey(origin)` so the
186
+ * post-bounce `sso-return` step validates it.
187
+ */
188
+ async requestSsoEstablishUrl(origin, state) {
189
+ if (typeof origin !== 'string' || origin.length === 0) {
190
+ throw this.handleError(new Error('requestSsoEstablishUrl requires a non-empty origin'));
191
+ }
192
+ if (typeof state !== 'string' || state.length === 0) {
193
+ throw this.handleError(new Error('requestSsoEstablishUrl requires a non-empty state'));
194
+ }
195
+ const response = await this.makeRequest('POST', '/sso/establish-token', { origin, state }, { cache: false });
196
+ if (!response ||
197
+ typeof response.establishUrl !== 'string' ||
198
+ response.establishUrl.length === 0) {
199
+ throw this.handleError(new Error('SSO establish-token returned no establishUrl'));
200
+ }
201
+ return { establishUrl: response.establishUrl };
202
+ }
167
203
  };
168
204
  }
@@ -630,6 +630,30 @@ export function OxyServicesUserMixin(Base) {
630
630
  throw this.handleError(error);
631
631
  }
632
632
  }
633
+ /**
634
+ * Get the authenticated VIEWER's OWN mutual-follow user ids — the accounts the
635
+ * viewer follows that ALSO follow the viewer back (a bidirectional follow
636
+ * edge). The viewer is derived server-side from the SDK's auth token (never a
637
+ * param), so there is no target id to pass.
638
+ *
639
+ * Returns a bounded, lean list of ids meant to SEED a "Mutuals" feed (the
640
+ * consumer hydrates/ranks the posts itself) — distinct from
641
+ * {@link getUserMutuals}, which returns hydrated "followers you know" DTOs
642
+ * about ANOTHER profile. An anonymous caller resolves to an empty array.
643
+ */
644
+ async getMutualUserIds(params) {
645
+ try {
646
+ const query = buildPaginationParams(params || {});
647
+ const response = await this.makeRequest('GET', '/users/mutual-ids', query, {
648
+ cache: true,
649
+ cacheTTL: 2 * 60 * 1000, // 2 minutes cache
650
+ });
651
+ return response.data || [];
652
+ }
653
+ catch (error) {
654
+ throw this.handleError(error);
655
+ }
656
+ }
633
657
  /**
634
658
  * Get notifications
635
659
  */
@@ -22,3 +22,13 @@ export { assertSafePublicUrl, isBlockedIp, safeFetch, SsrfRejection, UpstreamErr
22
22
  export { createOxyCors } from './cors.js';
23
23
  // Constant-time secret comparison.
24
24
  export { verifySecret } from './verifySecret.js';
25
+ // Registrable-apex (eTLD+1) derivation via the Public Suffix List — the SINGLE
26
+ // SOURCE OF TRUTH shared with the IdP worker and the client FAPI auto-detect.
27
+ // Pure host handling (no browser deps), so it is safe on the server subpath and
28
+ // lets `@oxyhq/api` derive `auth.<apex>` without duplicating PSL logic.
29
+ export { registrableApex } from '../utils/fapiAutoDetect.js';
30
+ // The single RP callback path the IdP redirects back to. A pure wire-contract
31
+ // constant (no browser deps at module top level), re-used server-side so the
32
+ // `/sso/establish-token` `return_to` cannot drift from what `/sso/establish`
33
+ // validates.
34
+ export { SSO_CALLBACK_PATH } from '../utils/ssoBounce.js';
@@ -0,0 +1,138 @@
1
+ import { deviceSessionStateSchema, deviceSessionSyncSchema, safeParseContract, } from '@oxyhq/contracts';
2
+ import { logger } from '../utils/loggerUtils.js';
3
+ import { getSocketIO } from './socketLoader.js';
4
+ export class SessionClient {
5
+ constructor(host, options = {}) {
6
+ this.host = host;
7
+ this.options = options;
8
+ this.state = null;
9
+ this.listeners = new Set();
10
+ this.socket = null;
11
+ this.tokenUnsub = null;
12
+ this.started = false;
13
+ }
14
+ getState() {
15
+ return this.state;
16
+ }
17
+ subscribe(listener) {
18
+ this.listeners.add(listener);
19
+ return () => {
20
+ this.listeners.delete(listener);
21
+ };
22
+ }
23
+ notify() {
24
+ for (const listener of this.listeners) {
25
+ try {
26
+ listener(this.state);
27
+ }
28
+ catch (error) {
29
+ logger.error('[SessionClient] subscriber threw', error);
30
+ }
31
+ }
32
+ }
33
+ /** Validate + last-writer-wins by revision. Returns true if applied. */
34
+ applyState(raw) {
35
+ const next = safeParseContract(deviceSessionStateSchema, raw);
36
+ if (!next) {
37
+ logger.warn('[SessionClient] discarded invalid session state');
38
+ return false;
39
+ }
40
+ if (this.state && next.revision <= this.state.revision) {
41
+ return false;
42
+ }
43
+ this.state = next;
44
+ this.notify();
45
+ if (this.options.transport) {
46
+ void this.options.transport.ensureActiveToken(next).catch((error) => {
47
+ logger.warn('[SessionClient] ensureActiveToken failed', { component: 'SessionClient' }, error);
48
+ });
49
+ }
50
+ return true;
51
+ }
52
+ /**
53
+ * Validate `{ state, activeToken }`, apply the state, and plant the active token host-side.
54
+ * Token-planting is decoupled from whether `applyState` advanced the revision: a socket push
55
+ * followed by this same `GET /state` fetch returns the SAME revision (applyState no-ops), but
56
+ * the token still needs to be planted. The account-match guard rejects a stale response for an
57
+ * account that is no longer active.
58
+ */
59
+ applySync(raw) {
60
+ const sync = safeParseContract(deviceSessionSyncSchema, raw);
61
+ if (!sync) {
62
+ logger.warn('[SessionClient] discarded invalid session sync');
63
+ return;
64
+ }
65
+ this.applyState(sync.state);
66
+ if (sync.activeToken && this.state && sync.state.activeAccountId === this.state.activeAccountId) {
67
+ this.host.setTokens(sync.activeToken.accessToken);
68
+ }
69
+ }
70
+ async bootstrap() {
71
+ const res = await this.host.makeRequest('GET', '/session/device/state', undefined, { cache: false });
72
+ this.applySync(res?.data);
73
+ }
74
+ async switchAccount(accountId) {
75
+ const res = await this.host.makeRequest('POST', '/session/device/switch', { accountId }, { cache: false });
76
+ this.applySync(res?.data);
77
+ }
78
+ async signOut(target) {
79
+ const res = await this.host.makeRequest('POST', '/session/device/signout', target, { cache: false });
80
+ this.applySync(res?.data);
81
+ }
82
+ async addCurrentAccount() {
83
+ const res = await this.host.makeRequest('POST', '/session/device/add', undefined, { cache: false });
84
+ this.applySync(res?.data);
85
+ }
86
+ async start() {
87
+ if (this.started)
88
+ return;
89
+ this.started = true;
90
+ this.tokenUnsub = this.host.onTokensChanged((token) => {
91
+ if (token && this.socket && !this.socket.connected) {
92
+ this.socket.connect();
93
+ }
94
+ });
95
+ await this.bootstrap();
96
+ await this.connectSocket();
97
+ }
98
+ stop() {
99
+ this.started = false;
100
+ if (this.tokenUnsub) {
101
+ this.tokenUnsub();
102
+ this.tokenUnsub = null;
103
+ }
104
+ if (this.socket) {
105
+ this.socket.disconnect();
106
+ this.socket = null;
107
+ }
108
+ }
109
+ async connectSocket() {
110
+ const io = await getSocketIO();
111
+ if (!io) {
112
+ logger.warn('[SessionClient] no socket.io-client; running REST-only (no realtime sync)', { component: 'SessionClient' });
113
+ return;
114
+ }
115
+ if (!this.started)
116
+ return; // stopped while the dynamic import was in flight
117
+ const hasToken = Boolean(this.host.getAccessToken());
118
+ const socket = io(this.host.getBaseURL(), {
119
+ transports: ['websocket'],
120
+ autoConnect: hasToken,
121
+ auth: (cb) => {
122
+ cb({ token: this.host.getAccessToken() ?? '' });
123
+ },
124
+ });
125
+ socket.on('session_state', (payload) => {
126
+ const applied = this.applyState(payload);
127
+ if (applied) {
128
+ const active = this.state?.activeAccountId ?? null;
129
+ if (active && active !== this.host.getCurrentAccountId()) {
130
+ void this.bootstrap().catch((error) => {
131
+ logger.warn('[SessionClient] post-push token fetch failed', { component: 'SessionClient' }, error);
132
+ });
133
+ }
134
+ }
135
+ });
136
+ this.socket = socket;
137
+ }
138
+ }
@@ -0,0 +1,23 @@
1
+ import { SessionClient } from './SessionClient.js';
2
+ import { createSessionClientHost } from './sessionClientHost.js';
3
+ /**
4
+ * Wires a `SessionClient` over the given `OxyServices` instance: builds the
5
+ * `SessionClientHost` adapter and passes it through together with a
6
+ * caller-supplied `TokenTransport`.
7
+ *
8
+ * The transport is a required parameter (not constructed here) because it is
9
+ * the one piece of this integration that is NOT platform-agnostic: `services`
10
+ * branches native (shared-keychain sign-in) vs. web (silent sign-in), while
11
+ * `auth-sdk` is web-only. Each consumer builds its own transport and passes
12
+ * it in; this factory only wires the platform-agnostic parts (host + client)
13
+ * so neither consumer re-implements them.
14
+ *
15
+ * The host is returned alongside the client (not just the client) so the
16
+ * caller can call `host.setCurrentAccountId(...)` as the active account
17
+ * changes.
18
+ */
19
+ export function createSessionClient(oxyServices, transport) {
20
+ const host = createSessionClientHost(oxyServices);
21
+ const client = new SessionClient(host, { transport });
22
+ return { client, host };
23
+ }