@oxyhq/services 14.0.1 → 15.0.0

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