@oxyhq/services 14.1.0 → 15.0.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 (262) hide show
  1. package/lib/commonjs/index.js +0 -17
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/SignInAccountChooser.js +189 -0
  4. package/lib/commonjs/ui/components/SignInAccountChooser.js.map +1 -0
  5. package/lib/commonjs/ui/components/SignInModal.js +350 -131
  6. package/lib/commonjs/ui/components/SignInModal.js.map +1 -1
  7. package/lib/commonjs/ui/context/OxyContext.js +340 -1547
  8. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  9. package/lib/commonjs/ui/context/hooks/useAuthOperations.js +61 -64
  10. package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
  11. package/lib/commonjs/ui/hooks/useAuth.js +14 -33
  12. package/lib/commonjs/ui/hooks/useAuth.js.map +1 -1
  13. package/lib/commonjs/ui/hooks/usePasswordSignIn.js +170 -0
  14. package/lib/commonjs/ui/hooks/usePasswordSignIn.js.map +1 -0
  15. package/lib/commonjs/ui/hooks/useProfileEditing.js +2 -5
  16. package/lib/commonjs/ui/hooks/useProfileEditing.js.map +1 -1
  17. package/lib/commonjs/ui/hooks/useSessionManagement.js +2 -2
  18. package/lib/commonjs/ui/hooks/useSessionManagement.js.map +1 -1
  19. package/lib/commonjs/ui/navigation/routes.js +1 -0
  20. package/lib/commonjs/ui/navigation/routes.js.map +1 -1
  21. package/lib/commonjs/ui/screens/EditProfileFieldScreen.js +93 -68
  22. package/lib/commonjs/ui/screens/EditProfileFieldScreen.js.map +1 -1
  23. package/lib/commonjs/ui/screens/EditProfileScreen.js +187 -0
  24. package/lib/commonjs/ui/screens/EditProfileScreen.js.map +1 -0
  25. package/lib/commonjs/ui/screens/ManageAccountScreen.js +3 -7
  26. package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
  27. package/lib/commonjs/ui/screens/OxyAuthScreen.js +231 -95
  28. package/lib/commonjs/ui/screens/OxyAuthScreen.js.map +1 -1
  29. package/lib/commonjs/ui/screens/ProfileScreen.js +15 -2
  30. package/lib/commonjs/ui/screens/ProfileScreen.js.map +1 -1
  31. package/lib/commonjs/ui/screens/linkFormat.js +38 -0
  32. package/lib/commonjs/ui/screens/linkFormat.js.map +1 -0
  33. package/lib/commonjs/ui/session/authStore.js +146 -0
  34. package/lib/commonjs/ui/session/authStore.js.map +1 -0
  35. package/lib/commonjs/ui/session/createSessionClient.js +23 -17
  36. package/lib/commonjs/ui/session/createSessionClient.js.map +1 -1
  37. package/lib/commonjs/ui/session/index.js +13 -7
  38. package/lib/commonjs/ui/session/index.js.map +1 -1
  39. package/lib/commonjs/ui/session/tokenTransport.js +19 -31
  40. package/lib/commonjs/ui/session/tokenTransport.js.map +1 -1
  41. package/lib/commonjs/ui/utils/isWebBrowser.js +13 -0
  42. package/lib/commonjs/ui/utils/isWebBrowser.js.map +1 -0
  43. package/lib/commonjs/utils/deviceFlowSignIn.js +13 -16
  44. package/lib/commonjs/utils/deviceFlowSignIn.js.map +1 -1
  45. package/lib/module/index.js +0 -5
  46. package/lib/module/index.js.map +1 -1
  47. package/lib/module/ui/components/SignInAccountChooser.js +183 -0
  48. package/lib/module/ui/components/SignInAccountChooser.js.map +1 -0
  49. package/lib/module/ui/components/SignInModal.js +352 -134
  50. package/lib/module/ui/components/SignInModal.js.map +1 -1
  51. package/lib/module/ui/context/OxyContext.js +342 -1551
  52. package/lib/module/ui/context/OxyContext.js.map +1 -1
  53. package/lib/module/ui/context/hooks/useAuthOperations.js +60 -63
  54. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  55. package/lib/module/ui/hooks/useAuth.js +14 -33
  56. package/lib/module/ui/hooks/useAuth.js.map +1 -1
  57. package/lib/module/ui/hooks/usePasswordSignIn.js +165 -0
  58. package/lib/module/ui/hooks/usePasswordSignIn.js.map +1 -0
  59. package/lib/module/ui/hooks/useProfileEditing.js +2 -5
  60. package/lib/module/ui/hooks/useProfileEditing.js.map +1 -1
  61. package/lib/module/ui/hooks/useSessionManagement.js +1 -1
  62. package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
  63. package/lib/module/ui/navigation/routes.js +1 -0
  64. package/lib/module/ui/navigation/routes.js.map +1 -1
  65. package/lib/module/ui/screens/EditProfileFieldScreen.js +91 -66
  66. package/lib/module/ui/screens/EditProfileFieldScreen.js.map +1 -1
  67. package/lib/module/ui/screens/EditProfileScreen.js +182 -0
  68. package/lib/module/ui/screens/EditProfileScreen.js.map +1 -0
  69. package/lib/module/ui/screens/ManageAccountScreen.js +3 -7
  70. package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
  71. package/lib/module/ui/screens/OxyAuthScreen.js +233 -97
  72. package/lib/module/ui/screens/OxyAuthScreen.js.map +1 -1
  73. package/lib/module/ui/screens/ProfileScreen.js +15 -2
  74. package/lib/module/ui/screens/ProfileScreen.js.map +1 -1
  75. package/lib/module/ui/screens/linkFormat.js +31 -0
  76. package/lib/module/ui/screens/linkFormat.js.map +1 -0
  77. package/lib/module/ui/session/authStore.js +143 -0
  78. package/lib/module/ui/session/authStore.js.map +1 -0
  79. package/lib/module/ui/session/createSessionClient.js +22 -16
  80. package/lib/module/ui/session/createSessionClient.js.map +1 -1
  81. package/lib/module/ui/session/index.js +9 -9
  82. package/lib/module/ui/session/index.js.map +1 -1
  83. package/lib/module/ui/session/tokenTransport.js +20 -33
  84. package/lib/module/ui/session/tokenTransport.js.map +1 -1
  85. package/lib/module/ui/utils/isWebBrowser.js +11 -0
  86. package/lib/module/ui/utils/isWebBrowser.js.map +1 -0
  87. package/lib/module/utils/deviceFlowSignIn.js +14 -17
  88. package/lib/module/utils/deviceFlowSignIn.js.map +1 -1
  89. package/lib/typescript/commonjs/index.d.ts +0 -1
  90. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  91. package/lib/typescript/commonjs/ui/components/SignInAccountChooser.d.ts +29 -0
  92. package/lib/typescript/commonjs/ui/components/SignInAccountChooser.d.ts.map +1 -0
  93. package/lib/typescript/commonjs/ui/components/SignInModal.d.ts +15 -10
  94. package/lib/typescript/commonjs/ui/components/SignInModal.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +36 -42
  96. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts +18 -23
  98. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +0 -2
  100. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  101. package/lib/typescript/commonjs/ui/hooks/useAuth.d.ts.map +1 -1
  102. package/lib/typescript/commonjs/ui/hooks/usePasswordSignIn.d.ts +31 -0
  103. package/lib/typescript/commonjs/ui/hooks/usePasswordSignIn.d.ts.map +1 -0
  104. package/lib/typescript/commonjs/ui/hooks/useProfileEditing.d.ts +0 -1
  105. package/lib/typescript/commonjs/ui/hooks/useProfileEditing.d.ts.map +1 -1
  106. package/lib/typescript/commonjs/ui/navigation/routes.d.ts +1 -1
  107. package/lib/typescript/commonjs/ui/navigation/routes.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/ui/screens/EditProfileFieldScreen.d.ts.map +1 -1
  109. package/lib/typescript/commonjs/ui/screens/EditProfileScreen.d.ts +14 -0
  110. package/lib/typescript/commonjs/ui/screens/EditProfileScreen.d.ts.map +1 -0
  111. package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts +1 -1
  112. package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/ui/screens/OxyAuthScreen.d.ts +13 -12
  114. package/lib/typescript/commonjs/ui/screens/OxyAuthScreen.d.ts.map +1 -1
  115. package/lib/typescript/commonjs/ui/screens/ProfileScreen.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/ui/screens/linkFormat.d.ts +23 -0
  117. package/lib/typescript/commonjs/ui/screens/linkFormat.d.ts.map +1 -0
  118. package/lib/typescript/commonjs/ui/session/authStore.d.ts +33 -0
  119. package/lib/typescript/commonjs/ui/session/authStore.d.ts.map +1 -0
  120. package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts +19 -15
  121. package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts.map +1 -1
  122. package/lib/typescript/commonjs/ui/session/index.d.ts +9 -9
  123. package/lib/typescript/commonjs/ui/session/index.d.ts.map +1 -1
  124. package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts +13 -19
  125. package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts.map +1 -1
  126. package/lib/typescript/commonjs/ui/utils/isWebBrowser.d.ts +9 -0
  127. package/lib/typescript/commonjs/ui/utils/isWebBrowser.d.ts.map +1 -0
  128. package/lib/typescript/commonjs/utils/deviceFlowSignIn.d.ts +15 -13
  129. package/lib/typescript/commonjs/utils/deviceFlowSignIn.d.ts.map +1 -1
  130. package/lib/typescript/module/index.d.ts +0 -1
  131. package/lib/typescript/module/index.d.ts.map +1 -1
  132. package/lib/typescript/module/ui/components/SignInAccountChooser.d.ts +29 -0
  133. package/lib/typescript/module/ui/components/SignInAccountChooser.d.ts.map +1 -0
  134. package/lib/typescript/module/ui/components/SignInModal.d.ts +15 -10
  135. package/lib/typescript/module/ui/components/SignInModal.d.ts.map +1 -1
  136. package/lib/typescript/module/ui/context/OxyContext.d.ts +36 -42
  137. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  138. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts +18 -23
  139. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  140. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +0 -2
  141. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  142. package/lib/typescript/module/ui/hooks/useAuth.d.ts.map +1 -1
  143. package/lib/typescript/module/ui/hooks/usePasswordSignIn.d.ts +31 -0
  144. package/lib/typescript/module/ui/hooks/usePasswordSignIn.d.ts.map +1 -0
  145. package/lib/typescript/module/ui/hooks/useProfileEditing.d.ts +0 -1
  146. package/lib/typescript/module/ui/hooks/useProfileEditing.d.ts.map +1 -1
  147. package/lib/typescript/module/ui/navigation/routes.d.ts +1 -1
  148. package/lib/typescript/module/ui/navigation/routes.d.ts.map +1 -1
  149. package/lib/typescript/module/ui/screens/EditProfileFieldScreen.d.ts.map +1 -1
  150. package/lib/typescript/module/ui/screens/EditProfileScreen.d.ts +14 -0
  151. package/lib/typescript/module/ui/screens/EditProfileScreen.d.ts.map +1 -0
  152. package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts +1 -1
  153. package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  154. package/lib/typescript/module/ui/screens/OxyAuthScreen.d.ts +13 -12
  155. package/lib/typescript/module/ui/screens/OxyAuthScreen.d.ts.map +1 -1
  156. package/lib/typescript/module/ui/screens/ProfileScreen.d.ts.map +1 -1
  157. package/lib/typescript/module/ui/screens/linkFormat.d.ts +23 -0
  158. package/lib/typescript/module/ui/screens/linkFormat.d.ts.map +1 -0
  159. package/lib/typescript/module/ui/session/authStore.d.ts +33 -0
  160. package/lib/typescript/module/ui/session/authStore.d.ts.map +1 -0
  161. package/lib/typescript/module/ui/session/createSessionClient.d.ts +19 -15
  162. package/lib/typescript/module/ui/session/createSessionClient.d.ts.map +1 -1
  163. package/lib/typescript/module/ui/session/index.d.ts +9 -9
  164. package/lib/typescript/module/ui/session/index.d.ts.map +1 -1
  165. package/lib/typescript/module/ui/session/tokenTransport.d.ts +13 -19
  166. package/lib/typescript/module/ui/session/tokenTransport.d.ts.map +1 -1
  167. package/lib/typescript/module/ui/utils/isWebBrowser.d.ts +9 -0
  168. package/lib/typescript/module/ui/utils/isWebBrowser.d.ts.map +1 -0
  169. package/lib/typescript/module/utils/deviceFlowSignIn.d.ts +15 -13
  170. package/lib/typescript/module/utils/deviceFlowSignIn.d.ts.map +1 -1
  171. package/package.json +4 -4
  172. package/src/index.ts +0 -4
  173. package/src/ui/components/SignInAccountChooser.tsx +162 -0
  174. package/src/ui/components/SignInModal.tsx +309 -139
  175. package/src/ui/context/OxyContext.tsx +532 -1785
  176. package/src/ui/context/hooks/useAuthOperations.ts +65 -76
  177. package/src/ui/hooks/useAuth.ts +14 -35
  178. package/src/ui/hooks/usePasswordSignIn.ts +207 -0
  179. package/src/ui/hooks/useProfileEditing.ts +2 -8
  180. package/src/ui/hooks/useSessionManagement.ts +1 -1
  181. package/src/ui/navigation/routes.ts +2 -0
  182. package/src/ui/screens/EditProfileFieldScreen.tsx +102 -66
  183. package/src/ui/screens/EditProfileScreen.tsx +155 -0
  184. package/src/ui/screens/ManageAccountScreen.tsx +3 -7
  185. package/src/ui/screens/OxyAuthScreen.tsx +259 -112
  186. package/src/ui/screens/ProfileScreen.tsx +15 -2
  187. package/src/ui/screens/__tests__/linkFormat.test.ts +73 -0
  188. package/src/ui/screens/linkFormat.ts +37 -0
  189. package/src/ui/session/__tests__/createSessionClient.test.ts +22 -7
  190. package/src/ui/session/__tests__/tokenTransport.test.ts +37 -49
  191. package/src/ui/session/authStore.ts +164 -0
  192. package/src/ui/session/createSessionClient.ts +30 -17
  193. package/src/ui/session/index.ts +10 -9
  194. package/src/ui/session/tokenTransport.ts +26 -36
  195. package/src/ui/utils/isWebBrowser.ts +8 -0
  196. package/src/utils/__tests__/deviceFlowSignIn.test.ts +50 -131
  197. package/src/utils/deviceFlowSignIn.ts +19 -26
  198. package/lib/commonjs/ui/context/inSessionTokenRefresh.js +0 -243
  199. package/lib/commonjs/ui/context/inSessionTokenRefresh.js.map +0 -1
  200. package/lib/commonjs/ui/context/silentSessionRestore.js +0 -56
  201. package/lib/commonjs/ui/context/silentSessionRestore.js.map +0 -1
  202. package/lib/commonjs/ui/hooks/useWebSSO.js +0 -28
  203. package/lib/commonjs/ui/hooks/useWebSSO.js.map +0 -1
  204. package/lib/commonjs/ui/session/projectSessionState.js +0 -86
  205. package/lib/commonjs/ui/session/projectSessionState.js.map +0 -1
  206. package/lib/commonjs/ui/session/sessionClientHost.js +0 -30
  207. package/lib/commonjs/ui/session/sessionClientHost.js.map +0 -1
  208. package/lib/commonjs/ui/utils/activeAuthuser.js +0 -142
  209. package/lib/commonjs/ui/utils/activeAuthuser.js.map +0 -1
  210. package/lib/commonjs/utils/crossApex.js +0 -74
  211. package/lib/commonjs/utils/crossApex.js.map +0 -1
  212. package/lib/module/ui/context/inSessionTokenRefresh.js +0 -238
  213. package/lib/module/ui/context/inSessionTokenRefresh.js.map +0 -1
  214. package/lib/module/ui/context/silentSessionRestore.js +0 -53
  215. package/lib/module/ui/context/silentSessionRestore.js.map +0 -1
  216. package/lib/module/ui/hooks/useWebSSO.js +0 -24
  217. package/lib/module/ui/hooks/useWebSSO.js.map +0 -1
  218. package/lib/module/ui/session/projectSessionState.js +0 -79
  219. package/lib/module/ui/session/projectSessionState.js.map +0 -1
  220. package/lib/module/ui/session/sessionClientHost.js +0 -26
  221. package/lib/module/ui/session/sessionClientHost.js.map +0 -1
  222. package/lib/module/ui/utils/activeAuthuser.js +0 -134
  223. package/lib/module/ui/utils/activeAuthuser.js.map +0 -1
  224. package/lib/module/utils/crossApex.js +0 -69
  225. package/lib/module/utils/crossApex.js.map +0 -1
  226. package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts +0 -106
  227. package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts.map +0 -1
  228. package/lib/typescript/commonjs/ui/context/silentSessionRestore.d.ts +0 -36
  229. package/lib/typescript/commonjs/ui/context/silentSessionRestore.d.ts.map +0 -1
  230. package/lib/typescript/commonjs/ui/hooks/useWebSSO.d.ts +0 -19
  231. package/lib/typescript/commonjs/ui/hooks/useWebSSO.d.ts.map +0 -1
  232. package/lib/typescript/commonjs/ui/session/projectSessionState.d.ts +0 -45
  233. package/lib/typescript/commonjs/ui/session/projectSessionState.d.ts.map +0 -1
  234. package/lib/typescript/commonjs/ui/session/sessionClientHost.d.ts +0 -14
  235. package/lib/typescript/commonjs/ui/session/sessionClientHost.d.ts.map +0 -1
  236. package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts +0 -67
  237. package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts.map +0 -1
  238. package/lib/typescript/commonjs/utils/crossApex.d.ts +0 -55
  239. package/lib/typescript/commonjs/utils/crossApex.d.ts.map +0 -1
  240. package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts +0 -106
  241. package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts.map +0 -1
  242. package/lib/typescript/module/ui/context/silentSessionRestore.d.ts +0 -36
  243. package/lib/typescript/module/ui/context/silentSessionRestore.d.ts.map +0 -1
  244. package/lib/typescript/module/ui/hooks/useWebSSO.d.ts +0 -19
  245. package/lib/typescript/module/ui/hooks/useWebSSO.d.ts.map +0 -1
  246. package/lib/typescript/module/ui/session/projectSessionState.d.ts +0 -45
  247. package/lib/typescript/module/ui/session/projectSessionState.d.ts.map +0 -1
  248. package/lib/typescript/module/ui/session/sessionClientHost.d.ts +0 -14
  249. package/lib/typescript/module/ui/session/sessionClientHost.d.ts.map +0 -1
  250. package/lib/typescript/module/ui/utils/activeAuthuser.d.ts +0 -67
  251. package/lib/typescript/module/ui/utils/activeAuthuser.d.ts.map +0 -1
  252. package/lib/typescript/module/utils/crossApex.d.ts +0 -55
  253. package/lib/typescript/module/utils/crossApex.d.ts.map +0 -1
  254. package/src/ui/context/inSessionTokenRefresh.ts +0 -264
  255. package/src/ui/context/silentSessionRestore.ts +0 -53
  256. package/src/ui/hooks/useWebSSO.ts +0 -23
  257. package/src/ui/session/__tests__/projectSessionState.test.ts +0 -114
  258. package/src/ui/session/__tests__/sessionClientHost.test.ts +0 -43
  259. package/src/ui/session/projectSessionState.ts +0 -85
  260. package/src/ui/session/sessionClientHost.ts +0 -27
  261. package/src/ui/utils/activeAuthuser.ts +0 -142
  262. package/src/utils/crossApex.ts +0 -75
@@ -3,10 +3,9 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.useOxy = exports.default = exports.OxyProvider = exports.OxyContextProvider = void 0;
6
+ exports.useOxy = exports.OxyProvider = exports.OxyContextProvider = void 0;
7
7
  var _react = require("react");
8
8
  var _core = require("@oxyhq/core");
9
- var _bloom = require("@oxyhq/bloom");
10
9
  var _authStore = require("../stores/authStore.js");
11
10
  var _shallow = require("zustand/react/shallow");
12
11
  var _useLanguageManagement = require("../hooks/useLanguageManagement.js");
@@ -14,17 +13,12 @@ var _useSessionManagement = require("../hooks/useSessionManagement.js");
14
13
  var _useAuthOperations = require("./hooks/useAuthOperations.js");
15
14
  var _useDeviceManagement = require("../hooks/useDeviceManagement.js");
16
15
  var _storageHelpers = require("../utils/storageHelpers.js");
17
- var _errorHandlers = require("../utils/errorHandlers.js");
18
- var _activeAuthuser = require("../utils/activeAuthuser.js");
19
16
  var _bottomSheetManager = require("../navigation/bottomSheetManager.js");
20
17
  var _reactQuery = require("@tanstack/react-query");
21
18
  var _queryClient = require("../hooks/queryClient.js");
22
19
  var _useAvatarPicker = require("../hooks/useAvatarPicker.js");
23
20
  var _accountStore = require("../stores/accountStore.js");
24
- var _useWebSSO = require("../hooks/useWebSSO.js");
25
- var _crossApex = require("../../utils/crossApex.js");
26
- var _inSessionTokenRefresh = require("./inSessionTokenRefresh.js");
27
- var _silentSessionRestore = require("./silentSessionRestore.js");
21
+ var _isWebBrowser = require("../utils/isWebBrowser.js");
28
22
  var _index = require("../session/index.js");
29
23
  var _jsxRuntime = require("react/jsx-runtime");
30
24
  const OxyContext = /*#__PURE__*/(0, _react.createContext)(null);
@@ -32,124 +26,38 @@ const OxyContext = /*#__PURE__*/(0, _react.createContext)(null);
32
26
  /**
33
27
  * Result of {@link OxyContextState.signInWithPassword}.
34
28
  *
35
- * `'ok'` — the password was accepted and the resulting session has been
36
- * committed into context state (the SAME path SSO / silent-restore sessions
37
- * use), so `isAuthenticated` / `user` are updated and the session is durably persisted;
38
- * the caller can proceed (e.g. navigate into the app).
29
+ * `'ok'` — the password was accepted and the session committed (so
30
+ * `isAuthenticated` / `user` are updated and the refresh family persisted).
39
31
  *
40
32
  * `'2fa_required'` — the account has two-factor auth enabled, so NO session was
41
- * created. The caller must complete the challenge with the returned short-lived
42
- * `loginToken` (`POST /security/2fa/verify-login`) before a session exists.
33
+ * created. Complete the challenge with the returned short-lived `loginToken`
34
+ * via {@link OxyContextState.completeTwoFactorSignIn}.
43
35
  */
44
36
 
45
- // Active-authuser persistence helpers (web localStorage; native no-op) live in
46
- // `../utils/activeAuthuser` so the session-management and auth-operations hooks
47
- // can share them without re-importing this 1k-line context file.
48
-
49
- /**
50
- * Per-step fail-fast budget for the cold-boot silent iframe (`silentSignIn`
51
- * against the per-apex `/auth/silent` host).
52
- *
53
- * This step ONLY succeeds when a durable per-apex `fedcm_session` cookie exists
54
- * (established by a prior `/sso` bounce). On the common reload of a logged-out
55
- * tab — or a tab that restores via the now-earlier stored-session step — the
56
- * iframe never posts a message, so the full wait would be dead latency in front
57
- * of the terminal `/sso` bounce. `silentSignIn` already fails fast on a load
58
- * error via `iframe.onerror`; this caps the no-message case. 2.5s is well above
59
- * a same-origin iframe handshake without blocking cold boot for several seconds.
60
- */
61
- const SILENT_IFRAME_TIMEOUT = 2500;
62
-
63
37
  /**
64
- * Per-step fail-fast budget (ms) for the native shared-key cold-boot step
65
- * (`signInWithSharedIdentity`).
66
- *
67
- * That step does a challenge round-trip plus a verify against the shared
68
- * cross-app identity key; if the device holds no shared identity it returns
69
- * `null` quickly, but a slow/offline network must not let it block the cold
70
- * boot. 8s mirrors the stored-session bearer-validation budget
71
- * (`VALIDATION_TIMEOUT`) since both perform comparable bounded network work; on
72
- * expiry the step resolves to `null` and cold boot falls through (native then
73
- * reaches the unauthenticated backstop, web never runs this step at all).
74
- */
75
- const SHARED_KEY_SIGNIN_TIMEOUT = 8000;
76
-
77
- /**
78
- * HARD overall deadline (ms) for the entire cold-boot step loop —
79
- * defense-in-depth so a single non-settling step can NEVER hang auth resolution
80
- * forever (the production regression this guards against: a browser silent
81
- * credential/iframe probe that ignored its own abort signal left a step's
82
- * promise unsettled, so `runColdBoot` never advanced to the terminal `/sso`
83
- * bounce and auth hung indefinitely).
84
- *
85
- * Every step ALREADY bounds its own network work (the stored-session bearer
86
- * validation at 8s, the silent iframe at `SILENT_IFRAME_TIMEOUT` plus its hard
87
- * settle). On a healthy load the FIRST recovering step wins in a
88
- * single round-trip (1–3s) and the chain short-circuits long before this fires.
89
- * This budget only trips when one of those per-step bounds regresses.
90
- *
91
- * 20s is the chosen value: comfortably ABOVE the worst-case bounded
92
- * stored-session path under transient slowness (the 8s parallel validation
93
- * window plus a `switchSession` round-trip) so a genuinely slow-but-healthy
94
- * reload is never cut off, yet well BELOW the ~28–30s the previous
95
- * probe-first ordering took — and, critically, finite, so the user can never
96
- * sit on an indefinite spinner. When the deadline trips, `runColdBoot` keeps
97
- * iterating to the terminal `sso-bounce` step (whose navigation side effect
98
- * runs synchronously), so a genuine no-local-session first visit STILL reaches
99
- * the cross-domain `/sso` fallback. Native runs only the stored-session step,
100
- * which is bounded well under this, so the deadline never alters native flow.
38
+ * Fallback client-session validity window (ms) 7 days applied when a
39
+ * committed session does not carry an explicit `expiresAt`. This is only a
40
+ * local display hint for the multi-session store; the server remains the source
41
+ * of truth for actual session expiry.
101
42
  */
102
- const COLD_BOOT_OVERALL_DEADLINE = 20000;
43
+ const DEFAULT_SESSION_VALIDITY_MS = 7 * 24 * 60 * 60 * 1000;
103
44
 
104
45
  /**
105
- * How long cold boot WAITS for the post-ladder SessionClient handoff
106
- * (`addCurrentAccount` + `start` + `syncFromClient`) before it resolves auth
107
- * and stops blocking. Once a ladder step planted a token the user is already
46
+ * How long the cold boot WAITS for the post-boot SessionClient handoff
47
+ * (`addCurrentAccount` + `start` + `syncFromClient`) before it resolves auth and
48
+ * stops blocking. Once a boot step planted a token the user is already
108
49
  * authenticated — the handoff only populates the multi-account set and the
109
50
  * server-authoritative active account, and those also arrive via the socket
110
- * subscription wired in `useEffect`. So on a slow/unresponsive backend we stop
111
- * AWAITING the handoff here (it keeps running in the background and projects
112
- * when it lands) rather than delaying `markAuthResolved` — otherwise the two
113
- * sequential `HttpService`-bounded REST calls could push auth resolution up to
114
- * ~10s past the ladder's own budget, reintroducing the exact spinner-stall
115
- * `COLD_BOOT_OVERALL_DEADLINE` exists to prevent. On the normal fast path the
116
- * handoff completes well within this, so the correct account shows with no
117
- * flash.
51
+ * subscription. So on a slow backend we stop AWAITING the handoff here (it keeps
52
+ * running in the background and projects when it lands) rather than delaying
53
+ * auth resolution.
118
54
  */
119
55
  const SESSION_HANDOFF_DEADLINE = 6000;
120
56
 
121
- /**
122
- * Per-session soft timeout (ms) for the parallel stored-session validation in
123
- * `restoreStoredSession`. Each `validateSession` call races against this timer
124
- * so a single slow/offline session never blocks the whole startup validation
125
- * sweep. Sessions that don't answer in time are treated as unvalidated and kept
126
- * in the persisted ID list; only explicit invalid-session responses are pruned.
127
- */
128
- const VALIDATION_TIMEOUT = 8000;
129
- const VALIDATION_TIMEOUT_RESULT = 'validation-timeout';
130
- /**
131
- * Fallback client-session validity window (ms) — 7 days — applied when a
132
- * restored account/session does not carry an explicit `expiresAt`. This is only
133
- * a local display/bookkeeping hint for the multi-session store; the server
134
- * remains the source of truth for actual session expiry. Used in the refresh
135
- * cookie restore, stored-session restore, and web-SSO session paths.
136
- */
137
- const DEFAULT_SESSION_VALIDITY_MS = 7 * 24 * 60 * 60 * 1000;
138
-
139
- /**
140
- * Minimum interval (ms) between visibility-driven IdP `/auth/session-check`
141
- * probes. Debounces the hidden-iframe session check so rapid tab focus/blur
142
- * cycles can't spawn a check-iframe storm; at most one probe runs per window.
143
- */
144
- const IDP_SESSION_CHECK_COOLDOWN = 30000;
57
+ /** The internal commit input — a session plus the device-first extras that are
58
+ * not on the public `SessionLoginResponse` (a rotating refresh token, a device
59
+ * token). All extras optional so `SessionLoginResponse` is assignable. */
145
60
 
146
- /**
147
- * Hard timeout (ms) for a single visibility-driven IdP `/auth/session-check`
148
- * iframe. If the IdP never posts a `oxy-session-check` message back, the iframe
149
- * and its listener are torn down after this budget so a non-responsive check
150
- * can never leak an iframe or a `message` listener.
151
- */
152
- const IDP_SESSION_CHECK_TIMEOUT = 5000;
153
61
  function getHttpStatus(error) {
154
62
  if (!error || typeof error !== 'object') {
155
63
  return undefined;
@@ -174,51 +82,6 @@ function getHttpStatus(error) {
174
82
  function isUnauthorizedStatus(error) {
175
83
  return getHttpStatus(error) === 401;
176
84
  }
177
-
178
- /**
179
- * Whether `idpOrigin` is a same-site, first-party host of the current page —
180
- * i.e. it shares the page's registrable apex (last two labels), so a "no
181
- * session" answer from its `/auth/session-check` iframe is authoritative for
182
- * THIS app and may force a local sign-out.
183
- *
184
- * On a cross-site IdP (or any host whose relationship to the page can't be
185
- * positively established) this returns `false`, so the visibility-driven check
186
- * may surface a session-ended toast but MUST NOT clear local state — a
187
- * third-party / undetermined IdP answer can never force logout. Returns `false`
188
- * off-browser.
189
- */
190
- function isSameSiteIdP(idpOrigin) {
191
- // Native defines a global `window` but no `window.location`; guard the
192
- // latter so reading `.hostname` can never throw off-browser. (Only reachable
193
- // from the web-only visibility check, but kept robust for parity.)
194
- if (typeof window === 'undefined' || typeof window.location === 'undefined') {
195
- return false;
196
- }
197
- let idpHostname;
198
- try {
199
- idpHostname = new URL(idpOrigin).hostname;
200
- } catch (parseError) {
201
- if (__DEV__) {
202
- _core.logger.debug('Invalid IdP origin while checking same-site session status', {
203
- component: 'OxyContext'
204
- }, parseError);
205
- }
206
- return false;
207
- }
208
- const pageHostname = window.location.hostname;
209
- if (!idpHostname || !pageHostname) return false;
210
- if (idpHostname === pageHostname) return true;
211
- const pageApex = (0, _core.registrableApex)(pageHostname);
212
- const idpApex = (0, _core.registrableApex)(idpHostname);
213
- // Require a real registrable apex (not a shared/public suffix) AND an exact
214
- // apex match AND that the IdP host is the page apex itself or a subdomain of it.
215
- if (!pageApex || idpApex !== pageApex) return false;
216
- return idpHostname === pageApex || idpHostname.endsWith(`.${pageApex}`);
217
- }
218
- function isOnSsoCallbackPath() {
219
- return (0, _useWebSSO.isWebBrowser)() && window.location.pathname === _core.SSO_CALLBACK_PATH;
220
- }
221
- const useBrowserLayoutEffect = typeof document !== 'undefined' ? _react.useLayoutEffect : _react.useEffect;
222
85
  let cachedUseFollowHook = null;
223
86
  const loadUseFollowHook = () => {
224
87
  if (cachedUseFollowHook) {
@@ -261,19 +124,11 @@ const OxyProvider = ({
261
124
  if (providedOxyServices) {
262
125
  oxyServicesRef.current = providedOxyServices;
263
126
  } else if (baseURL) {
264
- // Target the CENTRAL IdP for TRUE cross-domain SSO. Every RP
265
- // (mention.earth, homiio.com, alia.onl, …) delegates to the one central
266
- // `auth.oxy.so` — it owns the host-only `fedcm_session` cookie and the
267
- // central session store reached via `api.oxy.so`, so a single sign-in
268
- // there is observed by all RPs through the opaque-code `/sso` bounce.
269
- // `resolveCentralAuthUrl(authWebUrl)` returns the explicit `authWebUrl`
270
- // prop when provided (explicit always wins) and the central default
271
- // otherwise. This is NOT per-apex auto-detection — central SSO is
272
- // deliberately central. A consumer-provided `OxyServices` instance is
273
- // never mutated; only the baseURL-only construction path applies this.
127
+ // `authWebUrl` now only points the OAuth "Sign in with Oxy" third-party
128
+ // link builder at the central auth host; there is no cold-boot redirect.
274
129
  oxyServicesRef.current = new _core.OxyServices({
275
130
  baseURL,
276
- authWebUrl: (0, _core.resolveCentralAuthUrl)(authWebUrl),
131
+ authWebUrl,
277
132
  authRedirectUri
278
133
  });
279
134
  } else {
@@ -281,6 +136,15 @@ const OxyProvider = ({
281
136
  }
282
137
  }
283
138
  const oxyServices = oxyServicesRef.current;
139
+
140
+ // The device-first persisted auth-state store (per-origin refresh family on
141
+ // web; SecureStore session blob + shared-keychain device token on native).
142
+ // Built ONCE per provider mount.
143
+ const authStoreRef = (0, _react.useRef)(null);
144
+ if (!authStoreRef.current) {
145
+ authStoreRef.current = (0, _index.createPlatformAuthStateStore)();
146
+ }
147
+ const authStore = authStoreRef.current;
284
148
  const {
285
149
  user,
286
150
  isAuthenticated,
@@ -300,13 +164,6 @@ const OxyProvider = ({
300
164
  })));
301
165
  const [tokenReady, setTokenReady] = (0, _react.useState)(true);
302
166
  const [hasAccessToken, setHasAccessToken] = (0, _react.useState)(() => Boolean(oxyServices.getAccessToken()));
303
- // Whether the FIRST cold-boot auth restore has concluded. Starts `false`
304
- // (auth undetermined) and flips to `true` exactly once — monotonic, never
305
- // reverts. It now flips the MOMENT a session commits (the common reload case
306
- // unblocks immediately, without waiting for the rest of the cold-boot chain),
307
- // with the restore `finally` as the no-session/error backstop. The ref makes
308
- // the flip idempotent across both sites so the setters fire at most once. See
309
- // `isAuthResolved` on the context type for the consumer contract.
310
167
  const [authResolved, setAuthResolved] = (0, _react.useState)(false);
311
168
  const authResolvedRef = (0, _react.useRef)(false);
312
169
  const userRef = (0, _react.useRef)(user);
@@ -314,24 +171,12 @@ const OxyProvider = ({
314
171
  userRef.current = user;
315
172
  isAuthenticatedRef.current = isAuthenticated;
316
173
  const [initialized, setInitialized] = (0, _react.useState)(false);
317
- const [ssoCallbackIntercepting, setSsoCallbackIntercepting] = (0, _react.useState)(false);
318
174
  const setAuthState = _authStore.useAuthStore.setState;
319
175
 
320
176
  // Keep the shared `oxyClient` singleton's token store in lockstep with the
321
- // session owned by THIS provider's instance. Many apps construct their api
322
- // clients against the exported `oxyClient` singleton (reading
323
- // `oxyClient.getAccessToken()` to build Authorization headers) while passing
324
- // only `baseURL` to OxyProvider — in which case the provider owns a DIFFERENT
325
- // `OxyServices` instance and the singleton would otherwise never receive the
326
- // token, producing `Authorization: Bearer null`.
327
- //
328
- // Subscribing to `onTokensChanged` mirrors EVERY token mutation — sign-in,
329
- // session restore, switch, silent refresh, and sign-out/clear — onto the
330
- // singleton at the single source of truth in @oxyhq/core, with no per-app
331
- // plumbing and regardless of which auth code path fired.
332
- //
333
- // When the app passed the singleton itself as `oxyServices` (Mention's
334
- // pattern), `oxyServices === oxyClient`, so we skip the redundant self-write.
177
+ // session owned by THIS provider's instance (many apps build API clients
178
+ // against the exported singleton while passing only `baseURL` here). Skipped
179
+ // when the app passed the singleton itself as `oxyServices`.
335
180
  (0, _react.useEffect)(() => {
336
181
  if (oxyServices === _core.oxyClient) {
337
182
  return;
@@ -343,10 +188,6 @@ const OxyProvider = ({
343
188
  _core.oxyClient.clearTokens();
344
189
  }
345
190
  };
346
-
347
- // Seed the singleton with whatever token the instance already holds (it may
348
- // have been planted synchronously before this effect ran — e.g. a token set
349
- // during the same tick as mount), then keep it in sync going forward.
350
191
  applyToSingleton(oxyServices.getAccessToken());
351
192
  return oxyServices.onTokensChanged(applyToSingleton);
352
193
  }, [oxyServices]);
@@ -358,39 +199,16 @@ const OxyProvider = ({
358
199
  }
359
200
  }, []);
360
201
  const storageKeys = (0, _react.useMemo)(() => (0, _storageHelpers.getStorageKeys)(storageKeyPrefix), [storageKeyPrefix]);
361
-
362
- // The app's Oxy OAuth client id surfaced on the context so the cross-app
363
- // device sign-in components (SignInModal / OxyAuthScreen) can identify the
364
- // requesting app to `POST /auth/session/create`. Normalized to a trimmed
365
- // non-empty string, or `null` when the consumer did not configure one — the
366
- // sign-in components surface a clear configuration error in that case rather
367
- // than falling back to any display string.
368
202
  const clientId = (0, _react.useMemo)(() => {
369
203
  const trimmed = clientIdProp?.trim();
370
204
  return trimmed ? trimmed : null;
371
205
  }, [clientIdProp]);
372
206
 
373
- // Storage initialization.
374
- //
375
- // `storage` (state) drives render-time gating (`isStorageReady`) and the
376
- // hooks below. But it is `null` for a brief window after mount while
377
- // `createPlatformStorage()` resolves (a microtask on web; a dynamic
378
- // `import()` on native). Any persistence path that fires during that window
379
- // — e.g. an interactive password sign-in the instant the screen mounts —
380
- // would read `storage === null` and SILENTLY skip writing the session,
381
- // leaving the user signed-in in-memory but with nothing to restore on
382
- // reload.
383
- //
384
- // To make persistence robust regardless of timing we ALSO expose the storage
385
- // as an awaitable promise (`getReadyStorage`). Persistence code awaits the
386
- // ready instance instead of branching on the possibly-null state, so a write
387
- // is never silently dropped just because it raced storage init.
207
+ // Local display/bookkeeping storage (session id list, language). Distinct
208
+ // from `authStore` (the durable credential blob). Exposed as an awaitable so
209
+ // a persistence write is never dropped because it raced storage init.
388
210
  const storageRef = (0, _react.useRef)(null);
389
211
  const [storage, setStorage] = (0, _react.useState)(null);
390
-
391
- // A single, stable deferred that resolves with the initialized storage. Built
392
- // lazily via a ref initializer so the resolver is captured exactly once and
393
- // the promise identity is stable across renders.
394
212
  const buildStorageDeferred = () => {
395
213
  let resolve = () => undefined;
396
214
  const promise = new Promise(res => {
@@ -406,40 +224,9 @@ const OxyProvider = ({
406
224
  storageReadyRef.current = buildStorageDeferred();
407
225
  }
408
226
  const storageReady = storageReadyRef.current;
409
-
410
- // Resolve the storage instance that is guaranteed to be ready. Returns the
411
- // already-initialized instance synchronously when available, otherwise awaits
412
- // the init promise. Never resolves to `null`.
413
- const getReadyStorage = (0, _react.useCallback)(() => {
414
- if (storageRef.current) {
415
- return Promise.resolve(storageRef.current);
416
- }
417
- return storageReady.promise;
418
- }, [storageReady]);
419
-
420
- // Clear the durable "had a signed-in Oxy session before" hint.
421
- //
422
- // The hint (`storageKeys.priorSession`) is WRITTEN whenever a session is
423
- // established/restored — every commit funnels through `persistSessionDurably`,
424
- // and the stored-session reload winner sets it directly to backfill
425
- // pre-existing installs. It lives in the SAME `storageKeyPrefix`-scoped
426
- // durable store as the session ids, so it SURVIVES a session expiring; it is
427
- // cleared ONLY here, on EXPLICIT full sign-out (wired into `clearAllAccountData`
428
- // and the `useAuthOperations` logout paths — never the passive token-expiry
429
- // path). At cold boot the hint is read into `hadPriorSession` and feeds
430
- // `allowSsoBounce`: a RETURNING visitor (hint present) whose local session has
431
- // lapsed still gets ONE terminal `/sso` establish bounce so a central-only
432
- // cross-domain session recovers, while a truly first-time anonymous visitor is
433
- // never force-redirected.
434
- const clearPriorSessionHint = (0, _react.useCallback)(async () => {
435
- const readyStorage = await getReadyStorage();
436
- await readyStorage.removeItem(storageKeys.priorSession);
437
- }, [getReadyStorage, storageKeys.priorSession]);
438
227
  (0, _react.useEffect)(() => {
439
228
  let mounted = true;
440
229
  (0, _storageHelpers.createPlatformStorage)().then(storageInstance => {
441
- // Resolve the ready-promise even if the component unmounted: in-flight
442
- // persistence awaiting it must still complete against a real store.
443
230
  storageRef.current = storageInstance;
444
231
  storageReady.resolve(storageInstance);
445
232
  if (mounted) {
@@ -459,10 +246,6 @@ const OxyProvider = ({
459
246
  mounted = false;
460
247
  };
461
248
  }, [logger, onError, storageReady]);
462
-
463
- // Offline queuing is now handled by TanStack Query mutations
464
- // No need for custom offline queue
465
-
466
249
  const {
467
250
  currentLanguage,
468
251
  metadata: currentLanguageMetadata,
@@ -502,117 +285,72 @@ const OxyProvider = ({
502
285
  queryClient
503
286
  });
504
287
 
505
- // Session-sync integration layer (Fase 3-A -> 3-B). Built ONCE per
506
- // `oxyServices` instance via a lazy ref (mirrors the `oxyServicesRef`
507
- // pattern above) so the underlying `SessionClient` — and its socket
508
- // connection, once started — is never recreated across renders.
509
- //
510
- // ADDITIVE + INERT for this task: `client.start()` is NOT called here (that
511
- // is Task 2's job, which also retires the 8-step cold boot below). Until
512
- // then `client.getState()` never advances past `null`, so `syncFromClient`
513
- // is a guaranteed no-op in production — the existing cold-boot-driven state
514
- // remains the sole authority. The wiring exists so the projection can be
515
- // exercised in isolation against a controlled client before the authority
516
- // flip.
288
+ // Refs so callbacks below can invoke the latest session-management functions
289
+ // without widening dependency arrays.
290
+ const switchSessionRef = (0, _react.useRef)(switchSession);
291
+ switchSessionRef.current = switchSession;
292
+ const clearSessionStateRef = (0, _react.useRef)(clearSessionState);
293
+ clearSessionStateRef.current = clearSessionState;
294
+ const setActiveSessionIdRef = (0, _react.useRef)(setActiveSessionId);
295
+ setActiveSessionIdRef.current = setActiveSessionId;
296
+ const loginSuccessRef = (0, _react.useRef)(loginSuccess);
297
+ loginSuccessRef.current = loginSuccess;
298
+ const onAuthStateChangeRef = (0, _react.useRef)(onAuthStateChange);
299
+ onAuthStateChangeRef.current = onAuthStateChange;
300
+
301
+ // Flip the auth-resolution gate (`authResolved` + `tokenReady`) the moment a
302
+ // session commits or the boot concludes signed out. Idempotent + monotonic.
303
+ const markAuthResolved = (0, _react.useCallback)(() => {
304
+ if (authResolvedRef.current) {
305
+ return;
306
+ }
307
+ authResolvedRef.current = true;
308
+ setTokenReady(true);
309
+ setAuthResolved(true);
310
+ }, []);
311
+ const markAuthResolvedRef = (0, _react.useRef)(markAuthResolved);
312
+ markAuthResolvedRef.current = markAuthResolved;
313
+
314
+ // Server-authoritative device session client. Built ONCE per `oxyServices`
315
+ // instance. `onUnauthenticated` (a device signout-all pushed over the socket,
316
+ // or bootstrapped as zero-account) clears the persisted store + local state so
317
+ // a reload does not try to restore a session the device no longer has.
517
318
  const sessionClientPairRef = (0, _react.useRef)(null);
518
319
  if (!sessionClientPairRef.current) {
519
- sessionClientPairRef.current = (0, _index.createSessionClient)(oxyServices);
320
+ sessionClientPairRef.current = (0, _index.createSessionClient)(oxyServices, authStore, () => {
321
+ (0, _useAuthOperations.clearPersistedAuthSafe)(authStore, logger);
322
+ void clearSessionStateRef.current().catch(clearError => {
323
+ logger('Failed to clear local state on remote sign-out', clearError);
324
+ });
325
+ });
520
326
  }
521
327
  const {
522
328
  client: sessionClient,
523
329
  host: sessionClientHost
524
330
  } = sessionClientPairRef.current;
525
331
 
526
- // Cold-boot registration dedup (Task 5). `handleWebSSOSession` (declared
527
- // below) is the single commit funnel for every web ladder step that mints a
528
- // session (`sso-return`, `shared-key-signin`, `silent-iframe`) it registers
529
- // the recovered account into the device set
530
- // itself (`sessionClient.addCurrentAccount()`). The cold-boot post-ladder
531
- // handoff ALSO registers, but only as a FALLBACK for the `stored-session`
532
- // step, which commits through a completely different path
533
- // (`switchSessionRef`) and never touches `sessionClient` on its own. This
534
- // ref lets the handoff detect "did a ladder step already register this
535
- // boot?" and skip a redundant second `POST /session/device/add`. Reset at
536
- // the start of every cold-boot pass.
537
- const registeredDuringBootRef = (0, _react.useRef)(false);
538
-
539
- // Projects `client.getState()` onto the exposed `sessions` / `activeSessionId`
540
- // / `user` via the SAME setters the existing cold-boot/session-management
541
- // paths use. This is the SOLE authority for both a locally-initiated mutation
542
- // (switch/logout resolving here) AND a REMOTELY-pushed `session_state` (the
543
- // `device:<deviceId>` socket owned by `client.start()`, subscribed to below) —
544
- // there is no per-domain `useSessionSocket` anymore.
545
- //
546
- // GAP FIX (Task 4): when the pushed/bootstrapped state reports ZERO device
547
- // accounts, this device has been fully signed out — either by the LOCAL
548
- // `logout()`/`logoutAll()` mutation (which also runs its own explicit
549
- // cleanup) or, just as importantly, by a REMOTE actor (another tab/device
550
- // removing the last session, or an admin revoking every device account)
551
- // pushing that same empty state over the socket. The prior per-domain
552
- // `useSessionSocket` forced a local sign-out in this situation
553
- // (`triggerLocalSignOut`); without an equivalent here a remote full sign-out
554
- // would leave `user`/`isAuthenticated`/the bearer token stale until the next
555
- // 401. Route through the SAME `clearSessionState()` a local full sign-out
556
- // uses (authStore reset, token clear, query-cache clear, storage clear) —
557
- // idempotent with the local-logout caller's own explicit call.
558
- //
559
- // DELIBERATE-SIGN-OUT GUARD (Task 5): a zero-account state — however it
560
- // arrived — is just as terminal as a local `logout()`/`logoutAll()`, so it
561
- // sets the SAME durable "deliberately signed out" flag (web-only,
562
- // best-effort) that those two callers set directly. Without this, a remote
563
- // full wipe would leave the flag unset, and the `silent-iframe` cold-boot
564
- // step could silently re-mint a session from a still-live per-apex
565
- // `fedcm_session` cookie right after this device was authoritatively signed
566
- // out everywhere.
567
- //
568
- // REMOTE-SIGN-OUT SUPPRESSION FIX (review H1): the terminal `sso-bounce`
569
- // cold-boot step is gated on the durable prior-session hint
570
- // (`allowSsoBounce` = `hasPriorSession || hasLocalSession`), NOT on the
571
- // deliberately-signed-out flag set above. Leaving that hint in place after a
572
- // REMOTE full sign-out means the next reload still performs one terminal
573
- // `/sso` establish bounce, which can silently re-mint a session from a
574
- // still-live central `fedcm_session` — signing the user back in right after
575
- // they were signed out everywhere. Clear the SAME cleanup set the LOCAL
576
- // `logout()`/`logoutAll()` paths use (`useAuthOperations.ts`) — SSO bounce
577
- // state + the prior-session hint — so a remote sign-out is indistinguishable
578
- // from a local one to the next cold boot.
332
+ // Projects `client.getState()` onto the exposed `sessions`/`activeSessionId`/
333
+ // `user`. Sole authority for both locally-initiated mutations (switch/logout)
334
+ // AND remotely-pushed `session_state` over the `device:<deviceId>` socket.
579
335
  const syncFromClient = (0, _react.useCallback)(async () => {
580
336
  const state = sessionClient.getState();
581
337
  if (state === null) {
582
- // INERT: no session state has been bootstrapped (client.start() has not
583
- // run). Never overwrite the existing cold-boot-driven state.
584
338
  return;
585
339
  }
586
340
  if (state.accounts.length === 0) {
587
341
  sessionClientHost.setCurrentAccountId(null);
588
- if ((0, _useWebSSO.isWebBrowser)()) {
589
- (0, _activeAuthuser.markSignedOut)();
590
- }
591
- (0, _activeAuthuser.clearSsoBounceState)();
592
- (0, _useAuthOperations.clearPriorSessionHintSafe)(clearPriorSessionHint, logger);
342
+ (0, _useAuthOperations.clearPersistedAuthSafe)(authStore, logger);
593
343
  await clearSessionState();
594
344
  return;
595
345
  }
596
- // LAST-WRITE-WINS GUARD (review I3): `syncFromClient` is called
597
- // concurrently once per socket `notify()` push AND once per direct
598
- // mutation call — and each invocation captures `state` before an async
599
- // profile fetch (`getUsersByIds`). `SessionClient`'s own state is
600
- // monotonic by revision, but this projection previously was not: a
601
- // SLOWER, OLDER fetch resolving AFTER a newer one would still apply its
602
- // now-stale captured `state`, clobbering `user`/`sessions`/
603
- // `activeSessionId` back to an outdated account. Capture the revision
604
- // this fetch is FOR, then after the await, re-read the client's current
605
- // state and bail if it has since moved past the captured revision — a
606
- // fresher call has already applied (or will apply) the current truth.
346
+ // Last-write-wins guard: capture the revision this projection is for, then
347
+ // after the async profile fetch bail if a fresher state has landed.
607
348
  const capturedRevision = state.revision;
608
349
  const ids = (0, _index.accountIdsOf)(state);
609
350
  let users = [];
610
351
  try {
611
352
  users = ids.length > 0 ? await oxyServices.getUsersByIds(ids) : [];
612
353
  } catch (fetchError) {
613
- // Invoked fire-and-forget from the socket `notify()` subscription, so a
614
- // rejected profile fetch would surface as an unhandled rejection. Log and
615
- // bail; the next `notify()`/mutation will re-project the current truth.
616
354
  logger('Failed to resolve account profiles during syncFromClient', fetchError);
617
355
  return;
618
356
  }
@@ -628,7 +366,9 @@ const OxyProvider = ({
628
366
  loginSuccess(activeUser);
629
367
  }
630
368
  sessionClientHost.setCurrentAccountId(latest.activeAccountId);
631
- }, [oxyServices, sessionClient, sessionClientHost, updateSessions, setActiveSessionId, loginSuccess, clearSessionState, clearPriorSessionHint, logger]);
369
+ }, [oxyServices, sessionClient, sessionClientHost, authStore, updateSessions, setActiveSessionId, loginSuccess, clearSessionState, logger]);
370
+ const syncFromClientRef = (0, _react.useRef)(syncFromClient);
371
+ syncFromClientRef.current = syncFromClient;
632
372
  (0, _react.useEffect)(() => {
633
373
  return sessionClient.subscribe(() => {
634
374
  void syncFromClient();
@@ -640,13 +380,13 @@ const OxyProvider = ({
640
380
  logoutAll
641
381
  } = (0, _useAuthOperations.useAuthOperations)({
642
382
  oxyServices,
383
+ store: authStore,
643
384
  storage,
644
385
  activeSessionId,
645
386
  setActiveSessionId,
646
387
  updateSessions,
647
388
  saveActiveSessionId,
648
389
  clearSessionState,
649
- clearPriorSessionHint,
650
390
  switchSession,
651
391
  sessionClient,
652
392
  syncFromClient,
@@ -659,13 +399,8 @@ const OxyProvider = ({
659
399
  setAuthState,
660
400
  logger
661
401
  });
662
-
663
- // Clear all account data (sessions, cache, etc.)
664
402
  const clearAllAccountData = (0, _react.useCallback)(async () => {
665
- // Clear TanStack Query cache (in-memory)
666
403
  queryClient.clear();
667
-
668
- // Clear persisted query cache
669
404
  if (storage) {
670
405
  try {
671
406
  await (0, _queryClient.clearQueryCache)(storage);
@@ -673,22 +408,13 @@ const OxyProvider = ({
673
408
  logger('Failed to clear persisted query cache', error);
674
409
  }
675
410
  }
676
-
677
- // Clear session state (sessions, activeSessionId, storage)
678
411
  await clearSessionState();
679
-
680
- // Explicit FULL sign-out: drop the durable returning-user hint so the next
681
- // cold boot treats this device as a first-time anonymous visitor (no forced
682
- // `/sso` bounce). NOT done on the passive token-expiry path, so an expired
683
- // session still recovers via a returning-user bounce.
684
- await clearPriorSessionHint();
685
-
686
- // Reset account store
412
+ // Explicit FULL wipe: also drop the persisted refresh family so a reload
413
+ // finds nothing to restore.
414
+ await authStore.clear();
687
415
  _accountStore.useAccountStore.getState().reset();
688
-
689
- // Clear HTTP service cache
690
416
  oxyServices.clearCache();
691
- }, [queryClient, storage, clearSessionState, clearPriorSessionHint, logger, oxyServices]);
417
+ }, [queryClient, storage, clearSessionState, authStore, logger, oxyServices]);
692
418
  const {
693
419
  getDeviceSessions,
694
420
  logoutAllDeviceSessions,
@@ -702,13 +428,12 @@ const OxyProvider = ({
702
428
  });
703
429
  const useFollowHook = loadUseFollowHook();
704
430
 
705
- // Refs for mutable callbacks to avoid stale closures in restoreSessionsFromStorage (#187)
706
- const switchSessionRef = (0, _react.useRef)(switchSession);
707
- switchSessionRef.current = switchSession;
708
- const updateSessionsRef = (0, _react.useRef)(updateSessions);
709
- updateSessionsRef.current = updateSessions;
710
- const clearSessionStateRef = (0, _react.useRef)(clearSessionState);
711
- clearSessionStateRef.current = clearSessionState;
431
+ // Token-change side effects: an invalidated bearer (HttpService clears tokens
432
+ // on an unrecoverable 401 and emits `null`) must locally sign out an
433
+ // authenticated user so `isAuthenticated` never lingers true with no token.
434
+ // The persisted store is NOT cleared here — the refresh handler already
435
+ // cleared it if the family was revoked; a transient null leaves it intact so a
436
+ // later reload can still restore.
712
437
  const clearingInvalidTokenRef = (0, _react.useRef)(false);
713
438
  (0, _react.useEffect)(() => {
714
439
  const handleTokenChange = accessToken => {
@@ -741,1124 +466,279 @@ const OxyProvider = ({
741
466
  return oxyServices.onTokensChanged(handleTokenChange);
742
467
  }, [logger, oxyServices]);
743
468
 
744
- // In-session access-token refresh (SDK-owned; every Expo RP inherits it).
745
- //
746
- // The services path never installed an `authRefreshHandler`, so the owner
747
- // `HttpService.refreshAccessToken` short-circuited to null and a 15-minute
748
- // access token expired with the app open while `isAuthenticated` stayed true —
749
- // a zombie logged-in state whose cross-apex feed calls 401-looped. Here we (1)
750
- // install a handler that re-mints a fresh token WITHOUT a reload using the SAME
751
- // durable silent-restore arms cold boot uses, and (2) start a proactive
752
- // scheduler that refreshes ~60s before expiry (and on tab-focus / app-
753
- // foreground) so the common case never even hits the reactive 401 path. The
754
- // linked client (`createLinkedClient`) delegates its refresh back to this owner
755
- // handler, so it is fixed for free.
756
- //
757
- // Runs once per `oxyServices` instance (stable, ref-constructed), and BEFORE
758
- // any cold-boot request can 401: cold boot is gated on async storage init, so
759
- // this synchronous mount effect installs the handler first. On cleanup the
760
- // handler is detached and the scheduler torn down (timer + foreground listener)
761
- // so nothing leaks across a provider remount. `setAuthRefreshHandler` is
762
- // optional-chained to tolerate partial test stubs.
469
+ // Unified in-session refresh (SDK-owned; every RP inherits it). Installs the
470
+ // ONE core refresh handler (rotate the persisted refresh family; native falls
471
+ // back to the shared keychain) plus the proactive scheduler that refreshes
472
+ // ~60s before expiry and on tab-focus. Replaces the deleted services-local
473
+ // `inSessionTokenRefresh` module.
763
474
  (0, _react.useEffect)(() => {
764
- oxyServices.httpService.setAuthRefreshHandler?.((0, _inSessionTokenRefresh.createInSessionRefreshHandler)(oxyServices));
765
- const scheduler = (0, _inSessionTokenRefresh.startTokenRefreshScheduler)(oxyServices);
475
+ const dispose = (0, _core.installAuthRefreshHandler)({
476
+ oxy: oxyServices,
477
+ store: authStore
478
+ });
479
+ const scheduler = (0, _core.startTokenRefreshScheduler)(oxyServices);
766
480
  return () => {
767
481
  scheduler.dispose();
768
- oxyServices.httpService.setAuthRefreshHandler?.(null);
482
+ dispose();
769
483
  };
770
- }, [oxyServices]);
771
-
772
- // Durable, navigation-safe session persistence.
773
- //
774
- // Writes the active-session id and appends the session id to the durable
775
- // `session_ids` list, awaiting the READY storage instance (never the possibly
776
- // -null `storage` state) so a write is never dropped because it raced storage
777
- // init. Callers MUST invoke this BEFORE any work that can trigger a route
778
- // navigation (`onAuthStateChange`) — navigation can interrupt a still-pending
779
- // async write, which is exactly what once left `session_ids` empty after a
780
- // successful sign-in. Shared by the SSO/silent-restore path and the cold-boot
781
- // refresh-cookie restore so both land the same durable record.
782
- const persistSessionDurably = (0, _react.useCallback)(async sessionId => {
783
- const readyStorage = await getReadyStorage();
784
- await readyStorage.setItem(storageKeys.activeSessionId, sessionId);
785
- const existingIds = await readyStorage.getItem(storageKeys.sessionIds);
786
- let sessionIds = [];
787
- try {
788
- sessionIds = existingIds ? JSON.parse(existingIds) : [];
789
- } catch (parseError) {
790
- logger('Failed to parse persisted session ids; replacing corrupted storage value', parseError);
791
- }
792
- if (!sessionIds.includes(sessionId)) {
793
- sessionIds.push(sessionId);
794
- await readyStorage.setItem(storageKeys.sessionIds, JSON.stringify(sessionIds));
795
- }
796
- // A session is now durably committed — set the returning-user hint so a
797
- // future cold boot whose local session has lapsed still gets ONE `/sso`
798
- // establish bounce (see `markPriorSessionHint`). Every web commit path
799
- // (silent iframe / SSO return / password / cookie restore) funnels
800
- // through here, so this is the single chokepoint for the hint.
801
- await readyStorage.setItem(storageKeys.priorSession, '1');
802
- }, [getReadyStorage, logger, storageKeys.activeSessionId, storageKeys.sessionIds, storageKeys.priorSession]);
803
-
804
- // Refs so the cold-boot restore can plant session state without widening its
805
- // dependency array (mirrors the existing ref pattern above).
806
- const setActiveSessionIdRef = (0, _react.useRef)(setActiveSessionId);
807
- setActiveSessionIdRef.current = setActiveSessionId;
808
- const loginSuccessRef = (0, _react.useRef)(loginSuccess);
809
- loginSuccessRef.current = loginSuccess;
810
- const onAuthStateChangeRef = (0, _react.useRef)(onAuthStateChange);
811
- onAuthStateChangeRef.current = onAuthStateChange;
812
-
813
- // Flip the auth-resolution gate (`authResolved` + `tokenReady`) the MOMENT a
814
- // session commits, instead of waiting for the whole cold-boot chain to finish.
815
- // Idempotent and monotonic via `authResolvedRef`: the first call wins and the
816
- // setters fire at most once, so the restore `finally` backstop becomes a no-op
817
- // once a commit site has already marked resolution. Called from EVERY place a
818
- // user is actually committed (the iframe/SSO path
819
- // `handleWebSSOSession` and the stored-session path) so the common reload
820
- // case unblocks the loading gate without sitting behind the remaining
821
- // (now-skipped) cold-boot steps.
822
- const markAuthResolved = (0, _react.useCallback)(() => {
823
- if (authResolvedRef.current) {
824
- return;
825
- }
826
- authResolvedRef.current = true;
827
- setTokenReady(true);
828
- setAuthResolved(true);
829
- }, []);
830
- const markAuthResolvedRef = (0, _react.useRef)(markAuthResolved);
831
- markAuthResolvedRef.current = markAuthResolved;
832
-
833
- // `handleWebSSOSession` is declared further down (it depends on values that
834
- // are only available there). The iframe/SSO cold-boot steps need to commit
835
- // a recovered session through it, so we route the call through a ref that is
836
- // populated once the callback exists. The ref is assigned synchronously on
837
- // every render before the cold-boot effect can fire (the effect is gated on
838
- // `storage` + `initialized`, both of which settle after first render).
839
- const handleWebSSOSessionRef = (0, _react.useRef)(null);
840
-
841
- // Native (and offline) stored-session restore — the ONLY restore path that
842
- // runs on React Native, and the web fallback when no cross-domain step won.
843
- //
844
- // Stored-session restore. Web uses this only as a fast local winner after
845
- // URL-return handling; native uses it as the durable SecureStore path. Native
846
- // first plants the shared access token from KeyManager, then validates the
847
- // stored session ids with the bearer already in memory.
848
- const restoreStoredSession = (0, _react.useCallback)(async () => {
849
- if (!storage) {
850
- return false;
851
- }
852
- const storedSessionIdsJson = await storage.getItem(storageKeys.sessionIds);
853
- const storedSessionIdsFromStorage = storedSessionIdsJson ? JSON.parse(storedSessionIdsJson) : [];
854
- let storedActiveSessionId = await storage.getItem(storageKeys.activeSessionId);
855
- // OPTIONAL HINT ONLY (no longer a gate). Nothing writes the active-`authuser`
856
- // marker since the SessionClient cutover (`writeActiveAuthuser` was deleted),
857
- // so it is `null` on every fresh install; when a legacy value is present it
858
- // is used purely to backfill `clientSession.authuser` for the active session
859
- // below. It MUST NOT decide whether restore is attempted — see the guard.
860
- const storedActiveAuthuser = (0, _useWebSSO.isWebBrowser)() ? (0, _activeAuthuser.readActiveAuthuser)() : null;
861
-
862
- // On web with no in-memory bearer, bail ONLY when there is genuinely nothing
863
- // to restore — neither a stored active id NOR any stored session ids. A
864
- // surviving `sessionIds` list (e.g. the active-id key was removed after a
865
- // server-side revocation while sibling sessions remain) MUST still be
866
- // validated over the network and have a valid session elected as active
867
- // below. The old guard also required the deleted `activeAuthuser` marker,
868
- // which since the cutover is never written — that made EVERY web reload
869
- // whose local bearer had lapsed bail before any validation. Native always
870
- // proceeds (it plants the shared KeyManager bearer next).
871
- if ((0, _useWebSSO.isWebBrowser)() && !oxyServices.getAccessToken() && storedActiveSessionId === null && storedSessionIdsFromStorage.length === 0) {
872
- return false;
873
- }
874
- const nativeSharedSession = !(0, _useWebSSO.isWebBrowser)() ? await _core.KeyManager.getSharedSession().catch(() => null) : null;
875
- if (nativeSharedSession?.accessToken) {
876
- oxyServices.setTokens(nativeSharedSession.accessToken);
877
- storedActiveSessionId = storedActiveSessionId ?? nativeSharedSession.sessionId;
878
- }
879
- const storedSessionIds = Array.from(new Set([...storedSessionIdsFromStorage, ...(nativeSharedSession?.sessionId ? [nativeSharedSession.sessionId] : [])]));
880
- let validSessions = [];
881
- let unvalidatedSessionIds = [];
882
- if (storedSessionIds.length > 0) {
883
- // Validate all sessions in parallel (with a per-session timeout) to avoid
884
- // sequential blocking that freezes the app on startup
885
- const results = await Promise.allSettled(storedSessionIds.map(async sessionId => {
886
- const timeoutPromise = new Promise(resolve => setTimeout(() => resolve(VALIDATION_TIMEOUT_RESULT), VALIDATION_TIMEOUT));
887
- const validationPromise = oxyServices.validateSession(sessionId, {
888
- useHeaderValidation: true
889
- }).catch(validationError => {
890
- if (!(0, _errorHandlers.isInvalidSessionError)(validationError) && !(0, _errorHandlers.isTimeoutOrNetworkError)(validationError)) {
891
- logger('Session validation failed during init', validationError);
892
- } else if (__DEV__ && (0, _errorHandlers.isTimeoutOrNetworkError)(validationError)) {
893
- _core.logger.debug('Session validation timeout (expected when offline)', {
894
- component: 'OxyContext',
895
- method: 'restoreStoredSession'
896
- }, validationError);
897
- }
898
- return null;
899
- });
900
- return Promise.race([validationPromise, timeoutPromise]).then(validation => {
901
- if (validation === VALIDATION_TIMEOUT_RESULT) {
902
- return {
903
- sessionId,
904
- timedOut: true
905
- };
906
- }
907
- if (validation?.valid && validation.user) {
908
- const now = new Date();
909
- const clientSession = {
910
- sessionId,
911
- deviceId: '',
912
- expiresAt: new Date(now.getTime() + DEFAULT_SESSION_VALIDITY_MS).toISOString(),
913
- lastActive: now.toISOString(),
914
- userId: validation.user.id?.toString() ?? '',
915
- isCurrent: sessionId === storedActiveSessionId
916
- };
917
- if ((0, _useWebSSO.isWebBrowser)() && sessionId === storedActiveSessionId && storedActiveAuthuser !== null) {
918
- clientSession.authuser = storedActiveAuthuser;
919
- }
920
- return {
921
- session: clientSession,
922
- timedOut: false
923
- };
924
- }
925
- return {
926
- session: null,
927
- timedOut: false
928
- };
929
- });
930
- }));
931
- for (const result of results) {
932
- if (result.status !== 'fulfilled') continue;
933
- const value = result.value;
934
- if (value.timedOut) {
935
- unvalidatedSessionIds.push(value.sessionId);
936
- } else if (value.session) {
937
- validSessions.push(value.session);
938
- }
939
- }
940
-
941
- // Persist validated sessions while preserving soft-timed-out IDs. This
942
- // still clears sessions that were explicitly rejected as invalid, but
943
- // avoids logging out valid secondary accounts solely because the API was
944
- // slow during startup.
945
- updateSessionsRef.current(validSessions, {
946
- merge: false,
947
- preserveSessionIds: unvalidatedSessionIds
948
- });
949
- }
950
- if (storedActiveSessionId) {
484
+ }, [oxyServices, authStore]);
485
+
486
+ // ── Session commit funnel ────────────────────────────────────────────────
487
+ // The single place a session becomes committed: plant tokens, persist the
488
+ // rotating refresh family, register the account into the server-authoritative
489
+ // device set, and hydrate the full user. Used by the QR device flow
490
+ // (`handleWebSession`), password sign-in, 2FA completion, and the cold boot.
491
+ const commitSession = (0, _react.useCallback)(async (input, options) => {
492
+ if (input.accessToken) {
493
+ oxyServices.setTokens(input.accessToken);
494
+ }
495
+
496
+ // Persist the durable blob when a rotating refresh token is present
497
+ // (trusted-lane sign-ins). The cold boot has usually persisted already;
498
+ // this is idempotent. A missing refresh token means the session survives
499
+ // reload via the device cookie (web) / shared keychain (native) instead.
500
+ if (input.refreshToken && input.userId) {
951
501
  try {
952
- await switchSessionRef.current(storedActiveSessionId);
953
- // The stored session is committed (this is native's ONLY restore path
954
- // and the common web reload winner). Unblock the auth-resolution gate
955
- // immediately so the loading screen clears without waiting for the
956
- // remaining cold-boot steps to be evaluated/short-circuited (idempotent).
957
- markAuthResolvedRef.current();
958
- // Backfill the returning-user hint. A reload winner already had the hint
959
- // set at original sign-in, but pre-existing installs (signed in before
960
- // this hint shipped) get it set here so their NEXT lapse-and-return still
961
- // earns one `/sso` establish bounce. `storage` is non-null (guarded at
962
- // the top of this callback); best-effort, never blocks restore.
963
- await storage.setItem(storageKeys.priorSession, '1');
964
- return true;
965
- } catch (switchError) {
966
- // Silently handle expected errors (invalid sessions, timeouts, network issues)
967
- if ((0, _errorHandlers.isInvalidSessionError)(switchError)) {
968
- // The stored active id was rejected server-side. Clear it and drop the
969
- // dead session from the local view, then FALL THROUGH to the election
970
- // below to promote a surviving valid session — a stale/removed active
971
- // marker must not strand the other still-valid accounts.
972
- await storage.removeItem(storageKeys.activeSessionId);
973
- validSessions = validSessions.filter(session => session.sessionId !== storedActiveSessionId);
974
- updateSessionsRef.current(validSessions, {
975
- merge: false,
976
- preserveSessionIds: unvalidatedSessionIds
977
- });
978
- // Don't log expected session errors during restoration
979
- } else if ((0, _errorHandlers.isTimeoutOrNetworkError)(switchError)) {
980
- // Timeout/network — the stored active session may still be valid; we
981
- // just could not confirm it. Do NOT elect a different session (that
982
- // could switch accounts under the user); leave restore to a retry / the
983
- // remaining cold-boot steps.
984
- if (__DEV__) {
985
- _core.logger.debug('Active session validation timeout (expected when offline)', {
986
- component: 'OxyContext',
987
- method: 'restoreStoredSession'
988
- }, switchError);
989
- }
990
- return false;
991
- } else {
992
- // Only log unexpected errors, and do not elect on an ambiguous failure.
993
- logger('Active session validation error', switchError);
994
- return false;
995
- }
502
+ const deviceToken = (await authStore.loadDeviceToken()) ?? undefined;
503
+ const next = {
504
+ sessionId: input.sessionId,
505
+ refreshToken: input.refreshToken,
506
+ userId: input.userId,
507
+ ...(deviceToken ? {
508
+ deviceToken
509
+ } : {}),
510
+ ...(input.accessToken ? {
511
+ accessToken: input.accessToken
512
+ } : {}),
513
+ ...(input.expiresAt ? {
514
+ expiresAt: input.expiresAt
515
+ } : {})
516
+ };
517
+ await authStore.save(next);
518
+ } catch (persistError) {
519
+ logger('Failed to persist auth state on commit', persistError);
996
520
  }
997
521
  }
998
522
 
999
- // Election fallback. Reached when there is no usable stored active id
1000
- // either it was never persisted (e.g. removed after a server-side revocation
1001
- // while sibling sessions survived) or it was just rejected as invalid above.
1002
- // Promote the FIRST validated session to active and commit it through the
1003
- // same `switchSession` path. The server owns which account is truly active,
1004
- // so any valid session is a safe provisional active — the post-restore
1005
- // SessionClient handoff reconciles the real active account immediately after.
1006
- const electedSession = validSessions[0];
1007
- if (electedSession) {
1008
- try {
1009
- await switchSessionRef.current(electedSession.sessionId);
1010
- await storage.setItem(storageKeys.activeSessionId, electedSession.sessionId);
1011
- await storage.setItem(storageKeys.priorSession, '1');
1012
- markAuthResolvedRef.current();
1013
- return true;
1014
- } catch (electionError) {
1015
- // Activation requires an in-memory bearer on web; without one (a plain
1016
- // web reload) the switch throws and recovery defers to the cross-domain
1017
- // cold-boot steps. Invalid/timeout/network are expected here — surface
1018
- // only genuinely unexpected failures.
1019
- if (!(0, _errorHandlers.isInvalidSessionError)(electionError) && !(0, _errorHandlers.isTimeoutOrNetworkError)(electionError)) {
1020
- logger('Elected session activation error', electionError);
1021
- }
1022
- }
523
+ // Fast local mirror so the UI updates before the server round-trip; the
524
+ // SessionClient projection below overwrites it with server truth.
525
+ if (input.userId) {
526
+ const now = new Date();
527
+ updateSessions([{
528
+ sessionId: input.sessionId,
529
+ deviceId: input.deviceId ?? '',
530
+ expiresAt: input.expiresAt || new Date(now.getTime() + DEFAULT_SESSION_VALIDITY_MS).toISOString(),
531
+ lastActive: now.toISOString(),
532
+ userId: input.userId,
533
+ isCurrent: true
534
+ }], {
535
+ merge: true
536
+ });
537
+ setActiveSessionId(input.sessionId);
1023
538
  }
1024
- return false;
1025
- }, [logger, oxyServices, storage, storageKeys.activeSessionId, storageKeys.sessionIds, storageKeys.priorSession]);
1026
-
1027
- // Shared in-flight `runSsoReturn` promise — see the CONCURRENCY note on
1028
- // `runSsoReturn` below. Lets the eager interception effect and the cold-boot
1029
- // `sso-return` step share one `consumeSsoReturn` invocation race-free.
1030
- const inFlightSsoReturnRef = (0, _react.useRef)(null);
1031
539
 
1032
- // Central cross-domain SSO return handler (web). A THIN wrapper over core's
1033
- // `consumeSsoReturn`, which performs the entire security-critical kernel
1034
- // parse the IdP redirect fragment, validate the CSRF `state`, strip the
1035
- // fragment FIRST, exchange the opaque single-use code, restore the user's
1036
- // pre-bounce destination (same-origin only), and set the per-origin
1037
- // NO_SESSION loop breaker on every non-ok outcome — and RETURNS the exchanged
1038
- // session (or `null`) WITHOUT committing. We preserve services' contract by
1039
- // committing the returned session here via `handleWebSSOSession`. Shared by
1040
- // the `sso-return` cold-boot step AND the bfcache `pageshow` re-evaluation, so
1041
- // the same kernel runs exactly once per delivered fragment regardless of how
1042
- // the page was (re)shown.
1043
- //
1044
- // Returns `true` when a session was committed (caller short-circuits), `false`
1045
- // otherwise. Off-browser `consumeSsoReturn` is a no-op returning `null`, so
1046
- // this returns `false` (native never reaches it).
1047
- //
1048
- // CONCURRENCY: the eager SSO-callback interception effect and the cold-boot
1049
- // `sso-return` step can both invoke this in the same tick when we land on the
1050
- // callback path. `consumeSsoReturn` strips the fragment FIRST, so a naive
1051
- // second invocation would parse an already-stripped URL and return `null` —
1052
- // leaving the losing caller with no session (and on an `ok` outcome could let
1053
- // the terminal bounce fire spuriously). The FIRST call's promise is memoised in
1054
- // `inFlightSsoReturnRef` and SHARED with every concurrent caller, so the single
1055
- // `consumeSsoReturn` invocation — and its single commit via `handleWebSSOSession`
1056
- // — is delivered identically to both paths. Cleared once it settles so a later,
1057
- // genuinely-separate return (e.g. a bfcache restore) runs a fresh pass.
1058
- const runSsoReturn = (0, _react.useCallback)(() => {
1059
- if (inFlightSsoReturnRef.current) {
1060
- return inFlightSsoReturnRef.current;
1061
- }
1062
- const inFlight = (0, _core.consumeSsoReturn)(oxyServices, {
1063
- isWeb: _useWebSSO.isWebBrowser,
1064
- onExchangeError: error => {
1065
- if (__DEV__) {
1066
- _core.logger.debug('SSO code exchange failed (treating as no session)', {
1067
- component: 'OxyContext',
1068
- method: 'runSsoReturn'
1069
- }, error);
1070
- }
1071
- }
1072
- }).then(async session => {
1073
- if (!session) {
1074
- return false;
1075
- }
1076
- const commitWebSession = handleWebSSOSessionRef.current;
1077
- if (!commitWebSession) {
1078
- return false;
540
+ // Register into the device set. A deliberate sign-in ACTIVATES the account
541
+ // (`registerAndActivate`); the cold boot only ensures membership and lets
542
+ // the server's own `activeAccountId` win (`addCurrentAccount`). Both then
543
+ // start the socket + project. Best-effort a failure never fails the
544
+ // sign-in (cold boot re-registers on the next load).
545
+ try {
546
+ if (options.activate) {
547
+ await sessionClient.registerAndActivate(input.userId);
548
+ } else {
549
+ await sessionClient.addCurrentAccount();
1079
550
  }
1080
- await commitWebSession(session);
1081
- return true;
1082
- }).finally(() => {
1083
- inFlightSsoReturnRef.current = null;
1084
- });
1085
- inFlightSsoReturnRef.current = inFlight;
1086
- return inFlight;
1087
- }, [oxyServices]);
1088
-
1089
- // Cold boot — the single, ordered, short-circuit session-recovery sequence,
1090
- // consuming the SAME `runColdBoot` core primitive as `WebOxyProvider`. The
1091
- // FIRST step that yields a session wins; every later step is skipped. Each
1092
- // web-only step is gated by `isWebBrowser()`, so on native ONLY
1093
- // `stored-session` runs.
1094
- //
1095
- // Order (web): SSO return → stored session → shared-key sign-in (native
1096
- // only, disabled here) → silent iframe (per-apex, the durable reload path)
1097
- // → SSO bounce (terminal). This is a pure TOKEN-ACQUISITION ladder — it
1098
- // mints the first per-domain access token by whichever means recovers one
1099
- // fastest. Once a token is acquired (or the ladder exhausts), the
1100
- // SERVER-authoritative `SessionClient` takes over: `addCurrentAccount` +
1101
- // `start` bootstrap the device session set and multi-account/active-account
1102
- // state from `GET /session/device/state`, so WHICH account is active is
1103
- // decided server-side, not by a client-persisted slot. There is no oxy_rt
1104
- // refresh-cookie restore step in this ladder, and no FedCM step — FedCM was
1105
- // removed from the client sign-in/cold-boot path entirely (Chrome-only, and
1106
- // its fast/silent failure mode combined with a caller's auth-guard effect
1107
- // could re-trigger sign-in in a tight loop; see `CrossDomainAuth`'s doc
1108
- // comment in `@oxyhq/core`).
1109
- //
1110
- // LATENCY (FIX A): `stored-session` runs BEFORE the slow no-redirect
1111
- // `silent-iframe` probe. On a normal reload the local bearer validates in
1112
- // one round-trip and wins, so `runColdBoot` short-circuits and never sits
1113
- // through that probe's timeout. `sso-return` MUST stay first — it consumes
1114
- // the URL fragment before anything can strip it. On a first visit with no
1115
- // local session, `stored-session` skips and the cross-domain fallback chain
1116
- // (silent-iframe → sso-bounce) runs exactly as before; the per-apex silent
1117
- // iframe still restores a durable cross-domain session on reload WITHOUT a
1118
- // top-level bounce, so when it wins `sso-bounce` never fires (no flash, no
1119
- // loop).
1120
- // Order (native): stored session only (every web-only step is disabled
1121
- // off-browser).
1122
- const restoreSessionsFromStorage = (0, _react.useCallback)(async () => {
1123
- if (!storage) {
1124
- return;
551
+ await sessionClient.start();
552
+ await syncFromClient();
553
+ } catch (registrationError) {
554
+ _core.logger.warn('commitSession: device-set registration failed', {
555
+ component: 'OxyContext',
556
+ method: 'commitSession'
557
+ }, registrationError);
1125
558
  }
1126
- setTokenReady(false);
1127
- // Fresh per-boot flag — see the declaration comment above `sessionClient`.
1128
- registeredDuringBootRef.current = false;
1129
- const commitWebSession = handleWebSSOSessionRef.current;
1130
559
 
1131
- // FIX-B precondition flag: set true the instant the (now-earlier)
1132
- // `stored-session` step recovers a local bearer session. The slow
1133
- // web-only `silent-iframe` probe is `enabled` on `!storedSessionRestored`
1134
- // so it is explicitly skipped once a local session won. `runColdBoot`
1135
- // already short-circuits on the first `{kind:'session'}`, so on a winning
1136
- // reload that `enabled` body is never even reached — this flag makes the
1137
- // intent explicit and is redundant-safe. On a first-visit-no-local-session,
1138
- // `stored-session` skips, this stays false, and the probe runs as before.
1139
- let storedSessionRestored = false;
1140
-
1141
- // FIX-B smart-gate input: has this device/app EVER had a signed-in Oxy
1142
- // session (the durable `priorSession` hint, set on every commit, cleared
1143
- // only on explicit full sign-out)? Read ONCE here — synchronously usable by
1144
- // the terminal `sso-bounce` `enabled` gate below — so a RETURNING visitor
1145
- // whose local session has lapsed still earns one `/sso` establish bounce,
1146
- // while a truly first-time anonymous visitor is never force-redirected.
1147
- // `storage` is non-null (guarded above); a read failure is treated as "no
1148
- // prior session" (fail safe toward anonymous-browse).
1149
- let hadPriorSession = false;
560
+ // Hydrate the full user (the commit input carries only minimal data). Fall
561
+ // back to the minimal shape if the profile fetch fails.
562
+ let fullUser = null;
1150
563
  try {
1151
- hadPriorSession = (await storage.getItem(storageKeys.priorSession)) === '1';
1152
- } catch (priorSessionReadError) {
564
+ fullUser = await oxyServices.getCurrentUser();
565
+ } catch (profileError) {
1153
566
  if (__DEV__) {
1154
- _core.logger.debug('Failed to read prior-session hint (treating as first-time visitor)', {
567
+ _core.logger.debug('Failed to fetch full user on commit; using minimal fallback', {
1155
568
  component: 'OxyContext',
1156
- method: 'restoreSessionsFromStorage'
1157
- }, priorSessionReadError);
569
+ method: 'commitSession'
570
+ }, profileError);
1158
571
  }
572
+ fullUser = input.user ?? null;
573
+ }
574
+ if (fullUser) {
575
+ loginSuccess(fullUser);
576
+ onAuthStateChangeRef.current?.(fullUser);
1159
577
  }
578
+ markAuthResolvedRef.current();
579
+ }, [oxyServices, authStore, updateSessions, setActiveSessionId, sessionClient, syncFromClient, loginSuccess, logger]);
580
+ const commitSessionRef = (0, _react.useRef)(commitSession);
581
+ commitSessionRef.current = commitSession;
582
+
583
+ // Public `handleWebSession`: commit a session from the QR device flow. It is a
584
+ // deliberate sign-in on THIS device, so it activates the account.
585
+ const handleWebSession = (0, _react.useCallback)(async session => {
586
+ if (!session?.user || !session?.sessionId || !session.accessToken) {
587
+ throw new Error('Session response did not include a usable session');
588
+ }
589
+ await commitSession({
590
+ sessionId: session.sessionId,
591
+ accessToken: session.accessToken,
592
+ // deviceFlow threads a rotating refresh token on the runtime object
593
+ // even though `SessionLoginResponse` does not type it.
594
+ refreshToken: session.refreshToken,
595
+ deviceId: session.deviceId,
596
+ expiresAt: session.expiresAt,
597
+ userId: session.user.id,
598
+ user: session.user
599
+ }, {
600
+ activate: true
601
+ });
602
+ }, [commitSession]);
1160
603
 
1161
- // DELIBERATELY-SIGNED-OUT gate (web): when the user pressed "Sign out", the
1162
- // per-apex `fedcm_session` cookie can still be live, so the AUTOMATIC
1163
- // `silent-iframe` step below would re-mint a session on the very next cold
1164
- // boot and sign the user back in without intent. Read the durable flag
1165
- // ONCE here (synchronously usable by the step `enabled` gate) and skip
1166
- // that step while it is set. Any deliberate sign-in clears it. The
1167
- // `sso-bounce` step is already self-suppressed after sign-out (its
1168
- // `hasPriorSession` hint is cleared), and the local `stored-session`
1169
- // restore step is unaffected — a deliberate sign-out clears those
1170
- // credentials anyway.
1171
- const silentRestoreBlocked = (0, _activeAuthuser.isSilentRestoreSuppressed)();
604
+ // Keyless password sign-in: username/email + password.
605
+ const signInWithPassword = (0, _react.useCallback)(async (identifier, password, opts) => {
606
+ const deviceToken = (await authStore.loadDeviceToken()) ?? undefined;
607
+ const result = await oxyServices.passwordSignIn(identifier, password, {
608
+ deviceToken,
609
+ deviceName: opts?.deviceName,
610
+ deviceFingerprint: opts?.deviceFingerprint
611
+ });
612
+ if ('twoFactorRequired' in result) {
613
+ return {
614
+ status: '2fa_required',
615
+ loginToken: result.loginToken
616
+ };
617
+ }
618
+ // `passwordSignIn` already planted the access token on the session arm.
619
+ await commitSession({
620
+ sessionId: result.sessionId,
621
+ accessToken: result.accessToken,
622
+ refreshToken: result.refreshToken,
623
+ deviceId: result.deviceId,
624
+ expiresAt: result.expiresAt,
625
+ userId: result.user.id,
626
+ user: result.user
627
+ }, {
628
+ activate: true
629
+ });
630
+ return {
631
+ status: 'ok'
632
+ };
633
+ }, [oxyServices, authStore, commitSession]);
634
+ const completeTwoFactorSignIn = (0, _react.useCallback)(async params => {
635
+ const deviceToken = (await authStore.loadDeviceToken()) ?? undefined;
636
+ const result = await oxyServices.completeTwoFactorSignIn({
637
+ loginToken: params.loginToken,
638
+ token: params.token,
639
+ backupCode: params.backupCode,
640
+ deviceToken,
641
+ deviceName: params.deviceName
642
+ });
643
+ await commitSession({
644
+ sessionId: result.sessionId,
645
+ accessToken: result.accessToken,
646
+ refreshToken: result.refreshToken,
647
+ deviceId: result.deviceId,
648
+ expiresAt: result.expiresAt,
649
+ userId: result.user.id,
650
+ user: result.user
651
+ }, {
652
+ activate: true
653
+ });
654
+ }, [oxyServices, authStore, commitSession]);
655
+
656
+ // ── Cold boot ────────────────────────────────────────────────────────────
657
+ // The single device-first session restore. Runs ONCE after storage init.
658
+ // `runSessionColdBoot` resolves the device's session (bootstrap-return →
659
+ // stored-tokens → shared-key (native) → bootstrap-hop (web)) WITHOUT ever
660
+ // redirecting to a login page; an unresolved boot ends signed-out and the app
661
+ // renders its "Sign in with Oxy" affordance. On a winning boot the token is
662
+ // already planted + persisted, so `onSession` only hands off to the
663
+ // SessionClient (membership + active account + realtime socket) and marks
664
+ // auth resolved. `onSignedOut` marks auth resolved with no session.
665
+ const runColdBoot = (0, _react.useCallback)(async () => {
666
+ setTokenReady(false);
1172
667
  try {
1173
- const outcome = await (0, _core.runColdBoot)({
1174
- steps: [{
1175
- // 0) Central SSO return: we are landing back from an `auth.oxy.so/sso`
1176
- // bounce with the result in the URL fragment. Parse it, validate the
1177
- // CSRF state, exchange the opaque code, and commit. On any non-ok
1178
- // outcome `runSsoReturn` sets the per-origin NO_SESSION flag so the
1179
- // terminal `sso-bounce` step is disabled — the loop breaker.
1180
- id: 'sso-return',
1181
- enabled: () => (0, _useWebSSO.isWebBrowser)(),
1182
- run: async () => {
1183
- const committed = await runSsoReturn();
1184
- return committed ? {
1185
- kind: 'session',
1186
- session: true
1187
- } : {
1188
- kind: 'skip'
1189
- };
1190
- }
1191
- }, {
1192
- // 2) Stored-session bearer restore. NO `enabled` gate — runs on ALL
1193
- // platforms. This is native's ONLY restore path (every web-only step
1194
- // is disabled off-browser, so native reaches exactly this) AND the
1195
- // common WEB reload winner.
1196
- //
1197
- // ORDERING (FIX A): this step now runs BEFORE the slow web-only
1198
- // `silent-iframe` probe. On a normal reload the local bearer
1199
- // validates in one round-trip and wins; `runColdBoot`
1200
- // then short-circuits and never even evaluates the slow
1201
- // no-redirect probe that would otherwise time out (the ~20-30s
1202
- // serial stall). The `sso-return` step stays AHEAD of this one —
1203
- // it must consume the URL fragment before any later step (or
1204
- // anything else) strips it. On a first visit with no local
1205
- // session this step skips and the cross-domain fallback chain
1206
- // (silent-iframe → sso-bounce) runs exactly as before.
1207
- id: 'stored-session',
1208
- run: async () => {
1209
- const restored = await restoreStoredSession();
1210
- if (restored) {
1211
- // FIX-B: record the win so the slow probes below explicitly skip
1212
- // (belt-and-suspenders; `runColdBoot` already short-circuits).
1213
- storedSessionRestored = true;
1214
- return {
1215
- kind: 'session',
1216
- session: true
1217
- };
1218
- }
1219
- return {
1220
- kind: 'skip'
1221
- };
1222
- }
1223
- }, {
1224
- // 2.5) Shared-key SSO (NATIVE ONLY) — same-device, no interaction.
1225
- //
1226
- // When a sibling Oxy app (the Commons identity vault) has written
1227
- // the cross-app shared identity into the `group.so.oxy.shared`
1228
- // keychain, this mints THIS app's session from it silently:
1229
- // `signInWithSharedIdentity()` proves control of the shared key
1230
- // (challenge → sign → verify, which plants the tokens server-side)
1231
- // and returns a `SessionLoginResponse`, which we commit through the
1232
- // SAME path the web SSO steps use (`handleWebSSOSession`), so state,
1233
- // durable persistence, profile fetch, and `markAuthResolved` all run
1234
- // identically. Returns `null` when no shared identity is present, so
1235
- // a device without Commons simply falls through.
1236
- //
1237
- // ORDERING: runs immediately AFTER `stored-session` (an already
1238
- // restored local bearer always wins first — `storedSessionRestored`
1239
- // gates this off) and BEFORE the web-only probes (which are gated
1240
- // off on native anyway). Native-only: `enabled` requires
1241
- // `!isWebBrowser()`, and the core method also returns `null` on web,
1242
- // so `WebOxyProvider` / the web path are entirely unaffected.
1243
- // Bounded by `SHARED_KEY_SIGNIN_TIMEOUT` so a slow/offline network
1244
- // cannot stall cold boot.
1245
- id: 'shared-key-signin',
1246
- enabled: () => !(0, _useWebSSO.isWebBrowser)() && !storedSessionRestored,
1247
- run: async () => {
1248
- if (!commitWebSession) {
1249
- return {
1250
- kind: 'skip'
1251
- };
1252
- }
1253
- let timeoutId;
1254
- const session = await Promise.race([oxyServices.signInWithSharedIdentity?.() ?? Promise.resolve(null), new Promise(resolve => {
1255
- timeoutId = setTimeout(() => resolve(null), SHARED_KEY_SIGNIN_TIMEOUT);
1256
- })]).finally(() => {
1257
- if (timeoutId !== undefined) {
1258
- clearTimeout(timeoutId);
1259
- }
1260
- });
1261
- if (!session) {
1262
- return {
1263
- kind: 'skip'
1264
- };
1265
- }
1266
- await commitWebSession(session);
1267
- // Record the win so the (web-only) probes below explicitly skip —
1268
- // belt-and-suspenders; `runColdBoot` already short-circuits on the
1269
- // first `{kind:'session'}`, and those probes are off on native.
1270
- storedSessionRestored = true;
1271
- return {
1272
- kind: 'session',
1273
- session: true
1274
- };
1275
- }
1276
- }, {
1277
- // 3) First-party silent iframe at the PER-APEX IdP — the DURABLE
1278
- // cross-domain reload-restore path. The durable session lives as a
1279
- // first-party `fedcm_session` cookie on `auth.<rp-apex>` (e.g.
1280
- // `auth.mention.earth`), established during the `/sso` bounce's
1281
- // `/sso/establish` hop. That host is SAME-SITE to the RP page, so
1282
- // the cookie is first-party under Safari ITP / Firefox TCP — and
1283
- // an iframe read is NOT a top-level navigation, so it restores on
1284
- // reload with NO flash and works in a backgrounded tab. This is the
1285
- // step that prevents the re-bounce loop: when it finds a session,
1286
- // the terminal `sso-bounce` never fires.
1287
- //
1288
- // The per-apex iframe mint itself lives in
1289
- // `mintSessionViaPerApexIframe` (shared verbatim with the in-session
1290
- // refresh handler so the two paths can never drift): it points the
1291
- // iframe at `autoDetectAuthWebUrl()` and skips when there is no
1292
- // per-apex IdP (localhost/IP/single-label/off-browser). Web only; on
1293
- // native `isWebBrowser()` gates it off, so native never runs an
1294
- // iframe.
1295
- //
1296
- // FIX-B: additionally skipped when `stored-session` already won.
1297
- // FIX-D: bounded by `SILENT_IFRAME_TIMEOUT` (plus `iframe.onerror`
1298
- // fail-fast in core) so a no-message iframe cannot stall cold boot.
1299
- id: 'silent-iframe',
1300
- enabled: () => !storedSessionRestored && !silentRestoreBlocked && (0, _useWebSSO.isWebBrowser)(),
1301
- run: async () => {
1302
- if (!commitWebSession) {
1303
- return {
1304
- kind: 'skip'
1305
- };
1306
- }
1307
- // Shared with the in-session refresh handler: the ONE
1308
- // implementation of "mint a first-party per-apex token without a
1309
- // reload" lives in `silentSessionRestore` so cold boot and refresh
1310
- // never drift.
1311
- const session = await (0, _silentSessionRestore.mintSessionViaPerApexIframe)(oxyServices, SILENT_IFRAME_TIMEOUT);
1312
- if (!session) {
1313
- return {
1314
- kind: 'skip'
1315
- };
1316
- }
1317
- await commitWebSession(session);
1318
- return {
1319
- kind: 'session',
1320
- session: true
1321
- };
1322
- }
1323
- }, {
1324
- // 4) SSO bounce (TERMINAL, web only, at most once). No local session
1325
- // was found by any step above. Top-level navigate to the central
1326
- // `auth.oxy.so/sso?prompt=none` so the IdP can either mint a session
1327
- // (returning an opaque code we exchange on the callback) or report
1328
- // `none`. This step tears the document down on success — its `skip`
1329
- // result is only observed if `assign` no-ops. Disabled on the IdP
1330
- // itself, once the NO_SESSION flag is set, or while a bounce guard is
1331
- // still active (loop + self-heal protection).
1332
- id: 'sso-bounce',
1333
- enabled: () => {
1334
- // Smart gate (SDK-owned, shared with `WebOxyProvider` via core's
1335
- // `allowSsoBounce`). The terminal `/sso` establish-bounce is the
1336
- // ONLY cold-boot step that can recover a session living SOLELY at
1337
- // the central IdP (a cross-apex RP whose local session expired),
1338
- // and it is what plants the per-apex `fedcm_session` cookie the
1339
- // earlier silent-iframe step relies on. So it is allowed iff a
1340
- // prior-signed-in hint exists OR a local session was recovered this
1341
- // boot (`storedSessionRestored`, always false here — an earlier step
1342
- // would have won — but passed for spec fidelity): a RETURNING user
1343
- // still gets ONE bounce, while a truly first-time anonymous visitor
1344
- // does NOT (anonymous browse). The per-tab loop guards below
1345
- // (`ssoNoSessionKey`, `ssoAttemptedKey`, `guardActive`) still cap an
1346
- // allowed bounce at one per cold boot.
1347
- if (!(0, _core.allowSsoBounce)({
1348
- hasPriorSession: hadPriorSession,
1349
- hasLocalSession: storedSessionRestored
1350
- })) {
1351
- return false;
1352
- }
1353
- if (!(0, _useWebSSO.isWebBrowser)() || window.top !== window.self) {
1354
- return false;
1355
- }
1356
- const origin = window.location.origin;
1357
- if ((0, _core.isCentralIdPOrigin)(origin)) {
1358
- return false;
1359
- }
1360
- if (window.sessionStorage.getItem((0, _core.ssoNoSessionKey)(origin)) === '1') {
1361
- return false;
1362
- }
1363
- if (window.sessionStorage.getItem((0, _core.ssoAttemptedKey)(origin)) === '1') {
1364
- return false;
1365
- }
1366
- if ((0, _core.guardActive)(window.sessionStorage, origin, Date.now())) {
1367
- return false;
668
+ await (0, _core.runSessionColdBoot)({
669
+ oxy: oxyServices,
670
+ store: authStore,
671
+ platform: {
672
+ isWeb: (0, _isWebBrowser.isWebBrowser)(),
673
+ isNative: !(0, _isWebBrowser.isWebBrowser)()
674
+ },
675
+ onSession: async session => {
676
+ // Bound how long auth resolution waits for the handoff — the token is
677
+ // already planted, so on a slow backend we proceed and let the handoff
678
+ // land asynchronously (the socket subscription re-projects when it does).
679
+ const handoff = commitSessionRef.current({
680
+ sessionId: session.sessionId,
681
+ accessToken: session.accessToken,
682
+ userId: session.userId
683
+ }, {
684
+ activate: false
685
+ });
686
+ let handoffDeadlineId;
687
+ await Promise.race([handoff, new Promise(resolve => {
688
+ handoffDeadlineId = setTimeout(resolve, SESSION_HANDOFF_DEADLINE);
689
+ })]).finally(() => {
690
+ if (handoffDeadlineId !== undefined) {
691
+ clearTimeout(handoffDeadlineId);
1368
692
  }
1369
- return true;
1370
- },
1371
- run: async () => {
1372
- const origin = window.location.origin;
1373
- const state = oxyServices.generateSsoState();
1374
- window.sessionStorage.setItem((0, _core.ssoStateKey)(origin), state);
1375
- window.sessionStorage.setItem((0, _core.ssoGuardKey)(origin), String(Date.now()));
1376
- window.sessionStorage.setItem((0, _core.ssoDestKey)(origin), window.location.href);
1377
- // OUTCOME-INDEPENDENT once-guard: mark the probe attempted the instant we
1378
- // commit to the bounce, so even if the callback never lands cleanly no
1379
- // second bounce can ever fire this tab (the definitive loop breaker).
1380
- window.sessionStorage.setItem((0, _core.ssoAttemptedKey)(origin), '1');
1381
- const url = (0, _core.buildSsoBounceUrl)(origin, state, oxyServices.config?.authWebUrl);
1382
-
1383
- // TERMINAL: the document is torn down by this navigation. The
1384
- // `skip` below is only reached if `assign` is a no-op (e.g. the
1385
- // navigation is blocked); in that case we fall through
1386
- // unauthenticated, which is correct.
1387
- (0, _core.ssoNavigate)(url);
1388
- return {
1389
- kind: 'skip'
1390
- };
1391
- }
1392
- }],
693
+ });
694
+ markAuthResolvedRef.current();
695
+ },
696
+ onSignedOut: () => {
697
+ markAuthResolvedRef.current();
698
+ },
1393
699
  onStepError: (id, error) => {
1394
700
  if (__DEV__) {
1395
701
  _core.logger.debug(`Cold-boot step "${id}" errored (non-fatal, falling through)`, {
1396
702
  component: 'OxyContext',
1397
- method: 'restoreSessionsFromStorage'
703
+ method: 'runColdBoot'
1398
704
  }, error);
1399
705
  }
1400
- },
1401
- // Defense-in-depth: a single step whose promise never settles can no
1402
- // longer block the chain forever. On expiry the runner keeps iterating
1403
- // to the terminal `sso-bounce`
1404
- // step so a genuine no-local-session visit still reaches the
1405
- // cross-domain `/sso` fallback; the `finally` backstop flips
1406
- // `authResolved` regardless. See `COLD_BOOT_OVERALL_DEADLINE`.
1407
- overallDeadlineMs: COLD_BOOT_OVERALL_DEADLINE,
1408
- onStepDeadline: id => {
1409
- if (__DEV__) {
1410
- _core.logger.debug(`Cold-boot step "${id}" exceeded the overall deadline (abandoned, falling through)`, {
1411
- component: 'OxyContext',
1412
- method: 'restoreSessionsFromStorage'
1413
- });
1414
- }
1415
706
  }
1416
707
  });
1417
- if (__DEV__ && outcome.kind === 'session') {
1418
- _core.logger.debug(`Cold boot recovered a session via "${outcome.via}"`, {
1419
- component: 'OxyContext',
1420
- method: 'restoreSessionsFromStorage'
1421
- });
1422
- }
1423
-
1424
- // TOKEN LADDER → SESSIONCLIENT AUTHORITY HANDOFF. The steps above are
1425
- // ONLY a token-acquisition ladder — they mint the first per-domain
1426
- // access token by whichever means recovers one fastest. Once a session
1427
- // is known (either this cold boot committed one via the ladder, or an
1428
- // access token is already held in memory — e.g. a prior render already
1429
- // planted it), hand off to the server-authoritative `SessionClient`:
1430
- // `addCurrentAccount` registers this recovered account+session into the
1431
- // server `DeviceSession` (derives identity from the bearer), `start`
1432
- // bootstraps the full device-session state (server `activeAccountId` +
1433
- // `activeToken`) and connects the realtime socket, and `syncFromClient`
1434
- // projects that state onto the exposed `sessions`/`activeSessionId`/
1435
- // `user`. This is what lets a switched (managed/org) account survive
1436
- // reload: the SERVER, not a client-persisted slot, owns which account is
1437
- // active. Never call the client when no session was acquired — an
1438
- // anonymous visitor must stay logged out. Failures are logged and
1439
- // swallowed; they must never throw out of cold boot.
1440
- //
1441
- // `addCurrentAccount` is SKIPPED when `registeredDuringBootRef` is
1442
- // already `true` — every ladder step except `stored-session` commits
1443
- // through `handleWebSSOSession`, which registers the account itself
1444
- // (see its own `sessionClient.addCurrentAccount()` call). Without this
1445
- // guard a winning `sso-return` / `shared-key-signin` / `silent-iframe`
1446
- // step would register the SAME account twice
1447
- // (`POST /session/device/add` called back-to-back). `start()` and
1448
- // `syncFromClient()` still always run — `start()` is idempotent
1449
- // (no-ops once already started) and is what connects the realtime
1450
- // socket for the first time.
1451
- if (outcome.kind === 'session' || oxyServices.getAccessToken()) {
1452
- // Self-contained: never throws (own try/catch), so it is safe to let it
1453
- // outlive the race below and finish in the background if the deadline
1454
- // trips — the socket subscription then projects the state when it lands.
1455
- const handoff = (async () => {
1456
- try {
1457
- if (!registeredDuringBootRef.current) {
1458
- // Nested guard: registering this account into the device set is
1459
- // best-effort. If it throws, `start()` must STILL run so the
1460
- // realtime socket connects and state projects — cold boot
1461
- // re-registers the account into the device set on the next load.
1462
- try {
1463
- await sessionClient.addCurrentAccount();
1464
- } catch (addAccountErr) {
1465
- _core.logger.warn('cold-boot: addCurrentAccount failed; continuing to start()', {
1466
- component: 'OxyContext',
1467
- method: 'restoreSessionsFromStorage'
1468
- }, addAccountErr);
1469
- }
1470
- }
1471
- await sessionClient.start();
1472
- await syncFromClient();
1473
- } catch (startErr) {
1474
- _core.logger.warn('cold-boot: SessionClient start failed', {
1475
- component: 'OxyContext',
1476
- method: 'restoreSessionsFromStorage'
1477
- }, startErr);
1478
- }
1479
- })();
1480
- // Bound how long auth resolution waits for the handoff (see
1481
- // `SESSION_HANDOFF_DEADLINE`): the token is already planted, so on a
1482
- // slow backend we proceed to `markAuthResolved` and let the handoff
1483
- // complete asynchronously rather than stalling the spinner.
1484
- let handoffDeadlineId;
1485
- await Promise.race([handoff, new Promise(resolve => {
1486
- handoffDeadlineId = setTimeout(resolve, SESSION_HANDOFF_DEADLINE);
1487
- })]).finally(() => {
1488
- if (handoffDeadlineId !== undefined) {
1489
- clearTimeout(handoffDeadlineId);
1490
- }
1491
- });
1492
- }
1493
708
  } catch (error) {
1494
709
  if (__DEV__) {
1495
- _core.logger.error('Auth init error', error instanceof Error ? error : new Error(String(error)), {
710
+ _core.logger.error('Cold boot error', error instanceof Error ? error : new Error(String(error)), {
1496
711
  component: 'OxyContext',
1497
- method: 'restoreSessionsFromStorage'
712
+ method: 'runColdBoot'
1498
713
  });
1499
714
  }
1500
- await clearSessionStateRef.current();
1501
715
  } finally {
1502
- // Backstop: mark auth resolved on EVERY exit path success, no-session,
1503
- // AND error→catch→finally — and on native (which only runs the
1504
- // `stored-session` step), so the gate can never hang `false`. Idempotent
1505
- // via `markAuthResolved`'s ref: when a commit site already flipped it
1506
- // mid-chain (the common reload case), this is a no-op. When no session was
1507
- // recovered (the unauthenticated/error path), this is where `tokenReady` +
1508
- // `authResolved` finally flip. Monotonic — never reverts on later restores.
1509
- markAuthResolved();
716
+ // Backstop: resolve on every exit path so the gate can never hang.
717
+ markAuthResolvedRef.current();
1510
718
  }
1511
- }, [oxyServices, storage, storageKeys.priorSession, restoreStoredSession, runSsoReturn, markAuthResolved, sessionClient, syncFromClient]);
719
+ }, [oxyServices, authStore]);
1512
720
  (0, _react.useEffect)(() => {
1513
721
  if (!storage || initialized) {
1514
722
  return;
1515
723
  }
1516
724
  setInitialized(true);
1517
- restoreSessionsFromStorage().catch(error => {
1518
- if (__DEV__) {
1519
- logger('Failed to restore sessions from storage', error);
1520
- }
1521
- });
1522
- }, [restoreSessionsFromStorage, storage, initialized, logger]);
1523
-
1524
- // bfcache re-evaluation (web only, registered once). When a page is restored
1525
- // from the back/forward cache (`e.persisted`) NO cold boot re-runs — React
1526
- // state is resurrected as-is — yet the page may have been frozen mid-bounce
1527
- // and resurrected ON the SSO callback with a fresh fragment in the URL. Re-run
1528
- // the `sso-return` parse so the opaque code is still exchanged (and the
1529
- // fragment stripped + NO_SESSION flag maintained) on a bfcache restore. Routed
1530
- // through a ref so the listener registers exactly once and never churns with
1531
- // `runSsoReturn`'s identity.
1532
- const runSsoReturnRef = (0, _react.useRef)(runSsoReturn);
1533
- runSsoReturnRef.current = runSsoReturn;
1534
- (0, _react.useEffect)(() => {
1535
- if (!(0, _useWebSSO.isWebBrowser)()) {
1536
- return;
1537
- }
1538
- const onPageShow = event => {
1539
- if (!event.persisted) {
1540
- return;
1541
- }
1542
- runSsoReturnRef.current().catch(error => {
1543
- if (__DEV__) {
1544
- _core.logger.debug('bfcache SSO return re-evaluation failed (non-fatal)', {
1545
- component: 'OxyContext',
1546
- method: 'onPageShow'
1547
- }, error);
1548
- }
1549
- });
1550
- };
1551
- window.addEventListener('pageshow', onPageShow);
1552
- return () => window.removeEventListener('pageshow', onPageShow);
1553
- }, []);
1554
-
1555
- // EAGER, universal SSO-callback interception (web only, once on mount).
1556
- //
1557
- // When the central IdP redirects the RP back to the internal callback path
1558
- // ({@link SSO_CALLBACK_PATH}), the app's own router would otherwise mount on
1559
- // `/__oxy/sso-callback` — a route NO app declares — and briefly flash its
1560
- // +not-found screen before the storage-gated cold-boot `sso-return` step gets
1561
- // a chance to strip the fragment and restore the real destination.
1562
- //
1563
- // This effect fires the SAME `runSsoReturn` kernel the instant we hydrate ON
1564
- // the callback path, BEFORE the cold boot (which awaits storage init). The
1565
- // first render intentionally matches the app/router's static HTML; the
1566
- // browser layout effect then hides the internal route and consumes the
1567
- // callback before the first visible paint. That keeps SSR/SSG hydration stable
1568
- // while still ensuring no app needs a `/__oxy/sso-callback` route.
1569
- //
1570
- // It is purely ADDITIVE. The later cold-boot `sso-return` step stays as
1571
- // defense-in-depth for the non-callback-path case; `consumeSsoReturn` strips
1572
- // the fragment first, so once this eager pass has run the cold-boot step is a
1573
- // harmless idempotent no-op (a second parse of the now-fragment-less URL
1574
- // returns `null`). The path guard scopes this strictly to the callback path,
1575
- // so a normal page load is untouched. Routed through `runSsoReturnRef` (the
1576
- // SAME ref the bfcache handler uses) so deps stay `[]` and it registers once.
1577
- //
1578
- // Timing: `handleWebSSOSessionRef.current` is assigned SYNCHRONOUSLY during
1579
- // render (see below, around the `handleWebSSOSession` declaration), and effects
1580
- // run only after the render commits, so on an `ok` outcome the commit path is
1581
- // already wired when this fires at eager-mount time. If for any reason it were
1582
- // not yet set, the later cold-boot `sso-return` step would commit it — but the
1583
- // ref IS set during render, so the eager `ok` commit works.
1584
- useBrowserLayoutEffect(() => {
1585
- if (!isOnSsoCallbackPath()) {
1586
- setSsoCallbackIntercepting(false);
1587
- return;
1588
- }
1589
- let mounted = true;
1590
- setSsoCallbackIntercepting(true);
1591
- runSsoReturnRef.current().catch(error => {
1592
- if (__DEV__) {
1593
- _core.logger.debug('Eager SSO callback interception failed (non-fatal)', {
1594
- component: 'OxyContext',
1595
- method: 'eagerSsoCallbackIntercept'
1596
- }, error);
1597
- }
1598
- }).finally(() => {
1599
- if (mounted) {
1600
- setSsoCallbackIntercepting(false);
1601
- }
1602
- });
1603
- return () => {
1604
- mounted = false;
1605
- };
1606
- }, []);
1607
-
1608
- // Web SSO: automatically check for cross-domain session on web platforms.
1609
- // Updates all state and persists session metadata.
1610
- const handleWebSSOSession = (0, _react.useCallback)(async session => {
1611
- if (!session?.user || !session?.sessionId) {
725
+ runColdBoot().catch(error => {
1612
726
  if (__DEV__) {
1613
- _core.logger.warn('handleWebSSOSession: Invalid session', {
1614
- component: 'OxyContext'
1615
- });
727
+ logger('Cold boot failed', error);
1616
728
  }
1617
- return;
1618
- }
1619
- if (!session.accessToken) {
1620
- throw new Error('Session response did not include an access token');
1621
- }
1622
- oxyServices.httpService.setTokens(session.accessToken);
1623
-
1624
- // A committed web session re-enables automatic silent restore: clear the
1625
- // durable "deliberately signed out" flag. This funnel is reached by deliberate
1626
- // sign-ins (password, `/sso` return) AND by the silent
1627
- // cold-boot steps — but those are GATED on the flag, so when it is set they
1628
- // never run and never reach here; clearing is therefore only hit on a genuine
1629
- // (re-)sign-in or when restore was already permitted, both correct.
1630
- (0, _activeAuthuser.clearSignedOut)();
1631
-
1632
- // Register this recovered account+session into the server-authoritative
1633
- // device-session set. Every web primary-session restore funnels through
1634
- // here — per-apex `/auth/silent` iframe,
1635
- // central `/sso` return, and keyless password sign-in — and each of them
1636
- // needs the resulting account added to the device's `DeviceSession` doc so
1637
- // the sign-in persists across reload / other tabs / devices and the
1638
- // realtime `device:<deviceId>` socket has an account to track.
1639
- // `sessionClient.addCurrentAccount()` (`POST /session/device/add`) derives
1640
- // identity from the bearer this function just planted above;
1641
- // `syncFromClient()` reprojects the resulting server state onto the
1642
- // exposed sessions/activeSessionId/user. Best-effort: a failure here must
1643
- // NEVER fail the sign-in itself — cold boot re-registers this account into
1644
- // the device set on the next load regardless. `registeredDuringBootRef` is
1645
- // flipped on success so the cold-boot post-ladder handoff (which reaches
1646
- // every step, including this one) does not redundantly re-register the
1647
- // SAME account with a second `POST /session/device/add`.
1648
- try {
1649
- await sessionClient.addCurrentAccount();
1650
- registeredDuringBootRef.current = true;
1651
- await syncFromClient();
1652
- } catch (registrationError) {
1653
- _core.logger.warn('handleWebSSOSession: failed to register session into device set', {
1654
- component: 'OxyContext',
1655
- method: 'handleWebSSOSession'
1656
- }, registrationError);
1657
- }
1658
- const clientSession = {
1659
- sessionId: session.sessionId,
1660
- deviceId: session.deviceId || '',
1661
- expiresAt: session.expiresAt || new Date(Date.now() + DEFAULT_SESSION_VALIDITY_MS).toISOString(),
1662
- lastActive: new Date().toISOString(),
1663
- userId: session.user.id?.toString() ?? '',
1664
- isCurrent: true
1665
- };
1666
- updateSessions([clientSession], {
1667
- merge: true
1668
729
  });
1669
- setActiveSessionId(session.sessionId);
1670
-
1671
- // Persist to storage BEFORE fetching the profile and BEFORE notifying the
1672
- // auth-state-change callback. The token is planted and the in-memory store
1673
- // is updated above; durably committing the session id here — ahead of
1674
- // `getCurrentUser()` / `loginSuccess` / `onAuthStateChange` — is critical
1675
- // because `onAuthStateChange` triggers a route navigation that can
1676
- // interrupt/supersede a still-pending async write. Persisting first
1677
- // guarantees the durable record lands; that is exactly what was missing
1678
- // when `oxy_session_session_ids` came back empty after a successful SSO
1679
- // sign-in (the user appeared logged in until reload, then had no session to
1680
- // restore). `persistSessionDurably` awaits the READY storage instance rather
1681
- // than reading the possibly-null `storage` state, so a sign-in fired the
1682
- // instant the screen mounts (before storage-init populates state) is not
1683
- // silently dropped.
1684
- await persistSessionDurably(session.sessionId);
1685
-
1686
- // Fetch the full user profile now that we have a valid access token and the
1687
- // session is durably persisted. The session only carries MinimalUserData;
1688
- // the store and callbacks expect a full User. The navigation kicked off by
1689
- // `onAuthStateChange` now happens only after the durable write is committed.
1690
- let fullUser;
1691
- try {
1692
- fullUser = await oxyServices.getCurrentUser();
1693
- } catch (profileError) {
1694
- if (__DEV__) {
1695
- _core.logger.debug('Failed to fetch full user after web session; using session user fallback', {
1696
- component: 'OxyContext',
1697
- method: 'handleWebSSOSession'
1698
- }, profileError);
1699
- }
1700
- // If the profile fetch fails, fall back to the minimal data from the session
1701
- // so the user is still logged in (the store accepts User, but the shapes overlap at runtime).
1702
- fullUser = session.user;
1703
- }
1704
- loginSuccess(fullUser);
1705
- // A session is now committed (per-apex iframe /
1706
- // SSO-return all funnel through here) — unblock the auth-resolution
1707
- // gate immediately, ahead of the cold-boot chain returning (idempotent).
1708
- markAuthResolvedRef.current();
1709
- onAuthStateChange?.(fullUser);
1710
- }, [oxyServices, updateSessions, setActiveSessionId, loginSuccess, onAuthStateChange, persistSessionDurably, sessionClient, syncFromClient]);
1711
-
1712
- // Expose `handleWebSSOSession` to the cold-boot iframe/SSO steps,
1713
- // which reference it through a ref because they are declared above this
1714
- // callback. Assigned synchronously on every render so the ref is populated
1715
- // before the cold-boot effect (gated on `storage`/`initialized`) can fire.
1716
- handleWebSSOSessionRef.current = handleWebSSOSession;
1717
-
1718
- // Keyless native sign-in: username/email + password. The slimmed Accounts app
1719
- // (which no longer holds a local identity key) uses this; it commits a
1720
- // successful session through the SAME `handleWebSSOSession` path SSO
1721
- // use, so state, durable persistence, profile fetch, and `markAuthResolved`
1722
- // all run identically. The API returns either a full session OR a two-factor
1723
- // handoff (`{ twoFactorRequired, loginToken }`) — we surface the latter as a
1724
- // discriminated result so the caller can run the 2FA challenge without any
1725
- // session being committed.
1726
- const signInWithPassword = (0, _react.useCallback)(async (identifier, password, opts) => {
1727
- // On a cross-apex web RP a direct password sign-in mints a bearer against
1728
- // the Oxy API but establishes no `fedcm_session`, so the session would be
1729
- // lost on reload. Refuse it and direct the app to the durable IdP popup
1730
- // ("Continue with Oxy"). Native and same-apex `*.oxy.so` are unaffected.
1731
- if ((0, _crossApex.isCrossApexWeb)()) {
1732
- throw new _crossApex.CrossApexDirectSignInError();
1733
- }
1734
- const response = await oxyServices.signIn(identifier, password, opts?.deviceName, opts?.deviceFingerprint);
1735
- // Core types `signIn` as `SessionLoginResponse`, but the API may instead
1736
- // return a 2FA challenge handoff. Widen with optional fields (no `any`) to
1737
- // read them — the added members are optional, so the base type is
1738
- // assignable to this intersection.
1739
- const maybeTwoFactor = response;
1740
- if (maybeTwoFactor.twoFactorRequired && maybeTwoFactor.loginToken) {
1741
- return {
1742
- status: '2fa_required',
1743
- loginToken: maybeTwoFactor.loginToken
1744
- };
1745
- }
1746
- // Full session — commit it through the shared web-session path.
1747
- await handleWebSSOSession(response);
1748
- return {
1749
- status: 'ok'
1750
- };
1751
- }, [oxyServices, handleWebSSOSession]);
1752
-
1753
- // Cross-domain silent SSO is owned ENTIRELY by the `silent-iframe` cold-boot
1754
- // step above (the ordered `runColdBoot` sequence) plus the terminal
1755
- // `sso-bounce` fallback. There is no post-boot safety net here: the FedCM
1756
- // silent post-boot retry that used to run via `useWebSSO` was removed along
1757
- // with the rest of the client FedCM surface (Chrome-only; see
1758
- // `CrossDomainAuth`'s doc comment in `@oxyhq/core` for the production
1759
- // incident that motivated the removal).
730
+ }, [runColdBoot, storage, initialized, logger]);
1760
731
 
1761
- // IdP session validation via lightweight iframe check
1762
- // When user returns to tab, verify auth.oxy.so still has their session
1763
- // If session is gone (cleared/logged out), clear local session too
1764
- //
1765
- // KEPT (session-sync cutover audit, Task 5): this is NOT redundant with the
1766
- // `SessionClient` socket (`device:<deviceId>`, `session_state` push). That
1767
- // socket only fires for revocations that flow through oxy-api's
1768
- // `DeviceSession` authority (`POST /session/device/signout` and friends). A
1769
- // sign-out at the CENTRAL IdP itself (`auth.oxy.so`'s own `AuthSession` /
1770
- // `fedcm_session` — e.g. a direct IdP-side sign-out, or an admin/security
1771
- // revocation there) does not currently touch `DeviceSession` at all, so no
1772
- // socket push follows — this iframe poll is the ONLY mechanism that catches
1773
- // it today. A future phase should move this server-side (IdP deactivation
1774
- // -> `DeviceSession` update -> broadcast over the same socket), at which
1775
- // point this client-side poll becomes redundant and can be deleted.
1776
- const lastIdPCheckRef = (0, _react.useRef)(0);
1777
- const pendingIdPCleanupRef = (0, _react.useRef)(null);
1778
-
1779
- // Use the RESOLVED IdP origin (the auto-detected `auth.<rp-apex>` planted on
1780
- // the instance config), not the raw `authWebUrl` prop — on a cross-domain RP
1781
- // the prop is undefined but the instance was constructed with the detected
1782
- // value, so the check must target the same first-party IdP the cold-boot
1783
- // iframe used.
1784
- const resolvedAuthWebUrl = oxyServices.config?.authWebUrl;
1785
- (0, _react.useEffect)(() => {
1786
- if (!(0, _useWebSSO.isWebBrowser)() || !user || !initialized) return;
1787
- const idpOrigin = resolvedAuthWebUrl || 'https://auth.oxy.so';
1788
- const checkIdPSession = () => {
1789
- // Debounce: check at most once per cooldown window
1790
- const now = Date.now();
1791
- if (now - lastIdPCheckRef.current < IDP_SESSION_CHECK_COOLDOWN) return;
1792
- lastIdPCheckRef.current = now;
1793
-
1794
- // Clean up any in-flight check before starting a new one
1795
- pendingIdPCleanupRef.current?.();
1796
-
1797
- // Load hidden iframe to check IdP session via postMessage
1798
- const iframe = document.createElement('iframe');
1799
- iframe.style.cssText = 'display:none;width:0;height:0;border:0';
1800
- iframe.src = `${idpOrigin}/auth/session-check?client_id=${encodeURIComponent(window.location.origin)}`;
1801
- let cleaned = false;
1802
- const cleanup = () => {
1803
- if (cleaned) return;
1804
- cleaned = true;
1805
- window.removeEventListener('message', handleMessage);
1806
- iframe.remove();
1807
- };
1808
- const handleMessage = async event => {
1809
- if (event.origin !== idpOrigin) return;
1810
- if (event.data?.type !== 'oxy-session-check') return;
1811
- cleanup();
1812
- if (!event.data.hasSession) {
1813
- // Only a SAME-SITE, first-party IdP answer is authoritative enough to
1814
- // force a local sign-out. On a cross-site / undetermined IdP the
1815
- // "no session" answer must never clear local state (a third-party
1816
- // can't be trusted to end this app's session). Surface the toast in
1817
- // both cases, but gate the destructive `clearSessionState()`.
1818
- if (isSameSiteIdP(idpOrigin)) {
1819
- _bloom.toast.info('Your session has ended. Please sign in again.');
1820
- await clearSessionState();
1821
- }
1822
- }
1823
- };
1824
- window.addEventListener('message', handleMessage);
1825
- document.body.appendChild(iframe);
1826
- setTimeout(cleanup, IDP_SESSION_CHECK_TIMEOUT);
1827
- pendingIdPCleanupRef.current = cleanup;
1828
- };
1829
- const handleVisibilityChange = () => {
1830
- if (document.visibilityState === 'visible') {
1831
- checkIdPSession();
1832
- }
1833
- };
1834
- document.addEventListener('visibilitychange', handleVisibilityChange);
1835
- return () => {
1836
- document.removeEventListener('visibilitychange', handleVisibilityChange);
1837
- pendingIdPCleanupRef.current?.();
1838
- pendingIdPCleanupRef.current = null;
1839
- };
1840
- }, [user, initialized, clearSessionState, resolvedAuthWebUrl]);
1841
-
1842
- // Exposed `refreshSessions`: re-bootstraps the server-authoritative device
1843
- // state (`GET /session/device/state`, the SAME request `SessionClient.start()`
1844
- // makes) and reprojects it via `syncFromClient` — a manual pull-to-refresh
1845
- // counterpart to the realtime `device:<deviceId>` socket `client.start()`
1846
- // already owns (there is no per-domain `useSessionSocket` anymore; a REMOTE
1847
- // sign-out or account change arrives over that socket and is handled by
1848
- // `syncFromClient`'s subscription below, including the full-sign-out gap fix
1849
- // documented above it).
732
+ // Exposed `refreshSessions`: re-bootstrap the server-authoritative device
733
+ // state and reproject the manual counterpart to the realtime socket.
1850
734
  const refreshSessionsForContext = (0, _react.useCallback)(async () => {
1851
735
  await sessionClient.bootstrap();
1852
736
  await syncFromClient();
1853
737
  }, [sessionClient, syncFromClient]);
1854
738
 
1855
- // Exposed `switchSession`: routes through the server-authoritative
1856
- // `SessionClient` (Fase 3-B) rather than `useSessionManagement`'s own
1857
- // `switchSession` (which remains, unchanged, for `useAuthOperations`'
1858
- // internal same-user duplicate-session dedup — a different, legacy
1859
- // session-validate concept unrelated to switching the device's ACTIVE
1860
- // account). Resolves the target account from the current device state,
1861
- // asks the server to switch, reprojects, and returns the now-active user.
739
+ // Exposed `switchSession`: route through the server-authoritative
740
+ // `SessionClient`. Resolve the target account from the current device state,
741
+ // ask the server to switch, reproject, and return the now-active user.
1862
742
  const switchSessionForContext = (0, _react.useCallback)(async sessionId => {
1863
743
  const targetAccountId = sessionClient.getState()?.accounts.find(account => account.sessionId === sessionId)?.accountId;
1864
744
  if (!targetAccountId) {
@@ -1872,21 +752,11 @@ const OxyProvider = ({
1872
752
  }
1873
753
  return activeUser;
1874
754
  }, [sessionClient, syncFromClient]);
1875
-
1876
- // Identity management wrappers (delegate to KeyManager)
1877
- const hasIdentity = (0, _react.useCallback)(async () => {
1878
- return _core.KeyManager.hasIdentity();
1879
- }, []);
1880
- const getPublicKey = (0, _react.useCallback)(async () => {
1881
- return _core.KeyManager.getPublicKey();
1882
- }, []);
1883
-
1884
- // Create showBottomSheet function that uses the global function
755
+ const hasIdentity = (0, _react.useCallback)(async () => _core.KeyManager.hasIdentity(), []);
756
+ const getPublicKey = (0, _react.useCallback)(async () => _core.KeyManager.getPublicKey(), []);
1885
757
  const showBottomSheetForContext = (0, _react.useCallback)(screenOrConfig => {
1886
758
  (0, _bottomSheetManager.showBottomSheet)(screenOrConfig);
1887
759
  }, []);
1888
-
1889
- // Avatar picker extracted into dedicated hook
1890
760
  const {
1891
761
  openAvatarPicker
1892
762
  } = (0, _useAvatarPicker.useAvatarPicker)({
@@ -1897,10 +767,8 @@ const OxyProvider = ({
1897
767
  showBottomSheet: showBottomSheetForContext
1898
768
  });
1899
769
 
1900
- // --- Account graph state ---
770
+ // ── Account graph ──────────────────────────────────────────────────────────
1901
771
  const [accounts, setAccounts] = (0, _react.useState)([]);
1902
-
1903
- // Load the unified account graph when authenticated
1904
772
  const refreshAccounts = (0, _react.useCallback)(async () => {
1905
773
  if (!isAuthenticated || !tokenReady || !oxyServices.getAccessToken()) {
1906
774
  setAccounts([]);
@@ -1927,34 +795,13 @@ const OxyProvider = ({
1927
795
  refreshAccounts();
1928
796
  }
1929
797
  }, [isAuthenticated, initialized, tokenReady, refreshAccounts]);
1930
-
1931
- // Shared post-switch side effects, run identically regardless of which
1932
- // `switchToAccount` branch handled the switch below: reload the switchable
1933
- // account graph (the new active account's relationships differ) and
1934
- // invalidate every query so all data refetches as the new account.
1935
798
  const runPostAccountSwitchSideEffects = (0, _react.useCallback)(async () => {
1936
799
  await refreshAccounts();
1937
800
  queryClient.invalidateQueries();
1938
801
  }, [refreshAccounts, queryClient]);
1939
802
 
1940
803
  // Switch the active session INTO an account from the unified graph. In the
1941
- // REAL-SESSION model this is identical to switching device sign-ins: the
1942
- // whole app becomes that account. There is exactly ONE uniform path for
1943
- // that — the device's server-authoritative `SessionClient.switchAccount()`
1944
- // — used for EVERY switch, org/managed accounts included:
1945
- //
1946
- // - If `accountId` is ALREADY registered on this device's multi-account
1947
- // set (a prior `switchToAccount`/sign-in already added it), switch
1948
- // straight through `sessionClient.switchAccount()` — the SAME path
1949
- // `switchSession` uses for ordinary device sessions. No new session is
1950
- // minted, so no session churn / deactivation of the account's existing
1951
- // session (H4).
1952
- // - Only the FIRST time an account is switched into does it need minting:
1953
- // `oxyServices.switchToAccount` mints+plants a real session, then
1954
- // `sessionClient.addCurrentAccount()` registers it into the device set
1955
- // (server-set httpOnly `oxy_rt_<authuser>` cookie, so the session joins
1956
- // the device multi-account set and survives reload / `refresh-all`).
1957
- // Every subsequent switch into that same account takes the branch above.
804
+ // real-session model this is identical to switching device sign-ins.
1958
805
  const switchToAccount = (0, _react.useCallback)(async accountId => {
1959
806
  const deviceState = sessionClient.getState();
1960
807
  if (deviceState?.accounts.some(account => account.accountId === accountId)) {
@@ -1967,72 +814,22 @@ const OxyProvider = ({
1967
814
  if (!result?.user || !result?.sessionId) {
1968
815
  throw new Error('Account switch did not return a valid session');
1969
816
  }
1970
-
1971
- // A switch is a deliberate sign-in into an account: re-enable automatic silent
1972
- // restore by clearing any prior "deliberately signed out" flag.
1973
- (0, _activeAuthuser.clearSignedOut)();
1974
-
1975
- // `oxyServices.switchToAccount` already planted `result.accessToken` as the
1976
- // active token; mirror the minted session into the multi-account store and
1977
- // mark it current. The device account SET (and its `authuser` slot
1978
- // numbering) is server-authoritative via `SessionClient` — the
1979
- // `addCurrentAccount` + `syncFromClient` reprojection below supersedes this
1980
- // local mirror with the server's own state.
1981
- const now = new Date();
1982
- const clientSession = {
817
+ // `oxyServices.switchToAccount` already planted the access token; commit
818
+ // the minted session through the shared funnel (persist + register +
819
+ // hydrate) as a deliberate activation.
820
+ await commitSession({
1983
821
  sessionId: result.sessionId,
1984
- deviceId: result.deviceId || '',
1985
- expiresAt: result.expiresAt || new Date(now.getTime() + DEFAULT_SESSION_VALIDITY_MS).toISOString(),
1986
- lastActive: now.toISOString(),
1987
- userId: result.user.id?.toString() ?? '',
1988
- isCurrent: true
1989
- };
1990
- updateSessions([clientSession], {
1991
- merge: true
822
+ accessToken: result.accessToken,
823
+ refreshToken: result.refreshToken,
824
+ deviceId: result.deviceId,
825
+ expiresAt: result.expiresAt,
826
+ userId: result.user.id,
827
+ user: result.user
828
+ }, {
829
+ activate: true
1992
830
  });
1993
- setActiveSessionId(result.sessionId);
1994
- await persistSessionDurably(result.sessionId);
1995
-
1996
- // Register the switched-to account into the device's server-authoritative
1997
- // multi-account set (the SAME `SessionClient.addCurrentAccount()` the
1998
- // cold-boot handoff uses — it derives identity from the bearer, which
1999
- // `oxyServices.switchToAccount` already planted above) and reproject
2000
- // state, so the switch persists across reload / other tabs / devices
2001
- // instead of only living in this provider's local session-management
2002
- // state. Best-effort: the token for the new account is already planted, so
2003
- // a transient failure here must NEVER reject the switch — fall through to
2004
- // `loginSuccess`/`onAuthStateChange` so the UI reflects the switched-to
2005
- // account (cold boot re-registers into the device set on next load).
2006
- // Mirrors the same guard in `handleWebSSOSession`.
2007
- try {
2008
- await sessionClient.addCurrentAccount();
2009
- await syncFromClient();
2010
- } catch (registrationError) {
2011
- _core.logger.warn('switchToAccount: failed to register account into device set', {
2012
- component: 'OxyContext',
2013
- method: 'switchToAccount'
2014
- }, registrationError);
2015
- }
2016
-
2017
- // Fetch the canonical User for the new account (the switch result carries
2018
- // only MinimalUserData); fall back to that minimal shape if the profile
2019
- // fetch fails so the app still reflects the switched identity.
2020
- let fullUser;
2021
- try {
2022
- fullUser = await oxyServices.getCurrentUser();
2023
- } catch (profileError) {
2024
- if (__DEV__) {
2025
- _core.logger.debug('Failed to fetch full user after account switch; using switch result user', {
2026
- component: 'OxyContext',
2027
- method: 'switchToAccount'
2028
- }, profileError);
2029
- }
2030
- fullUser = result.user;
2031
- }
2032
- loginSuccess(fullUser);
2033
- onAuthStateChange?.(fullUser);
2034
831
  await runPostAccountSwitchSideEffects();
2035
- }, [oxyServices, updateSessions, setActiveSessionId, persistSessionDurably, sessionClient, syncFromClient, loginSuccess, onAuthStateChange, runPostAccountSwitchSideEffects]);
832
+ }, [oxyServices, sessionClient, syncFromClient, commitSession, runPostAccountSwitchSideEffects]);
2036
833
  const createAccountFn = (0, _react.useCallback)(async data => {
2037
834
  const account = await oxyServices.createAccount(data);
2038
835
  await refreshAccounts();
@@ -2061,7 +858,8 @@ const OxyProvider = ({
2061
858
  getPublicKey,
2062
859
  signIn,
2063
860
  signInWithPassword,
2064
- handleWebSession: handleWebSSOSession,
861
+ completeTwoFactorSignIn,
862
+ handleWebSession,
2065
863
  logout,
2066
864
  logoutAll,
2067
865
  switchSession: switchSessionForContext,
@@ -2083,10 +881,10 @@ const OxyProvider = ({
2083
881
  switchToAccount,
2084
882
  refreshAccounts,
2085
883
  createAccount: createAccountFn
2086
- }), [activeSessionId, signIn, signInWithPassword, handleWebSSOSession, currentLanguage, currentLanguageMetadata, currentLanguageName, currentNativeLanguageName, error, getDeviceSessions, hasAccessToken, canUsePrivateApi, isPrivateApiPending, getPublicKey, hasIdentity, isAuthenticated, isLoading, logout, logoutAll, logoutAllDeviceSessions, oxyServices, storageKeyPrefix, clientId, refreshSessionsForContext, sessions, setLanguage, storage, switchSessionForContext, tokenReady, hasAccessToken, canUsePrivateApi, isPrivateApiPending, authResolved, updateDeviceName, clearAllAccountData, useFollowHook, user, showBottomSheetForContext, openAvatarPicker, accounts, switchToAccount, refreshAccounts, createAccountFn]);
884
+ }), [user, sessions, activeSessionId, isAuthenticated, isLoading, tokenReady, hasAccessToken, canUsePrivateApi, isPrivateApiPending, authResolved, storage, error, currentLanguage, currentLanguageMetadata, currentLanguageName, currentNativeLanguageName, hasIdentity, getPublicKey, signIn, signInWithPassword, completeTwoFactorSignIn, handleWebSession, logout, logoutAll, switchSessionForContext, refreshSessionsForContext, setLanguage, getDeviceSessions, logoutAllDeviceSessions, updateDeviceName, clearSessionState, clearAllAccountData, storageKeyPrefix, clientId, oxyServices, useFollowHook, showBottomSheetForContext, openAvatarPicker, accounts, switchToAccount, refreshAccounts, createAccountFn]);
2087
885
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(OxyContext.Provider, {
2088
886
  value: contextValue,
2089
- children: ssoCallbackIntercepting ? null : children
887
+ children: children
2090
888
  });
2091
889
  };
2092
890
  exports.OxyProvider = OxyProvider;
@@ -2094,15 +892,10 @@ const OxyContextProvider = exports.OxyContextProvider = OxyProvider;
2094
892
 
2095
893
  /**
2096
894
  * Loading-state stub used when `useOxy()` is called outside an OxyProvider.
2097
- * All async methods reject with a clear error so misuse is caught early
2098
- * instead of silently no-oping and leaving the UI in a bad state.
895
+ * All async methods reject with a clear error so misuse is caught early.
2099
896
  */
2100
897
  const PROVIDER_MISSING_ERROR_MESSAGE = 'OxyProvider is not mounted. Wrap your app in <OxyProvider> before calling useOxy() methods.';
2101
898
  const rejectMissingProvider = () => Promise.reject(new Error(PROVIDER_MISSING_ERROR_MESSAGE));
2102
-
2103
- // A stub OxyServices instance so the public type contract is preserved.
2104
- // Calling network methods on it before a provider mounts will fail with
2105
- // a descriptive baseURL — preferable to a null-pointer crash at the call site.
2106
899
  const LOADING_STATE_OXY_SERVICES = new _core.OxyServices({
2107
900
  baseURL: 'about:blank'
2108
901
  });
@@ -2127,6 +920,7 @@ const LOADING_STATE = {
2127
920
  getPublicKey: () => Promise.resolve(null),
2128
921
  signIn: () => rejectMissingProvider(),
2129
922
  signInWithPassword: () => rejectMissingProvider(),
923
+ completeTwoFactorSignIn: () => rejectMissingProvider(),
2130
924
  handleWebSession: () => rejectMissingProvider(),
2131
925
  logout: () => rejectMissingProvider(),
2132
926
  logoutAll: () => rejectMissingProvider(),
@@ -2156,5 +950,4 @@ const useOxy = () => {
2156
950
  return context;
2157
951
  };
2158
952
  exports.useOxy = useOxy;
2159
- var _default = exports.default = OxyContext;
2160
953
  //# sourceMappingURL=OxyContext.js.map