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