@oxyhq/services 13.3.0 → 14.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 (251) hide show
  1. package/lib/commonjs/index.js +11 -17
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/AccountMenu.js +103 -65
  4. package/lib/commonjs/ui/components/AccountMenu.js.map +1 -1
  5. package/lib/commonjs/ui/components/AccountSwitcher.js +84 -126
  6. package/lib/commonjs/ui/components/AccountSwitcher.js.map +1 -1
  7. package/lib/commonjs/ui/components/ProfileMenu.js +140 -153
  8. package/lib/commonjs/ui/components/ProfileMenu.js.map +1 -1
  9. package/lib/commonjs/ui/components/SignInModal.js +4 -4
  10. package/lib/commonjs/ui/components/SignInModal.js.map +1 -1
  11. package/lib/commonjs/ui/context/OxyContext.js +506 -466
  12. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  13. package/lib/commonjs/ui/context/hooks/useAuthOperations.js +59 -57
  14. package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
  15. package/lib/commonjs/ui/context/inSessionTokenRefresh.js +23 -42
  16. package/lib/commonjs/ui/context/inSessionTokenRefresh.js.map +1 -1
  17. package/lib/commonjs/ui/context/silentSessionRestore.js +12 -21
  18. package/lib/commonjs/ui/context/silentSessionRestore.js.map +1 -1
  19. package/lib/commonjs/ui/hooks/useAuth.js +4 -3
  20. package/lib/commonjs/ui/hooks/useAuth.js.map +1 -1
  21. package/lib/commonjs/ui/hooks/useOxyAuthSession.js +12 -7
  22. package/lib/commonjs/ui/hooks/useOxyAuthSession.js.map +1 -1
  23. package/lib/commonjs/ui/hooks/useSessionManagement.js +17 -65
  24. package/lib/commonjs/ui/hooks/useSessionManagement.js.map +1 -1
  25. package/lib/commonjs/ui/hooks/useSwitchableAccounts.js +288 -0
  26. package/lib/commonjs/ui/hooks/useSwitchableAccounts.js.map +1 -0
  27. package/lib/commonjs/ui/hooks/useWebSSO.js +12 -191
  28. package/lib/commonjs/ui/hooks/useWebSSO.js.map +1 -1
  29. package/lib/commonjs/ui/index.js.map +1 -1
  30. package/lib/commonjs/ui/screens/OxyAuthScreen.js +2 -2
  31. package/lib/commonjs/ui/screens/OxyAuthScreen.js.map +1 -1
  32. package/lib/commonjs/ui/session/createSessionClient.js +28 -0
  33. package/lib/commonjs/ui/session/createSessionClient.js.map +1 -0
  34. package/lib/commonjs/ui/session/index.js +52 -0
  35. package/lib/commonjs/ui/session/index.js.map +1 -0
  36. package/lib/commonjs/ui/session/projectSessionState.js +86 -0
  37. package/lib/commonjs/ui/session/projectSessionState.js.map +1 -0
  38. package/lib/commonjs/ui/session/sessionClientHost.js +30 -0
  39. package/lib/commonjs/ui/session/sessionClientHost.js.map +1 -0
  40. package/lib/commonjs/ui/session/tokenTransport.js +77 -0
  41. package/lib/commonjs/ui/session/tokenTransport.js.map +1 -0
  42. package/lib/commonjs/ui/utils/activeAuthuser.js +35 -79
  43. package/lib/commonjs/ui/utils/activeAuthuser.js.map +1 -1
  44. package/lib/commonjs/ui/utils/sessionHelpers.js +1 -51
  45. package/lib/commonjs/ui/utils/sessionHelpers.js.map +1 -1
  46. package/lib/commonjs/utils/deviceFlowSignIn.js +70 -15
  47. package/lib/commonjs/utils/deviceFlowSignIn.js.map +1 -1
  48. package/lib/module/index.js +5 -5
  49. package/lib/module/index.js.map +1 -1
  50. package/lib/module/ui/components/AccountMenu.js +103 -65
  51. package/lib/module/ui/components/AccountMenu.js.map +1 -1
  52. package/lib/module/ui/components/AccountSwitcher.js +85 -127
  53. package/lib/module/ui/components/AccountSwitcher.js.map +1 -1
  54. package/lib/module/ui/components/ProfileMenu.js +143 -156
  55. package/lib/module/ui/components/ProfileMenu.js.map +1 -1
  56. package/lib/module/ui/components/SignInModal.js +4 -4
  57. package/lib/module/ui/components/SignInModal.js.map +1 -1
  58. package/lib/module/ui/context/OxyContext.js +510 -470
  59. package/lib/module/ui/context/OxyContext.js.map +1 -1
  60. package/lib/module/ui/context/hooks/useAuthOperations.js +61 -60
  61. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  62. package/lib/module/ui/context/inSessionTokenRefresh.js +24 -43
  63. package/lib/module/ui/context/inSessionTokenRefresh.js.map +1 -1
  64. package/lib/module/ui/context/silentSessionRestore.js +12 -20
  65. package/lib/module/ui/context/silentSessionRestore.js.map +1 -1
  66. package/lib/module/ui/hooks/useAuth.js +4 -3
  67. package/lib/module/ui/hooks/useAuth.js.map +1 -1
  68. package/lib/module/ui/hooks/useOxyAuthSession.js +12 -7
  69. package/lib/module/ui/hooks/useOxyAuthSession.js.map +1 -1
  70. package/lib/module/ui/hooks/useSessionManagement.js +18 -66
  71. package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
  72. package/lib/module/ui/hooks/useSwitchableAccounts.js +283 -0
  73. package/lib/module/ui/hooks/useSwitchableAccounts.js.map +1 -0
  74. package/lib/module/ui/hooks/useWebSSO.js +13 -192
  75. package/lib/module/ui/hooks/useWebSSO.js.map +1 -1
  76. package/lib/module/ui/index.js +0 -1
  77. package/lib/module/ui/index.js.map +1 -1
  78. package/lib/module/ui/screens/OxyAuthScreen.js +2 -2
  79. package/lib/module/ui/screens/OxyAuthScreen.js.map +1 -1
  80. package/lib/module/ui/session/createSessionClient.js +25 -0
  81. package/lib/module/ui/session/createSessionClient.js.map +1 -0
  82. package/lib/module/ui/session/index.js +17 -0
  83. package/lib/module/ui/session/index.js.map +1 -0
  84. package/lib/module/ui/session/projectSessionState.js +79 -0
  85. package/lib/module/ui/session/projectSessionState.js.map +1 -0
  86. package/lib/module/ui/session/sessionClientHost.js +26 -0
  87. package/lib/module/ui/session/sessionClientHost.js.map +1 -0
  88. package/lib/module/ui/session/tokenTransport.js +74 -0
  89. package/lib/module/ui/session/tokenTransport.js.map +1 -0
  90. package/lib/module/ui/utils/activeAuthuser.js +34 -76
  91. package/lib/module/ui/utils/activeAuthuser.js.map +1 -1
  92. package/lib/module/ui/utils/sessionHelpers.js +0 -49
  93. package/lib/module/ui/utils/sessionHelpers.js.map +1 -1
  94. package/lib/module/utils/deviceFlowSignIn.js +71 -16
  95. package/lib/module/utils/deviceFlowSignIn.js.map +1 -1
  96. package/lib/typescript/commonjs/index.d.ts +2 -3
  97. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  98. package/lib/typescript/commonjs/ui/components/AccountMenu.d.ts +4 -2
  99. package/lib/typescript/commonjs/ui/components/AccountMenu.d.ts.map +1 -1
  100. package/lib/typescript/commonjs/ui/components/AccountSwitcher.d.ts +7 -8
  101. package/lib/typescript/commonjs/ui/components/AccountSwitcher.d.ts.map +1 -1
  102. package/lib/typescript/commonjs/ui/components/ProfileMenu.d.ts +12 -9
  103. package/lib/typescript/commonjs/ui/components/ProfileMenu.d.ts.map +1 -1
  104. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +16 -13
  105. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  106. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts +27 -3
  107. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts +22 -17
  109. package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts.map +1 -1
  110. package/lib/typescript/commonjs/ui/context/silentSessionRestore.d.ts +12 -18
  111. package/lib/typescript/commonjs/ui/context/silentSessionRestore.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +1 -0
  113. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/ui/hooks/useAuth.d.ts +1 -1
  115. package/lib/typescript/commonjs/ui/hooks/useAuth.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/ui/hooks/useOxyAuthSession.d.ts +2 -2
  117. package/lib/typescript/commonjs/ui/hooks/useOxyAuthSession.d.ts.map +1 -1
  118. package/lib/typescript/commonjs/ui/hooks/useSessionManagement.d.ts.map +1 -1
  119. package/lib/typescript/commonjs/ui/hooks/useSwitchableAccounts.d.ts +162 -0
  120. package/lib/typescript/commonjs/ui/hooks/useSwitchableAccounts.d.ts.map +1 -0
  121. package/lib/typescript/commonjs/ui/hooks/useWebSSO.d.ts +13 -52
  122. package/lib/typescript/commonjs/ui/hooks/useWebSSO.d.ts.map +1 -1
  123. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  124. package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts +15 -0
  125. package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts.map +1 -0
  126. package/lib/typescript/commonjs/ui/session/index.d.ts +15 -0
  127. package/lib/typescript/commonjs/ui/session/index.d.ts.map +1 -0
  128. package/lib/typescript/commonjs/ui/session/projectSessionState.d.ts +45 -0
  129. package/lib/typescript/commonjs/ui/session/projectSessionState.d.ts.map +1 -0
  130. package/lib/typescript/commonjs/ui/session/sessionClientHost.d.ts +14 -0
  131. package/lib/typescript/commonjs/ui/session/sessionClientHost.d.ts.map +1 -0
  132. package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts +24 -0
  133. package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts.map +1 -0
  134. package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts +21 -29
  135. package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts.map +1 -1
  136. package/lib/typescript/commonjs/ui/utils/sessionHelpers.d.ts +0 -19
  137. package/lib/typescript/commonjs/ui/utils/sessionHelpers.d.ts.map +1 -1
  138. package/lib/typescript/commonjs/utils/deviceFlowSignIn.d.ts +51 -19
  139. package/lib/typescript/commonjs/utils/deviceFlowSignIn.d.ts.map +1 -1
  140. package/lib/typescript/module/index.d.ts +2 -3
  141. package/lib/typescript/module/index.d.ts.map +1 -1
  142. package/lib/typescript/module/ui/components/AccountMenu.d.ts +4 -2
  143. package/lib/typescript/module/ui/components/AccountMenu.d.ts.map +1 -1
  144. package/lib/typescript/module/ui/components/AccountSwitcher.d.ts +7 -8
  145. package/lib/typescript/module/ui/components/AccountSwitcher.d.ts.map +1 -1
  146. package/lib/typescript/module/ui/components/ProfileMenu.d.ts +12 -9
  147. package/lib/typescript/module/ui/components/ProfileMenu.d.ts.map +1 -1
  148. package/lib/typescript/module/ui/context/OxyContext.d.ts +16 -13
  149. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  150. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts +27 -3
  151. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  152. package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts +22 -17
  153. package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts.map +1 -1
  154. package/lib/typescript/module/ui/context/silentSessionRestore.d.ts +12 -18
  155. package/lib/typescript/module/ui/context/silentSessionRestore.d.ts.map +1 -1
  156. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +1 -0
  157. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  158. package/lib/typescript/module/ui/hooks/useAuth.d.ts +1 -1
  159. package/lib/typescript/module/ui/hooks/useAuth.d.ts.map +1 -1
  160. package/lib/typescript/module/ui/hooks/useOxyAuthSession.d.ts +2 -2
  161. package/lib/typescript/module/ui/hooks/useOxyAuthSession.d.ts.map +1 -1
  162. package/lib/typescript/module/ui/hooks/useSessionManagement.d.ts.map +1 -1
  163. package/lib/typescript/module/ui/hooks/useSwitchableAccounts.d.ts +162 -0
  164. package/lib/typescript/module/ui/hooks/useSwitchableAccounts.d.ts.map +1 -0
  165. package/lib/typescript/module/ui/hooks/useWebSSO.d.ts +13 -52
  166. package/lib/typescript/module/ui/hooks/useWebSSO.d.ts.map +1 -1
  167. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  168. package/lib/typescript/module/ui/session/createSessionClient.d.ts +15 -0
  169. package/lib/typescript/module/ui/session/createSessionClient.d.ts.map +1 -0
  170. package/lib/typescript/module/ui/session/index.d.ts +15 -0
  171. package/lib/typescript/module/ui/session/index.d.ts.map +1 -0
  172. package/lib/typescript/module/ui/session/projectSessionState.d.ts +45 -0
  173. package/lib/typescript/module/ui/session/projectSessionState.d.ts.map +1 -0
  174. package/lib/typescript/module/ui/session/sessionClientHost.d.ts +14 -0
  175. package/lib/typescript/module/ui/session/sessionClientHost.d.ts.map +1 -0
  176. package/lib/typescript/module/ui/session/tokenTransport.d.ts +24 -0
  177. package/lib/typescript/module/ui/session/tokenTransport.d.ts.map +1 -0
  178. package/lib/typescript/module/ui/utils/activeAuthuser.d.ts +21 -29
  179. package/lib/typescript/module/ui/utils/activeAuthuser.d.ts.map +1 -1
  180. package/lib/typescript/module/ui/utils/sessionHelpers.d.ts +0 -19
  181. package/lib/typescript/module/ui/utils/sessionHelpers.d.ts.map +1 -1
  182. package/lib/typescript/module/utils/deviceFlowSignIn.d.ts +51 -19
  183. package/lib/typescript/module/utils/deviceFlowSignIn.d.ts.map +1 -1
  184. package/package.json +4 -4
  185. package/src/index.ts +9 -9
  186. package/src/ui/components/AccountMenu.tsx +117 -70
  187. package/src/ui/components/AccountSwitcher.tsx +88 -134
  188. package/src/ui/components/ProfileMenu.tsx +158 -162
  189. package/src/ui/components/SignInModal.tsx +5 -5
  190. package/src/ui/context/OxyContext.tsx +560 -475
  191. package/src/ui/context/hooks/useAuthOperations.ts +72 -63
  192. package/src/ui/context/inSessionTokenRefresh.ts +23 -42
  193. package/src/ui/context/silentSessionRestore.ts +12 -26
  194. package/src/ui/hooks/useAuth.ts +4 -3
  195. package/src/ui/hooks/useOxyAuthSession.ts +13 -8
  196. package/src/ui/hooks/useSessionManagement.ts +16 -87
  197. package/src/ui/hooks/useSwitchableAccounts.ts +397 -0
  198. package/src/ui/hooks/useWebSSO.ts +13 -227
  199. package/src/ui/index.ts +0 -1
  200. package/src/ui/screens/OxyAuthScreen.tsx +2 -2
  201. package/src/ui/session/__tests__/createSessionClient.test.ts +41 -0
  202. package/src/ui/session/__tests__/projectSessionState.test.ts +114 -0
  203. package/src/ui/session/__tests__/sessionClientHost.test.ts +43 -0
  204. package/src/ui/session/__tests__/tokenTransport.test.ts +142 -0
  205. package/src/ui/session/createSessionClient.ts +21 -0
  206. package/src/ui/session/index.ts +19 -0
  207. package/src/ui/session/projectSessionState.ts +85 -0
  208. package/src/ui/session/sessionClientHost.ts +27 -0
  209. package/src/ui/session/tokenTransport.ts +76 -0
  210. package/src/ui/utils/activeAuthuser.ts +34 -76
  211. package/src/ui/utils/sessionHelpers.ts +0 -66
  212. package/src/utils/__tests__/deviceFlowSignIn.test.ts +243 -34
  213. package/src/utils/deviceFlowSignIn.ts +98 -21
  214. package/lib/commonjs/ui/components/accountMenuRows.js +0 -33
  215. package/lib/commonjs/ui/components/accountMenuRows.js.map +0 -1
  216. package/lib/commonjs/ui/hooks/useDeviceAccounts.js +0 -280
  217. package/lib/commonjs/ui/hooks/useDeviceAccounts.js.map +0 -1
  218. package/lib/commonjs/ui/hooks/useSessionSocket.js +0 -203
  219. package/lib/commonjs/ui/hooks/useSessionSocket.js.map +0 -1
  220. package/lib/commonjs/utils/silentGuardKey.js +0 -54
  221. package/lib/commonjs/utils/silentGuardKey.js.map +0 -1
  222. package/lib/module/ui/components/accountMenuRows.js +0 -29
  223. package/lib/module/ui/components/accountMenuRows.js.map +0 -1
  224. package/lib/module/ui/hooks/useDeviceAccounts.js +0 -276
  225. package/lib/module/ui/hooks/useDeviceAccounts.js.map +0 -1
  226. package/lib/module/ui/hooks/useSessionSocket.js +0 -199
  227. package/lib/module/ui/hooks/useSessionSocket.js.map +0 -1
  228. package/lib/module/utils/silentGuardKey.js +0 -49
  229. package/lib/module/utils/silentGuardKey.js.map +0 -1
  230. package/lib/typescript/commonjs/ui/components/accountMenuRows.d.ts +0 -34
  231. package/lib/typescript/commonjs/ui/components/accountMenuRows.d.ts.map +0 -1
  232. package/lib/typescript/commonjs/ui/hooks/useDeviceAccounts.d.ts +0 -133
  233. package/lib/typescript/commonjs/ui/hooks/useDeviceAccounts.d.ts.map +0 -1
  234. package/lib/typescript/commonjs/ui/hooks/useSessionSocket.d.ts +0 -14
  235. package/lib/typescript/commonjs/ui/hooks/useSessionSocket.d.ts.map +0 -1
  236. package/lib/typescript/commonjs/utils/silentGuardKey.d.ts +0 -31
  237. package/lib/typescript/commonjs/utils/silentGuardKey.d.ts.map +0 -1
  238. package/lib/typescript/module/ui/components/accountMenuRows.d.ts +0 -34
  239. package/lib/typescript/module/ui/components/accountMenuRows.d.ts.map +0 -1
  240. package/lib/typescript/module/ui/hooks/useDeviceAccounts.d.ts +0 -133
  241. package/lib/typescript/module/ui/hooks/useDeviceAccounts.d.ts.map +0 -1
  242. package/lib/typescript/module/ui/hooks/useSessionSocket.d.ts +0 -14
  243. package/lib/typescript/module/ui/hooks/useSessionSocket.d.ts.map +0 -1
  244. package/lib/typescript/module/utils/silentGuardKey.d.ts +0 -31
  245. package/lib/typescript/module/utils/silentGuardKey.d.ts.map +0 -1
  246. package/src/ui/components/accountMenuRows.ts +0 -48
  247. package/src/ui/hooks/useDeviceAccounts.ts +0 -344
  248. package/src/ui/hooks/useSessionSocket.ts +0 -233
  249. package/src/ui/utils/__tests__/activeAuthuser.test.ts +0 -75
  250. package/src/utils/__tests__/silentGuardKey.test.ts +0 -82
  251. package/src/utils/silentGuardKey.ts +0 -46
@@ -35,15 +35,20 @@ import {
35
35
  import { toast } from '@oxyhq/bloom';
36
36
  import { useAuthStore, type AuthState } from '../stores/authStore';
37
37
  import { useShallow } from 'zustand/react/shallow';
38
- import { useSessionSocket } from '../hooks/useSessionSocket';
39
38
  import type { UseFollowHook } from '../hooks/useFollow.types';
40
39
  import { useLanguageManagement } from '../hooks/useLanguageManagement';
41
40
  import { useSessionManagement } from '../hooks/useSessionManagement';
42
- import { useAuthOperations } from './hooks/useAuthOperations';
41
+ import { useAuthOperations, clearPriorSessionHintSafe } from './hooks/useAuthOperations';
43
42
  import { useDeviceManagement } from '../hooks/useDeviceManagement';
44
43
  import { getStorageKeys, createPlatformStorage, type StorageInterface } from '../utils/storageHelpers';
45
44
  import { isInvalidSessionError, isTimeoutOrNetworkError } from '../utils/errorHandlers';
46
- import { readActiveAuthuser, writeActiveAuthuser, clearSignedOut, isSilentRestoreSuppressed } from '../utils/activeAuthuser';
45
+ import {
46
+ readActiveAuthuser,
47
+ clearSignedOut,
48
+ isSilentRestoreSuppressed,
49
+ markSignedOut,
50
+ clearSsoBounceState,
51
+ } from '../utils/activeAuthuser';
47
52
  import type { RouteName } from '../navigation/routes';
48
53
  import { showBottomSheet as globalShowBottomSheet } from '../navigation/bottomSheetManager';
49
54
  import { useQueryClient } from '@tanstack/react-query';
@@ -51,11 +56,17 @@ import { clearQueryCache } from '../hooks/queryClient';
51
56
  import { useAvatarPicker } from '../hooks/useAvatarPicker';
52
57
  import { useAccountStore } from '../stores/accountStore';
53
58
  import { logger as loggerUtil } from '@oxyhq/core';
54
- import { useWebSSO, isWebBrowser } from '../hooks/useWebSSO';
55
- import { buildSilentGuardKey } from '../../utils/silentGuardKey';
59
+ import { isWebBrowser } from '../hooks/useWebSSO';
56
60
  import { isCrossApexWeb, CrossApexDirectSignInError } from '../../utils/crossApex';
57
61
  import { createInSessionRefreshHandler, startTokenRefreshScheduler } from './inSessionTokenRefresh';
58
- import { mintSessionViaPerApexIframe, selectActiveRefreshAccount } from './silentSessionRestore';
62
+ import { mintSessionViaPerApexIframe } from './silentSessionRestore';
63
+ import {
64
+ createSessionClient,
65
+ deviceStateToClientSessions,
66
+ activeSessionIdOf,
67
+ activeUserOf,
68
+ accountIdsOf,
69
+ } from '../session';
59
70
 
60
71
  export interface OxyContextState {
61
72
  user: User | null;
@@ -99,9 +110,10 @@ export interface OxyContextState {
99
110
  /**
100
111
  * Sign in with a username/email + password.
101
112
  *
102
- * Commits a successful session into context state through the SAME path FedCM
103
- * / SSO use (so `isAuthenticated` / `user` update and the session is persisted
104
- * durably). Returns a discriminated result so the caller can branch on the
113
+ * 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
105
117
  * two-factor-required case — which creates NO session; the caller completes
106
118
  * the 2FA challenge with the returned `loginToken`.
107
119
  *
@@ -171,14 +183,16 @@ export interface OxyContextState {
171
183
  * Switch the active session INTO an account from the {@link accounts} graph
172
184
  * (a managed org/project/bot, or an account shared with the caller).
173
185
  *
174
- * Mints and plants a REAL session for the target via
175
- * `oxyServices.switchToAccount`, then commits it into context state the SAME
176
- * way sign-in / {@link switchSession} do — so afterwards `user` IS the target
177
- * account and every request authenticates as it. The minted session joins the
178
- * device multi-account set (server-set httpOnly `oxy_rt_<authuser>` cookie), so
179
- * it survives reload / `refresh-all` and appears in the device account list
180
- * exactly like a device sign-in. Refreshes the account graph and invalidates
181
- * all React Query data so everything reloads as the new account.
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
188
+ * 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.
182
196
  */
183
197
  switchToAccount: (accountId: string) => Promise<void>;
184
198
  refreshAccounts: () => Promise<void>;
@@ -191,8 +205,8 @@ const OxyContext = createContext<OxyContextState | null>(null);
191
205
  * Result of {@link OxyContextState.signInWithPassword}.
192
206
  *
193
207
  * `'ok'` — the password was accepted and the resulting session has been
194
- * committed into context state (the SAME path FedCM / SSO sessions use), so
195
- * `isAuthenticated` / `user` are updated and the session is durably persisted;
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;
196
210
  * the caller can proceed (e.g. navigate into the app).
197
211
  *
198
212
  * `'2fa_required'` — the account has two-factor auth enabled, so NO session was
@@ -223,41 +237,6 @@ export interface OxyContextProviderProps {
223
237
  onError?: (error: ApiError) => void;
224
238
  }
225
239
 
226
- /**
227
- * Module-level run-once guard for the cold-boot `fedcm-silent` step.
228
- *
229
- * The FedCM silent step triggers a one-shot `navigator.credentials.get`
230
- * handshake that must fire AT MOST ONCE per page load — otherwise a provider
231
- * remount storm (route churn, StrictMode double-invoke, error-boundary
232
- * recovery) becomes a credential request storm. A per-instance ref resets on
233
- * every remount, so the guard must live at module scope. Keyed on
234
- * `origin|baseURL` so two providers pointed at the same API from the same
235
- * origin share one attempt; never cleared because only a fresh page load can
236
- * change the central IdP session state, and a fresh page load starts a fresh
237
- * module scope.
238
- *
239
- * This is a dedicated set — distinct from `useWebSSO`'s `silentSSOAttempted`
240
- * (which guards the post-boot INTERACTIVE button path) and never a core
241
- * module-level singleton (that re-evaluates under Metro web bundling and the
242
- * guard would not hold).
243
- */
244
- const servicesSilentAttempted = new Set<string>();
245
-
246
- /**
247
- * Build the `origin|baseURL` signature used as the silent-cold-boot guard key.
248
- */
249
- function silentColdBootKey(oxyServices: OxyServices): string {
250
- // `buildSilentGuardKey` reads `window.location.origin` behind a guard that
251
- // also verifies `window.location` exists. This is critical: it runs
252
- // UNCONDITIONALLY at the top of `restoreSessionsFromStorage` (before the
253
- // cold-boot try/catch) on EVERY platform, and React Native aliases a global
254
- // `window` with NO `window.location`. Without that guard the read threw
255
- // `Cannot read property 'origin' of undefined` on native, escaping the
256
- // restore path so `markAuthResolved` never ran and stored-session restore was
257
- // never reached.
258
- return buildSilentGuardKey(() => oxyServices.getBaseURL?.());
259
- }
260
-
261
240
  /**
262
241
  * Per-step fail-fast budget for the cold-boot silent iframe (`silentSignIn`
263
242
  * against the per-apex `/auth/silent` host).
@@ -272,18 +251,6 @@ function silentColdBootKey(oxyServices: OxyServices): string {
272
251
  */
273
252
  const SILENT_IFRAME_TIMEOUT = 2500;
274
253
 
275
- /**
276
- * Per-step fail-fast budget for the cold-boot refresh-cookie restore
277
- * (`refreshAllSessions`).
278
- *
279
- * On a cross-domain RP the `Domain=oxy.so` refresh cookie never reaches
280
- * `api.<apex>`, so this request returns no accounts (or stalls behind a slow
281
- * endpoint) with no useful answer. As one cold-boot step it must not block the
282
- * fall-through to the terminal `/sso` bounce. 3s bounds the wait while leaving
283
- * ample headroom for a genuine first-party `*.oxy.so` rotation round-trip.
284
- */
285
- const COOKIE_RESTORE_TIMEOUT = 3000;
286
-
287
254
  /**
288
255
  * Per-step fail-fast budget (ms) for the native shared-key cold-boot step
289
256
  * (`signInWithSharedIdentity`).
@@ -301,15 +268,14 @@ const SHARED_KEY_SIGNIN_TIMEOUT = 8000;
301
268
  /**
302
269
  * HARD overall deadline (ms) for the entire cold-boot step loop —
303
270
  * defense-in-depth so a single non-settling step can NEVER hang auth resolution
304
- * forever (the production regression: a `navigator.credentials.get()` that
305
- * ignored its abort signal left the `fedcm-silent` step's promise unsettled, so
306
- * `runColdBoot` never advanced to the terminal `/sso` bounce and auth hung
307
- * indefinitely).
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).
308
275
  *
309
276
  * Every step ALREADY bounds its own network work (the stored-session bearer
310
- * validation at 8s, the silent iframe at `SILENT_IFRAME_TIMEOUT`, the refresh
311
- * cookie at `COOKIE_RESTORE_TIMEOUT`, FedCM silent at `FEDCM_SILENT_TIMEOUT`
312
- * plus its hard settle). On a healthy load the FIRST recovering step wins in a
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
313
279
  * single round-trip (1–3s) and the chain short-circuits long before this fires.
314
280
  * This budget only trips when one of those per-step bounds regresses.
315
281
  *
@@ -326,6 +292,23 @@ const SHARED_KEY_SIGNIN_TIMEOUT = 8000;
326
292
  */
327
293
  const COLD_BOOT_OVERALL_DEADLINE = 20000;
328
294
 
295
+ /**
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
299
+ * authenticated — the handoff only populates the multi-account set and the
300
+ * 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.
309
+ */
310
+ const SESSION_HANDOFF_DEADLINE = 6000;
311
+
329
312
  /**
330
313
  * Per-session soft timeout (ms) for the parallel stored-session validation in
331
314
  * `restoreStoredSession`. Each `validateSession` call races against this timer
@@ -606,9 +589,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
606
589
  // hooks below. But it is `null` for a brief window after mount while
607
590
  // `createPlatformStorage()` resolves (a microtask on web; a dynamic
608
591
  // `import()` on native). Any persistence path that fires during that window
609
- // — e.g. an interactive FedCM sign-in the instant the screen mounts — would
610
- // read `storage === null` and SILENTLY skip writing the session, leaving the
611
- // user signed-in in-memory but with nothing to restore on reload.
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.
612
596
  //
613
597
  // To make persistence robust regardless of timing we ALSO expose the storage
614
598
  // as an awaitable promise (`getReadyStorage`). Persistence code awaits the
@@ -716,10 +700,8 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
716
700
  setActiveSessionId,
717
701
  updateSessions,
718
702
  switchSession,
719
- refreshSessions,
720
703
  clearSessionState,
721
704
  saveActiveSessionId,
722
- trackRemovedSession,
723
705
  } = useSessionManagement({
724
706
  oxyServices,
725
707
  storage,
@@ -735,6 +717,147 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
735
717
  queryClient,
736
718
  });
737
719
 
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.
732
+ const sessionClientPairRef = useRef<ReturnType<typeof createSessionClient> | null>(null);
733
+ if (!sessionClientPairRef.current) {
734
+ sessionClientPairRef.current = createSessionClient(oxyServices);
735
+ }
736
+ const { client: sessionClient, host: sessionClientHost } = sessionClientPairRef.current;
737
+
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.
791
+ const syncFromClient = useCallback(async (): Promise<void> => {
792
+ const state = sessionClient.getState();
793
+ 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
+ return;
797
+ }
798
+ if (state.accounts.length === 0) {
799
+ sessionClientHost.setCurrentAccountId(null);
800
+ if (isWebBrowser()) {
801
+ markSignedOut();
802
+ }
803
+ clearSsoBounceState();
804
+ clearPriorSessionHintSafe(clearPriorSessionHint, logger);
805
+ await clearSessionState();
806
+ return;
807
+ }
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.
819
+ const capturedRevision = state.revision;
820
+ const ids = accountIdsOf(state);
821
+ let users: User[] = [];
822
+ try {
823
+ users = ids.length > 0 ? await oxyServices.getUsersByIds(ids) : [];
824
+ } 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
+ logger('Failed to resolve account profiles during syncFromClient', fetchError);
829
+ return;
830
+ }
831
+ const latest = sessionClient.getState();
832
+ if (!latest || latest.revision !== capturedRevision) {
833
+ return;
834
+ }
835
+ const usersById = new Map(users.map((resolvedUser) => [resolvedUser.id, resolvedUser]));
836
+ updateSessions(deviceStateToClientSessions(latest, usersById));
837
+ setActiveSessionId(activeSessionIdOf(latest));
838
+ const activeUser = activeUserOf(latest, usersById);
839
+ if (activeUser) {
840
+ loginSuccess(activeUser);
841
+ }
842
+ sessionClientHost.setCurrentAccountId(latest.activeAccountId);
843
+ }, [
844
+ oxyServices,
845
+ sessionClient,
846
+ sessionClientHost,
847
+ updateSessions,
848
+ setActiveSessionId,
849
+ loginSuccess,
850
+ clearSessionState,
851
+ clearPriorSessionHint,
852
+ logger,
853
+ ]);
854
+
855
+ useEffect(() => {
856
+ return sessionClient.subscribe(() => {
857
+ void syncFromClient();
858
+ });
859
+ }, [sessionClient, syncFromClient]);
860
+
738
861
  const {
739
862
  signIn,
740
863
  logout,
@@ -742,7 +865,6 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
742
865
  } = useAuthOperations({
743
866
  oxyServices,
744
867
  storage,
745
- sessions,
746
868
  activeSessionId,
747
869
  setActiveSessionId,
748
870
  updateSessions,
@@ -750,6 +872,8 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
750
872
  clearSessionState,
751
873
  clearPriorSessionHint,
752
874
  switchSession,
875
+ sessionClient,
876
+ syncFromClient,
753
877
  applyLanguagePreference,
754
878
  onAuthStateChange,
755
879
  onError,
@@ -881,7 +1005,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
881
1005
  // init. Callers MUST invoke this BEFORE any work that can trigger a route
882
1006
  // navigation (`onAuthStateChange`) — navigation can interrupt a still-pending
883
1007
  // async write, which is exactly what once left `session_ids` empty after a
884
- // successful sign-in. Shared by the FedCM/SSO path and the cold-boot
1008
+ // successful sign-in. Shared by the SSO/silent-restore path and the cold-boot
885
1009
  // refresh-cookie restore so both land the same durable record.
886
1010
  const persistSessionDurably = useCallback(async (sessionId: string): Promise<void> => {
887
1011
  const readyStorage = await getReadyStorage();
@@ -900,7 +1024,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
900
1024
  // A session is now durably committed — set the returning-user hint so a
901
1025
  // future cold boot whose local session has lapsed still gets ONE `/sso`
902
1026
  // establish bounce (see `markPriorSessionHint`). Every web commit path
903
- // (FedCM / silent iframe / SSO return / password / cookie restore) funnels
1027
+ // (silent iframe / SSO return / password / cookie restore) funnels
904
1028
  // through here, so this is the single chokepoint for the hint.
905
1029
  await readyStorage.setItem(storageKeys.priorSession, '1');
906
1030
  }, [getReadyStorage, logger, storageKeys.activeSessionId, storageKeys.sessionIds, storageKeys.priorSession]);
@@ -919,10 +1043,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
919
1043
  // Idempotent and monotonic via `authResolvedRef`: the first call wins and the
920
1044
  // setters fire at most once, so the restore `finally` backstop becomes a no-op
921
1045
  // once a commit site has already marked resolution. Called from EVERY place a
922
- // user is actually committed (the FedCM/iframe/SSO path
923
- // `handleWebSSOSession`, the cookie-restore path, and the stored-session path)
924
- // so the common reload case unblocks the loading gate without sitting behind
925
- // the remaining (now-skipped) cold-boot steps.
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.
926
1050
  const markAuthResolved = useCallback(() => {
927
1051
  if (authResolvedRef.current) {
928
1052
  return;
@@ -935,104 +1059,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
935
1059
  markAuthResolvedRef.current = markAuthResolved;
936
1060
 
937
1061
  // `handleWebSSOSession` is declared further down (it depends on values that
938
- // are only available there). The FedCM/iframe cold-boot steps need to commit
1062
+ // are only available there). The iframe/SSO cold-boot steps need to commit
939
1063
  // a recovered session through it, so we route the call through a ref that is
940
1064
  // populated once the callback exists. The ref is assigned synchronously on
941
1065
  // every render before the cold-boot effect can fire (the effect is gated on
942
1066
  // `storage` + `initialized`, both of which settle after first render).
943
1067
  const handleWebSSOSessionRef = useRef<((session: SessionLoginResponse) => Promise<void>) | null>(null);
944
1068
 
945
- // Cold-boot session restore via the secure refresh cookies (web only).
946
- //
947
- // Calls `oxyServices.refreshAllSessions()` → `POST /auth/refresh-all` with
948
- // `credentials: 'include'`. The server rotates every device-local
949
- // `oxy_rt_${authuser}` cookie in parallel and returns one entry per valid
950
- // account (Google-style multi-account).
951
- //
952
- // Active-account selection: the persisted `oxy_active_authuser` slot index
953
- // wins when it matches a returned account; otherwise the lowest `authuser`
954
- // is picked. JS never sees the refresh cookies (httpOnly).
955
- //
956
- // Returns `true` when at least one session was restored (caller short-
957
- // circuits the bearer path); `false` on no signed-in accounts / any failure
958
- // (caller proceeds unauthenticated through the existing flow — nothing is
959
- // cleared).
960
- const restoreViaRefreshCookie = useCallback(async (): Promise<boolean> => {
961
- if (!isWebBrowser()) {
962
- return false;
963
- }
964
-
965
- let snapshot;
966
- try {
967
- // Bound the refresh so a cross-domain/stalled call cannot hang the cold
968
- // boot in front of the terminal `/sso` bounce (see COOKIE_RESTORE_TIMEOUT).
969
- snapshot = await oxyServices.refreshAllSessions({ timeout: COOKIE_RESTORE_TIMEOUT });
970
- } catch (fetchError) {
971
- // Offline / network error — fall through to the cached/stored-session flow.
972
- if (__DEV__) {
973
- loggerUtil.debug('Refresh-all cookie restore network error (expected when offline)', { component: 'OxyContext', method: 'restoreViaRefreshCookie' }, fetchError as unknown);
974
- }
975
- return false;
976
- }
977
-
978
- if (snapshot.accounts.length === 0) {
979
- return false;
980
- }
981
-
982
- // Pick the active account: persisted authuser if it still matches a returned
983
- // account, otherwise the lowest authuser (deterministic). The server has
984
- // already sorted ascending so [0] is the lowest.
985
- const activeAccount = selectActiveRefreshAccount(snapshot.accounts, readActiveAuthuser());
986
-
987
- // Plant the active access token. Sibling accounts' access tokens stay in
988
- // the snapshot (the chooser can drive a per-account refresh via
989
- // `refreshTokenViaCookie({authuser})` on switch).
990
- oxyServices.httpService.setTokens(activeAccount.accessToken);
991
-
992
- // Fetch the full user with the freshly planted token. The refresh-all
993
- // payload includes a minimal user shape (id, username, name, avatar,
994
- // email, color) — sufficient for the chooser but the auth store wants the
995
- // canonical User document for downstream rendering.
996
- let fullUser: User;
997
- try {
998
- fullUser = await oxyServices.getCurrentUser();
999
- } catch (userError) {
1000
- // Token planted but profile fetch failed (e.g. transient network). Do
1001
- // not claim a restored session; fall through so the stored-session flow
1002
- // can retry. Leave the planted token in place — it is valid and harmless.
1003
- if (__DEV__) {
1004
- loggerUtil.debug('Refresh-all cookie restore: getCurrentUser failed', { component: 'OxyContext', method: 'restoreViaRefreshCookie' }, userError as unknown);
1005
- }
1006
- return false;
1007
- }
1008
-
1009
- // Build a ClientSession per returned account so the multi-session store
1010
- // reflects every device-local slot, not just the active one. The active
1011
- // account is flagged `isCurrent: true`.
1012
- const now = new Date();
1013
- const clientSessions: ClientSession[] = snapshot.accounts.map((account) => ({
1014
- sessionId: account.sessionId,
1015
- deviceId: '',
1016
- expiresAt: account.expiresAt || new Date(now.getTime() + DEFAULT_SESSION_VALIDITY_MS).toISOString(),
1017
- lastActive: now.toISOString(),
1018
- userId: account.user?.id,
1019
- isCurrent: account.sessionId === activeAccount.sessionId,
1020
- authuser: account.authuser,
1021
- }));
1022
-
1023
- updateSessionsRef.current(clientSessions, { merge: true });
1024
- setActiveSessionIdRef.current(activeAccount.sessionId);
1025
- writeActiveAuthuser(activeAccount.authuser);
1026
- await persistSessionDurably(activeAccount.sessionId);
1027
-
1028
- loginSuccessRef.current(fullUser);
1029
- // A session is now committed — unblock the auth-resolution gate immediately
1030
- // rather than waiting for `runColdBoot` to return (idempotent).
1031
- markAuthResolvedRef.current();
1032
- onAuthStateChangeRef.current?.(fullUser);
1033
- return true;
1034
- }, [oxyServices, persistSessionDurably]);
1035
-
1036
1069
  // Native (and offline) stored-session restore — the ONLY restore path that
1037
1070
  // runs on React Native, and the web fallback when no cross-domain step won.
1038
1071
  //
@@ -1048,9 +1081,28 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1048
1081
  const storedSessionIdsJson = await storage.getItem(storageKeys.sessionIds);
1049
1082
  const storedSessionIdsFromStorage: string[] = storedSessionIdsJson ? JSON.parse(storedSessionIdsJson) : [];
1050
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.
1051
1089
  const storedActiveAuthuser = isWebBrowser() ? readActiveAuthuser() : null;
1052
1090
 
1053
- if (isWebBrowser() && !oxyServices.getAccessToken() && (storedActiveSessionId === null || storedActiveAuthuser === null)) {
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
+ ) {
1054
1106
  return false;
1055
1107
  }
1056
1108
 
@@ -1151,20 +1203,56 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1151
1203
  } catch (switchError) {
1152
1204
  // Silently handle expected errors (invalid sessions, timeouts, network issues)
1153
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.
1154
1210
  await storage.removeItem(storageKeys.activeSessionId);
1155
- updateSessionsRef.current(
1156
- validSessions.filter((session) => session.sessionId !== storedActiveSessionId),
1157
- { merge: false, preserveSessionIds: unvalidatedSessionIds },
1158
- );
1211
+ validSessions = validSessions.filter((session) => session.sessionId !== storedActiveSessionId);
1212
+ updateSessionsRef.current(validSessions, {
1213
+ merge: false,
1214
+ preserveSessionIds: unvalidatedSessionIds,
1215
+ });
1159
1216
  // Don't log expected session errors during restoration
1160
1217
  } else if (isTimeoutOrNetworkError(switchError)) {
1161
- // Timeout/network error - non-critical, don't block
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.
1162
1222
  if (__DEV__) {
1163
1223
  loggerUtil.debug('Active session validation timeout (expected when offline)', { component: 'OxyContext', method: 'restoreStoredSession' }, switchError as unknown);
1164
1224
  }
1225
+ return false;
1165
1226
  } else {
1166
- // Only log unexpected errors
1227
+ // Only log unexpected errors, and do not elect on an ambiguous failure.
1167
1228
  logger('Active session validation error', switchError);
1229
+ return false;
1230
+ }
1231
+ }
1232
+ }
1233
+
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) {
1243
+ 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);
1168
1256
  }
1169
1257
  }
1170
1258
  }
@@ -1250,21 +1338,31 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1250
1338
  // web-only step is gated by `isWebBrowser()`, so on native ONLY
1251
1339
  // `stored-session` runs.
1252
1340
  //
1253
- // Order (web): SSO return → stored session → FedCM silent
1254
- // (central) → silent iframe (per-apex, the durable reload path) → cookie
1255
- // restore → SSO bounce (terminal).
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`).
1256
1355
  //
1257
- // LATENCY (FIX A): `stored-session` runs BEFORE the slow no-redirect probes
1258
- // (`fedcm-silent`, `silent-iframe`, `cookie-restore`). On a normal reload the
1259
- // local bearer validates in one round-trip and wins, so `runColdBoot`
1260
- // short-circuits and never sits through those probes' timeouts (the prior
1261
- // serial sum was a ~20-30s stall). `sso-return` MUST stay first it consumes
1262
- // the URL fragment before anything can strip it. On a
1263
- // first visit with no local session, `stored-session` skips and the
1264
- // cross-domain fallback chain (fedcm iframe cookie sso-bounce) runs
1265
- // exactly as before; the per-apex silent iframe still restores a durable
1266
- // cross-domain session on reload WITHOUT a top-level bounce, so when it wins
1267
- // `sso-bounce` never fires (no flash, no loop).
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).
1268
1366
  // Order (native): stored session only (every web-only step is disabled
1269
1367
  // off-browser).
1270
1368
  const restoreSessionsFromStorage = useCallback(async (): Promise<void> => {
@@ -1273,19 +1371,19 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1273
1371
  }
1274
1372
 
1275
1373
  setTokenReady(false);
1374
+ // Fresh per-boot flag — see the declaration comment above `sessionClient`.
1375
+ registeredDuringBootRef.current = false;
1276
1376
 
1277
1377
  const commitWebSession = handleWebSSOSessionRef.current;
1278
- const silentKey = silentColdBootKey(oxyServices);
1279
- const fedcmSupported = isWebBrowser() && oxyServices.isFedCMSupported?.() === true;
1280
1378
 
1281
1379
  // FIX-B precondition flag: set true the instant the (now-earlier)
1282
- // `stored-session` step recovers a local bearer session. The slow web-only
1283
- // probes (`fedcm-silent`, `silent-iframe`) AND `enabled` on `!storedSessionRestored`
1284
- // so they are explicitly skipped once a local session won. `runColdBoot`
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`
1285
1383
  // already short-circuits on the first `{kind:'session'}`, so on a winning
1286
- // reload those `enabled` bodies are never even reached — this flag makes the
1384
+ // reload that `enabled` body is never even reached — this flag makes the
1287
1385
  // intent explicit and is redundant-safe. On a first-visit-no-local-session,
1288
- // `stored-session` skips, this stays false, and the probes run as before.
1386
+ // `stored-session` skips, this stays false, and the probe runs as before.
1289
1387
  let storedSessionRestored = false;
1290
1388
 
1291
1389
  // FIX-B smart-gate input: has this device/app EVER had a signed-in Oxy
@@ -1309,52 +1407,16 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1309
1407
  }
1310
1408
  }
1311
1409
 
1312
- // LAST-ACTIVE-ACCOUNT priority (web multi-account). When the user has an
1313
- // explicit persisted active slot (`oxy_active_authuser`, written on every
1314
- // device sign-in / account SWITCH / cookie restore), the multi-account
1315
- // refresh-cookie restore is the ONLY cold-boot step that honors WHICH slot
1316
- // was last active `restoreViaRefreshCookie` selects it via
1317
- // `selectActiveRefreshAccount(accounts, readActiveAuthuser())`. The
1318
- // `fedcm-silent` and per-apex `silent-iframe` steps only ever recover the
1319
- // PRIMARY central IdP session, so if either ran first they would clobber a
1320
- // switched (managed/org) account back to the primary on reload — the exact
1321
- // "switch is lost on reload" regression. So when a slot selection exists we
1322
- // run cookie-restore BEFORE those probes (and disable the later duplicate).
1323
- //
1324
- // Read ONCE here (synchronously usable by the step `enabled` gates below).
1325
- // It is non-null ONLY on first-party web apps that persist the slot. Both the
1326
- // device sign-in/switch paths AND `handleWebSSOSession` (FedCM/SSO/credentials
1327
- // commit funnel) now persist it — but only when the session genuinely joined
1328
- // the device set: `establishDeviceRefreshSlot` returns an `authuser` ONLY on
1329
- // first-party web (same registrable apex as the API), so a cross-apex RP never
1330
- // writes a phantom slot and native has no localStorage. Both therefore keep
1331
- // their existing order untouched. The earlier `stored-session` step still runs
1332
- // first (FIX-A latency), and when a slot exists but its cookies have all
1333
- // lapsed, cookie-restore simply returns no accounts and the chain falls through
1334
- // to the cross-domain fallbacks exactly as before.
1335
- const prioritizeMultiAccount = isWebBrowser() && readActiveAuthuser() !== null;
1336
-
1337
- // DELIBERATELY-SIGNED-OUT gate (web): when the user pressed "Sign out", any
1338
- // credential that can silently re-mint a session may still be live on the
1339
- // next reload — the central IdP session (FedCM credential association /
1340
- // per-apex `fedcm_session` cookie) AND the device refresh cookies. Since
1341
- // PR #455 the PRIMARY web session also joins the `oxy_rt_<authuser>` device
1342
- // set, so the un-gated cookie-restore steps would `refresh-all` that still-
1343
- // present cookie and sign the user back in without intent — exactly the
1344
- // "sign-out is silently undone" regression. So this flag gates EVERY
1345
- // AUTOMATIC silent-restore cold-boot step: `stored-session`,
1346
- // `cookie-restore-active`, `fedcm-silent`, `silent-iframe`, and
1347
- // `cookie-restore`. The gate — not a cookie wipe — is the authority: the
1348
- // account may stay "known" for a fast deliberate re-sign-in, but no step
1349
- // restores it silently while the flag is set. Read the durable flag ONCE here
1350
- // (synchronously usable by every step `enabled` gate). Any deliberate sign-in
1351
- // (password, FedCM, account switch, device claim) clears it, so there is no
1352
- // "stuck signed out" state. The `sso-bounce` step needs no extra gate: it is
1353
- // already self-suppressed after sign-out (its `hasPriorSession` hint is
1354
- // cleared). `sso-return` is NOT gated — it commits the result of a deliberate
1355
- // top-level `/sso` bounce the user just initiated. `shared-key-signin` is
1356
- // native-only and the flag never sets on native (`markSignedOut` no-ops
1357
- // off-web), so its gate would be moot; it is left untouched.
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.
1358
1420
  const silentRestoreBlocked = isSilentRestoreSuppressed();
1359
1421
 
1360
1422
  try {
@@ -1374,28 +1436,22 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1374
1436
  },
1375
1437
  },
1376
1438
  {
1377
- // 2) Stored-session bearer restore. Runs on ALL platforms EXCEPT when
1378
- // the deliberately-signed-out flag is set (web only it never sets on
1379
- // native, so native still always reaches exactly this step, its ONLY
1380
- // restore path). This is also the common WEB reload winner.
1381
- //
1382
- // SIGNED-OUT GATE: after a deliberate full sign-out the stored session
1383
- // state is cleared, so this would normally skip anyway — but the gate
1384
- // makes that authoritative rather than incidental, so no residual
1385
- // stored token can silently restore while the user is signed out.
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.
1386
1443
  //
1387
- // ORDERING (FIX A): this step runs BEFORE the slow web-only
1388
- // probes (`fedcm-silent`, `silent-iframe`, `cookie-restore`). On a
1389
- // normal reload the local bearer validates in one round-trip and
1390
- // wins; `runColdBoot` then short-circuits and never even evaluates
1391
- // the slow no-redirect probes that would otherwise time out (the
1392
- // ~20-30s serial stall). The `sso-return` step stays AHEAD of this
1393
- // one — it must consume the URL fragment before any
1394
- // later step (or anything else) strips it. On a first visit with no
1395
- // local session this step skips and the cross-domain fallback chain
1396
- // (fedcm → iframe → cookie → sso-bounce) runs exactly as before.
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.
1397
1454
  id: 'stored-session',
1398
- enabled: () => !silentRestoreBlocked,
1399
1455
  run: async () => {
1400
1456
  const restored = await restoreStoredSession();
1401
1457
  if (restored) {
@@ -1458,62 +1514,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1458
1514
  },
1459
1515
  },
1460
1516
  {
1461
- // 2.75) LAST-ACTIVE multi-account restore (WEB, prioritized). Runs
1462
- // ONLY when an explicit persisted slot selection exists
1463
- // (`prioritizeMultiAccount`) — i.e. the user previously signed in or
1464
- // SWITCHED accounts on this first-party app. It runs BEFORE the
1465
- // `fedcm-silent` / `silent-iframe` probes (which only ever recover the
1466
- // PRIMARY central session) so a switched managed/org account survives
1467
- // reload instead of being clobbered back to the primary. The restore
1468
- // itself is the SAME `restoreViaRefreshCookie` as the terminal-tier
1469
- // step below: it rotates every device-local `oxy_rt_<authuser>` cookie
1470
- // and picks the persisted slot via `selectActiveRefreshAccount`,
1471
- // committing it (token + state + durable persistence + `markAuthResolved`).
1472
- // When no slot is persisted this is disabled and the original order is
1473
- // unchanged; when the slot's cookies have lapsed it returns no accounts
1474
- // and the chain falls through to the cross-domain fallbacks below.
1475
- //
1476
- // SIGNED-OUT GATE: since PR #455 the primary's `oxy_rt` slot survives a
1477
- // deliberate sign-out, so without this gate `refresh-all` would re-mint
1478
- // and silently re-log-in the user on reload. The flag is cleared by any
1479
- // deliberate sign-in (including an account SWITCH, which also writes the
1480
- // new active slot), so the switch-survives-reload path is unaffected.
1481
- id: 'cookie-restore-active',
1482
- enabled: () => prioritizeMultiAccount && !silentRestoreBlocked,
1483
- run: async () => {
1484
- const restored = await restoreViaRefreshCookie();
1485
- return restored ? { kind: 'session', session: true } : { kind: 'skip' };
1486
- },
1487
- },
1488
- {
1489
- // 3) FedCM silent reauthn (Chrome) against the CENTRAL IdP
1490
- // (auth.oxy.so). `silentSignInWithFedCM` plants the access token
1491
- // internally; we commit the returned session via
1492
- // `handleWebSSOSession`. Guarded so it fires at most once per page
1493
- // load across remounts. This is an enhancement layered above the
1494
- // opaque-code bounce: when it succeeds the bounce never fires.
1495
- //
1496
- // FIX-B: additionally skipped when the earlier `stored-session` step
1497
- // already recovered a local session — the probe cannot improve on a
1498
- // valid local bearer, and skipping it avoids the silent round-trip.
1499
- id: 'fedcm-silent',
1500
- enabled: () =>
1501
- !storedSessionRestored &&
1502
- !silentRestoreBlocked &&
1503
- fedcmSupported &&
1504
- !servicesSilentAttempted.has(silentKey),
1505
- run: async () => {
1506
- servicesSilentAttempted.add(silentKey);
1507
- const session = await oxyServices.silentSignInWithFedCM?.();
1508
- if (!session || !commitWebSession) {
1509
- return { kind: 'skip' };
1510
- }
1511
- await commitWebSession(session);
1512
- return { kind: 'session', session: true };
1513
- },
1514
- },
1515
- {
1516
- // 4) First-party silent iframe at the PER-APEX IdP — the DURABLE
1517
+ // 3) First-party silent iframe at the PER-APEX IdP the DURABLE
1517
1518
  // cross-domain reload-restore path. The durable session lives as a
1518
1519
  // first-party `fedcm_session` cookie on `auth.<rp-apex>` (e.g.
1519
1520
  // `auth.mention.earth`), established during the `/sso` bounce's
@@ -1554,35 +1555,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1554
1555
  },
1555
1556
  },
1556
1557
  {
1557
- // 5) Refresh-cookie restore (first-party only). On `*.oxy.so` the
1558
- // httpOnly `oxy_rt_${n}` cookies ride along and resurrect every
1559
- // device-local slot. On a cross-domain RP (mention.earth, …) the
1560
- // cookie is `Domain=oxy.so` so it never reaches `api.<apex>` —
1561
- // `refreshAllSessions` returns `{accounts:[]}` and this skips. That
1562
- // is correct; cross-domain restore is handled by the SSO bounce.
1563
- // FIX-D: `restoreViaRefreshCookie` bounds the request with
1564
- // `COOKIE_RESTORE_TIMEOUT` so a cross-domain stall cannot hang here.
1565
- //
1566
- // Disabled when `prioritizeMultiAccount` already ran the
1567
- // `cookie-restore-active` step above (an explicit persisted slot) — that
1568
- // earlier step is the identical restore, so this terminal-tier copy
1569
- // would be a redundant second `refreshAllSessions`. It still runs in the
1570
- // common no-persisted-slot case (e.g. first visit to a first-party app
1571
- // whose central refresh cookies already exist).
1572
- //
1573
- // SIGNED-OUT GATE (same rationale as `cookie-restore-active`): the
1574
- // primary's `oxy_rt` slot survives a deliberate sign-out (PR #455), so
1575
- // this must be skipped while the signed-out flag is set or it would
1576
- // silently re-restore the primary on reload.
1577
- id: 'cookie-restore',
1578
- enabled: () => isWebBrowser() && !prioritizeMultiAccount && !silentRestoreBlocked,
1579
- run: async () => {
1580
- const restored = await restoreViaRefreshCookie();
1581
- return restored ? { kind: 'session', session: true } : { kind: 'skip' };
1582
- },
1583
- },
1584
- {
1585
- // 6) SSO bounce (TERMINAL, web only, at most once). No local session
1558
+ // 4) SSO bounce (TERMINAL, web only, at most once). No local session
1586
1559
  // was found by any step above. Top-level navigate to the central
1587
1560
  // `auth.oxy.so/sso?prompt=none` so the IdP can either mint a session
1588
1561
  // (returning an opaque code we exchange on the callback) or report
@@ -1660,9 +1633,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1660
1633
  );
1661
1634
  }
1662
1635
  },
1663
- // Defense-in-depth: a single step whose promise never settles (the
1664
- // production FedCM-silent hang) can no longer block the chain forever.
1665
- // On expiry the runner keeps iterating to the terminal `sso-bounce`
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`
1666
1639
  // step so a genuine no-local-session visit still reaches the
1667
1640
  // cross-domain `/sso` fallback; the `finally` backstop flips
1668
1641
  // `authResolved` regardless. See `COLD_BOOT_OVERALL_DEADLINE`.
@@ -1683,6 +1656,81 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1683
1656
  { component: 'OxyContext', method: 'restoreSessionsFromStorage' },
1684
1657
  );
1685
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
+ }
1686
1734
  } catch (error) {
1687
1735
  if (__DEV__) {
1688
1736
  loggerUtil.error('Auth init error', error instanceof Error ? error : new Error(String(error)), { component: 'OxyContext', method: 'restoreSessionsFromStorage' });
@@ -1702,10 +1750,11 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1702
1750
  oxyServices,
1703
1751
  storage,
1704
1752
  storageKeys.priorSession,
1705
- restoreViaRefreshCookie,
1706
1753
  restoreStoredSession,
1707
1754
  runSsoReturn,
1708
1755
  markAuthResolved,
1756
+ sessionClient,
1757
+ syncFromClient,
1709
1758
  ]);
1710
1759
 
1711
1760
  useEffect(() => {
@@ -1826,34 +1875,38 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1826
1875
 
1827
1876
  // A committed web session re-enables automatic silent restore: clear the
1828
1877
  // durable "deliberately signed out" flag. This funnel is reached by deliberate
1829
- // sign-ins (password, interactive FedCM, `/sso` return) AND by the silent
1878
+ // sign-ins (password, `/sso` return) AND by the silent
1830
1879
  // cold-boot steps — but those are GATED on the flag, so when it is set they
1831
1880
  // never run and never reach here; clearing is therefore only hit on a genuine
1832
1881
  // (re-)sign-in or when restore was already permitted, both correct.
1833
1882
  clearSignedOut();
1834
1883
 
1835
- // Register this primary session in the device's first-party multi-account
1836
- // refresh-cookie set (web only). Every web primary-session restore funnels
1837
- // through here — FedCM silent (`/fedcm/exchange`), per-apex `/auth/silent`
1838
- // iframe, central `/sso` return, and keyless password sign-in. Of those, only
1839
- // a same-apex `/fedcm/exchange` plants an `oxy_rt_<authuser>` slot as a side
1840
- // effect; the cross-origin/credential-less restores (`/sso/exchange`,
1841
- // `/auth/silent` postMessage) cannot set an `api.oxy.so` cookie at all. So
1842
- // WITHOUT this call a web primary never joins the device set: `refresh-all`
1843
- // returns zero accounts, and account-switch persistence + the
1844
- // `cookie-restore-active` cold-boot step have no foundation. Calling the shared
1845
- // `POST /auth/session` primitive here makes EVERY primary participate in the
1846
- // set, re-plants the rotated token, and records the active `authuser` so the
1847
- // next cold boot's `cookie-restore-active` reconciles the persisted active
1848
- // (possibly switched) account instead of `fedcm-silent` clobbering it back to
1849
- // the primary. Best-effort: a `null` result (native / transient failure)
1850
- // leaves the in-session token untouched.
1851
- const primaryAuthuser = await oxyServices.establishDeviceRefreshSlot();
1852
- // A non-null slot is returned ONLY on first-party web (the helper gates on
1853
- // platform + same registrable apex), so recording the active slot here is safe
1854
- // and never writes a phantom authuser for a cross-apex RP or native.
1855
- if (typeof primaryAuthuser === 'number') {
1856
- writeActiveAuthuser(primaryAuthuser);
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
+ );
1857
1910
  }
1858
1911
 
1859
1912
  const clientSession = {
@@ -1863,7 +1916,6 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1863
1916
  lastActive: new Date().toISOString(),
1864
1917
  userId: session.user.id?.toString() ?? '',
1865
1918
  isCurrent: true,
1866
- ...(typeof primaryAuthuser === 'number' ? { authuser: primaryAuthuser } : null),
1867
1919
  };
1868
1920
 
1869
1921
  updateSessions([clientSession], { merge: true });
@@ -1876,7 +1928,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1876
1928
  // because `onAuthStateChange` triggers a route navigation that can
1877
1929
  // interrupt/supersede a still-pending async write. Persisting first
1878
1930
  // guarantees the durable record lands; that is exactly what was missing
1879
- // when `oxy_session_session_ids` came back empty after a successful FedCM
1931
+ // when `oxy_session_session_ids` came back empty after a successful SSO
1880
1932
  // sign-in (the user appeared logged in until reload, then had no session to
1881
1933
  // restore). `persistSessionDurably` awaits the READY storage instance rather
1882
1934
  // than reading the possibly-null `storage` state, so a sign-in fired the
@@ -1900,14 +1952,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1900
1952
  fullUser = session.user as unknown as User;
1901
1953
  }
1902
1954
  loginSuccess(fullUser);
1903
- // A session is now committed (FedCM silent / per-apex iframe /
1955
+ // A session is now committed (per-apex iframe /
1904
1956
  // SSO-return all funnel through here) — unblock the auth-resolution
1905
1957
  // gate immediately, ahead of the cold-boot chain returning (idempotent).
1906
1958
  markAuthResolvedRef.current();
1907
1959
  onAuthStateChange?.(fullUser);
1908
- }, [oxyServices, updateSessions, setActiveSessionId, loginSuccess, onAuthStateChange, persistSessionDurably]);
1960
+ }, [oxyServices, updateSessions, setActiveSessionId, loginSuccess, onAuthStateChange, persistSessionDurably, sessionClient, syncFromClient]);
1909
1961
 
1910
- // Expose `handleWebSSOSession` to the cold-boot FedCM/iframe/SSO steps,
1962
+ // Expose `handleWebSSOSession` to the cold-boot iframe/SSO steps,
1911
1963
  // which reference it through a ref because they are declared above this
1912
1964
  // callback. Assigned synchronously on every render so the ref is populated
1913
1965
  // before the cold-boot effect (gated on `storage`/`initialized`) can fire.
@@ -1915,7 +1967,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1915
1967
 
1916
1968
  // Keyless native sign-in: username/email + password. The slimmed Accounts app
1917
1969
  // (which no longer holds a local identity key) uses this; it commits a
1918
- // successful session through the SAME `handleWebSSOSession` path FedCM / SSO
1970
+ // successful session through the SAME `handleWebSSOSession` path SSO
1919
1971
  // use, so state, durable persistence, profile fetch, and `markAuthResolved`
1920
1972
  // all run identically. The API returns either a full session OR a two-factor
1921
1973
  // handoff (`{ twoFactorRequired, loginToken }`) — we surface the latter as a
@@ -1958,31 +2010,29 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1958
2010
  [oxyServices, handleWebSSOSession],
1959
2011
  );
1960
2012
 
1961
- // Cross-domain silent SSO is now owned by the `fedcm-silent` / `silent-iframe`
1962
- // cold-boot steps above (the ordered `runColdBoot` sequence). `useWebSSO`
1963
- // remains mounted for its module-level run-once guard and its interactive
1964
- // FedCM helpers, and as a bounded post-boot safety net: it can fire at most
1965
- // once per page load (its own module guard), and only AFTER cold boot has
1966
- // finished (`tokenReady`) with no user recovered. We deliberately keep
1967
- // `shouldTryWebSSO` as `tokenReady && !user && initialized` — it is NOT
1968
- // loosened; cold boot runs while `tokenReady` is false, so this never races
1969
- // the cold-boot silent step.
1970
- const shouldTryWebSSO = isWebBrowser() && tokenReady && !user && initialized;
1971
-
1972
- useWebSSO({
1973
- oxyServices,
1974
- onSessionFound: handleWebSSOSession,
1975
- onError: (error) => {
1976
- if (__DEV__) {
1977
- loggerUtil.debug('Web SSO check failed (non-critical)', { component: 'OxyContext' }, error);
1978
- }
1979
- },
1980
- enabled: shouldTryWebSSO,
1981
- });
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).
1982
2020
 
1983
2021
  // IdP session validation via lightweight iframe check
1984
2022
  // When user returns to tab, verify auth.oxy.so still has their session
1985
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.
1986
2036
  const lastIdPCheckRef = useRef<number>(0);
1987
2037
  const pendingIdPCleanupRef = useRef<(() => void) | null>(null);
1988
2038
 
@@ -2058,51 +2108,45 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
2058
2108
  };
2059
2109
  }, [user, initialized, clearSessionState, resolvedAuthWebUrl]);
2060
2110
 
2061
- const activeSession = activeSessionId
2062
- ? sessions.find((session) => session.sessionId === activeSessionId)
2063
- : undefined;
2064
- const currentDeviceId = activeSession?.deviceId ?? null;
2065
-
2066
- const userId = user?.id;
2067
-
2068
- const refreshSessionsWithUser = useCallback(
2069
- () => refreshSessions(userId),
2070
- [refreshSessions, userId],
2071
- );
2072
-
2073
- const handleSessionRemoved = useCallback(
2074
- (sessionId: string) => {
2075
- trackRemovedSession(sessionId);
2076
- },
2077
- [trackRemovedSession],
2078
- );
2079
-
2080
- const handleRemoteSignOut = useCallback(() => {
2081
- toast.info('You have been signed out remotely.');
2082
- logout().catch((remoteError) => logger('Failed to process remote sign out', remoteError));
2083
- }, [logger, logout]);
2084
-
2085
- useSessionSocket({
2086
- userId,
2087
- activeSessionId,
2088
- currentDeviceId,
2089
- refreshSessions: refreshSessionsWithUser,
2090
- clearSessionState,
2091
- baseURL: oxyServices.getBaseURL(),
2092
- getAccessToken: () => oxyServices.getAccessToken(),
2093
- onRemoteSignOut: handleRemoteSignOut,
2094
- onSessionRemoved: handleSessionRemoved,
2095
- });
2096
-
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).
2119
+ const refreshSessionsForContext = useCallback(async (): Promise<void> => {
2120
+ await sessionClient.bootstrap();
2121
+ await syncFromClient();
2122
+ }, [sessionClient, syncFromClient]);
2123
+
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.
2097
2131
  const switchSessionForContext = useCallback(
2098
2132
  async (sessionId: string): Promise<User> => {
2099
- // Propagate the activated user so callers (the device-flow sign-in,
2100
- // `useSwitchSession`'s cache write, account chooser) receive it. The
2101
- // underlying session-management `switchSession` already resolves the
2102
- // `User`; the previous `Promise<void>` wrapper discarded it.
2103
- return switchSession(sessionId);
2133
+ const targetAccountId = sessionClient
2134
+ .getState()
2135
+ ?.accounts.find((account) => account.sessionId === sessionId)?.accountId;
2136
+ if (!targetAccountId) {
2137
+ throw new Error(`No device account found for session "${sessionId}"`);
2138
+ }
2139
+
2140
+ await sessionClient.switchAccount(targetAccountId);
2141
+ await syncFromClient();
2142
+
2143
+ const activeUser = useAuthStore.getState().user;
2144
+ if (!activeUser) {
2145
+ throw new Error('Active account profile could not be resolved after switch');
2146
+ }
2147
+ return activeUser;
2104
2148
  },
2105
- [switchSession],
2149
+ [sessionClient, syncFromClient],
2106
2150
  );
2107
2151
 
2108
2152
  // Identity management wrappers (delegate to KeyManager)
@@ -2162,16 +2206,42 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
2162
2206
  }
2163
2207
  }, [isAuthenticated, initialized, tokenReady, refreshAccounts]);
2164
2208
 
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
+ const runPostAccountSwitchSideEffects = useCallback(async (): Promise<void> => {
2214
+ await refreshAccounts();
2215
+ queryClient.invalidateQueries();
2216
+ }, [refreshAccounts, queryClient]);
2217
+
2165
2218
  // Switch the active session INTO an account from the unified graph. In the
2166
- // REAL-SESSION model this is identical to switching device sign-ins: mint a
2167
- // real session for the target and make the whole app that account. The removed
2168
- // `X-Acting-As` delegation header is gone `oxyServices.switchToAccount`
2169
- // plants the minted access token (the refresh token is the server-set httpOnly
2170
- // `oxy_rt_<authuser>` cookie, so the session joins the device multi-account set
2171
- // and survives reload / `refresh-all`). We then commit the session into context
2172
- // state the SAME way sign-in / `switchSession` do, refresh the account graph,
2173
- // and invalidate every query so all data reloads as the new account.
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.
2174
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
+
2175
2245
  const result = await oxyServices.switchToAccount(accountId);
2176
2246
  if (!result?.user || !result?.sessionId) {
2177
2247
  throw new Error('Account switch did not return a valid session');
@@ -2183,8 +2253,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
2183
2253
 
2184
2254
  // `oxyServices.switchToAccount` already planted `result.accessToken` as the
2185
2255
  // active token; mirror the minted session into the multi-account store and
2186
- // mark it current, recording the device `authuser` slot so web silent-switch
2187
- // and the device account chooser can address it.
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.
2188
2260
  const now = new Date();
2189
2261
  const clientSession: ClientSession = {
2190
2262
  sessionId: result.sessionId,
@@ -2193,15 +2265,33 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
2193
2265
  lastActive: now.toISOString(),
2194
2266
  userId: result.user.id?.toString() ?? '',
2195
2267
  isCurrent: true,
2196
- ...(typeof result.authuser === 'number' ? { authuser: result.authuser } : null),
2197
2268
  };
2198
2269
  updateSessions([clientSession], { merge: true });
2199
2270
  setActiveSessionId(result.sessionId);
2200
- if (isWebBrowser() && typeof result.authuser === 'number') {
2201
- writeActiveAuthuser(result.authuser);
2202
- }
2203
2271
  await persistSessionDurably(result.sessionId);
2204
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
+
2205
2295
  // Fetch the canonical User for the new account (the switch result carries
2206
2296
  // only MinimalUserData); fall back to that minimal shape if the profile
2207
2297
  // fetch fails so the app still reflects the switched identity.
@@ -2217,13 +2307,8 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
2217
2307
  loginSuccess(fullUser);
2218
2308
  onAuthStateChange?.(fullUser);
2219
2309
 
2220
- // Reload the switchable account graph (the new active account's relationships
2221
- // differ) and invalidate every query so all data refetches as the new
2222
- // account — the deviceAccounts probe re-enumerates the multi-account set so
2223
- // the switched account now appears as a device session.
2224
- await refreshAccounts();
2225
- queryClient.invalidateQueries();
2226
- }, [oxyServices, updateSessions, setActiveSessionId, persistSessionDurably, loginSuccess, onAuthStateChange, refreshAccounts, queryClient]);
2310
+ await runPostAccountSwitchSideEffects();
2311
+ }, [oxyServices, updateSessions, setActiveSessionId, persistSessionDurably, sessionClient, syncFromClient, loginSuccess, onAuthStateChange, runPostAccountSwitchSideEffects]);
2227
2312
 
2228
2313
  const createAccountFn = useCallback(async (data: CreateAccountInput): Promise<AccountNode> => {
2229
2314
  const account = await oxyServices.createAccount(data);
@@ -2260,7 +2345,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
2260
2345
  logoutAll,
2261
2346
  switchSession: switchSessionForContext,
2262
2347
  removeSession: logout,
2263
- refreshSessions: refreshSessionsWithUser,
2348
+ refreshSessions: refreshSessionsForContext,
2264
2349
  setLanguage,
2265
2350
  getDeviceSessions,
2266
2351
  logoutAllDeviceSessions,
@@ -2301,7 +2386,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
2301
2386
  oxyServices,
2302
2387
  storageKeyPrefix,
2303
2388
  clientId,
2304
- refreshSessionsWithUser,
2389
+ refreshSessionsForContext,
2305
2390
  sessions,
2306
2391
  setLanguage,
2307
2392
  storage,