@oxyhq/services 13.2.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 (260) 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/ProfileButton.js +81 -22
  8. package/lib/commonjs/ui/components/ProfileButton.js.map +1 -1
  9. package/lib/commonjs/ui/components/ProfileMenu.js +244 -142
  10. package/lib/commonjs/ui/components/ProfileMenu.js.map +1 -1
  11. package/lib/commonjs/ui/components/SignInModal.js +4 -4
  12. package/lib/commonjs/ui/components/SignInModal.js.map +1 -1
  13. package/lib/commonjs/ui/context/OxyContext.js +506 -466
  14. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  15. package/lib/commonjs/ui/context/hooks/useAuthOperations.js +59 -57
  16. package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
  17. package/lib/commonjs/ui/context/inSessionTokenRefresh.js +23 -42
  18. package/lib/commonjs/ui/context/inSessionTokenRefresh.js.map +1 -1
  19. package/lib/commonjs/ui/context/silentSessionRestore.js +12 -21
  20. package/lib/commonjs/ui/context/silentSessionRestore.js.map +1 -1
  21. package/lib/commonjs/ui/hooks/useAuth.js +4 -3
  22. package/lib/commonjs/ui/hooks/useAuth.js.map +1 -1
  23. package/lib/commonjs/ui/hooks/useOxyAuthSession.js +12 -7
  24. package/lib/commonjs/ui/hooks/useOxyAuthSession.js.map +1 -1
  25. package/lib/commonjs/ui/hooks/useSessionManagement.js +17 -65
  26. package/lib/commonjs/ui/hooks/useSessionManagement.js.map +1 -1
  27. package/lib/commonjs/ui/hooks/useSwitchableAccounts.js +288 -0
  28. package/lib/commonjs/ui/hooks/useSwitchableAccounts.js.map +1 -0
  29. package/lib/commonjs/ui/hooks/useWebSSO.js +12 -191
  30. package/lib/commonjs/ui/hooks/useWebSSO.js.map +1 -1
  31. package/lib/commonjs/ui/index.js.map +1 -1
  32. package/lib/commonjs/ui/screens/OxyAuthScreen.js +2 -2
  33. package/lib/commonjs/ui/screens/OxyAuthScreen.js.map +1 -1
  34. package/lib/commonjs/ui/session/createSessionClient.js +28 -0
  35. package/lib/commonjs/ui/session/createSessionClient.js.map +1 -0
  36. package/lib/commonjs/ui/session/index.js +52 -0
  37. package/lib/commonjs/ui/session/index.js.map +1 -0
  38. package/lib/commonjs/ui/session/projectSessionState.js +86 -0
  39. package/lib/commonjs/ui/session/projectSessionState.js.map +1 -0
  40. package/lib/commonjs/ui/session/sessionClientHost.js +30 -0
  41. package/lib/commonjs/ui/session/sessionClientHost.js.map +1 -0
  42. package/lib/commonjs/ui/session/tokenTransport.js +77 -0
  43. package/lib/commonjs/ui/session/tokenTransport.js.map +1 -0
  44. package/lib/commonjs/ui/utils/activeAuthuser.js +35 -79
  45. package/lib/commonjs/ui/utils/activeAuthuser.js.map +1 -1
  46. package/lib/commonjs/ui/utils/sessionHelpers.js +1 -51
  47. package/lib/commonjs/ui/utils/sessionHelpers.js.map +1 -1
  48. package/lib/commonjs/utils/deviceFlowSignIn.js +70 -15
  49. package/lib/commonjs/utils/deviceFlowSignIn.js.map +1 -1
  50. package/lib/module/index.js +5 -5
  51. package/lib/module/index.js.map +1 -1
  52. package/lib/module/ui/components/AccountMenu.js +103 -65
  53. package/lib/module/ui/components/AccountMenu.js.map +1 -1
  54. package/lib/module/ui/components/AccountSwitcher.js +85 -127
  55. package/lib/module/ui/components/AccountSwitcher.js.map +1 -1
  56. package/lib/module/ui/components/ProfileButton.js +82 -23
  57. package/lib/module/ui/components/ProfileButton.js.map +1 -1
  58. package/lib/module/ui/components/ProfileMenu.js +247 -145
  59. package/lib/module/ui/components/ProfileMenu.js.map +1 -1
  60. package/lib/module/ui/components/SignInModal.js +4 -4
  61. package/lib/module/ui/components/SignInModal.js.map +1 -1
  62. package/lib/module/ui/context/OxyContext.js +510 -470
  63. package/lib/module/ui/context/OxyContext.js.map +1 -1
  64. package/lib/module/ui/context/hooks/useAuthOperations.js +61 -60
  65. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  66. package/lib/module/ui/context/inSessionTokenRefresh.js +24 -43
  67. package/lib/module/ui/context/inSessionTokenRefresh.js.map +1 -1
  68. package/lib/module/ui/context/silentSessionRestore.js +12 -20
  69. package/lib/module/ui/context/silentSessionRestore.js.map +1 -1
  70. package/lib/module/ui/hooks/useAuth.js +4 -3
  71. package/lib/module/ui/hooks/useAuth.js.map +1 -1
  72. package/lib/module/ui/hooks/useOxyAuthSession.js +12 -7
  73. package/lib/module/ui/hooks/useOxyAuthSession.js.map +1 -1
  74. package/lib/module/ui/hooks/useSessionManagement.js +18 -66
  75. package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
  76. package/lib/module/ui/hooks/useSwitchableAccounts.js +283 -0
  77. package/lib/module/ui/hooks/useSwitchableAccounts.js.map +1 -0
  78. package/lib/module/ui/hooks/useWebSSO.js +13 -192
  79. package/lib/module/ui/hooks/useWebSSO.js.map +1 -1
  80. package/lib/module/ui/index.js +0 -1
  81. package/lib/module/ui/index.js.map +1 -1
  82. package/lib/module/ui/screens/OxyAuthScreen.js +2 -2
  83. package/lib/module/ui/screens/OxyAuthScreen.js.map +1 -1
  84. package/lib/module/ui/session/createSessionClient.js +25 -0
  85. package/lib/module/ui/session/createSessionClient.js.map +1 -0
  86. package/lib/module/ui/session/index.js +17 -0
  87. package/lib/module/ui/session/index.js.map +1 -0
  88. package/lib/module/ui/session/projectSessionState.js +79 -0
  89. package/lib/module/ui/session/projectSessionState.js.map +1 -0
  90. package/lib/module/ui/session/sessionClientHost.js +26 -0
  91. package/lib/module/ui/session/sessionClientHost.js.map +1 -0
  92. package/lib/module/ui/session/tokenTransport.js +74 -0
  93. package/lib/module/ui/session/tokenTransport.js.map +1 -0
  94. package/lib/module/ui/utils/activeAuthuser.js +34 -76
  95. package/lib/module/ui/utils/activeAuthuser.js.map +1 -1
  96. package/lib/module/ui/utils/sessionHelpers.js +0 -49
  97. package/lib/module/ui/utils/sessionHelpers.js.map +1 -1
  98. package/lib/module/utils/deviceFlowSignIn.js +71 -16
  99. package/lib/module/utils/deviceFlowSignIn.js.map +1 -1
  100. package/lib/typescript/commonjs/index.d.ts +2 -3
  101. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  102. package/lib/typescript/commonjs/ui/components/AccountMenu.d.ts +4 -2
  103. package/lib/typescript/commonjs/ui/components/AccountMenu.d.ts.map +1 -1
  104. package/lib/typescript/commonjs/ui/components/AccountSwitcher.d.ts +7 -8
  105. package/lib/typescript/commonjs/ui/components/AccountSwitcher.d.ts.map +1 -1
  106. package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts +11 -1
  107. package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/ui/components/ProfileMenu.d.ts +12 -9
  109. package/lib/typescript/commonjs/ui/components/ProfileMenu.d.ts.map +1 -1
  110. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +16 -13
  111. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts +27 -3
  113. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts +22 -17
  115. package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/ui/context/silentSessionRestore.d.ts +12 -18
  117. package/lib/typescript/commonjs/ui/context/silentSessionRestore.d.ts.map +1 -1
  118. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +1 -0
  119. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  120. package/lib/typescript/commonjs/ui/hooks/useAuth.d.ts +1 -1
  121. package/lib/typescript/commonjs/ui/hooks/useAuth.d.ts.map +1 -1
  122. package/lib/typescript/commonjs/ui/hooks/useOxyAuthSession.d.ts +2 -2
  123. package/lib/typescript/commonjs/ui/hooks/useOxyAuthSession.d.ts.map +1 -1
  124. package/lib/typescript/commonjs/ui/hooks/useSessionManagement.d.ts.map +1 -1
  125. package/lib/typescript/commonjs/ui/hooks/useSwitchableAccounts.d.ts +162 -0
  126. package/lib/typescript/commonjs/ui/hooks/useSwitchableAccounts.d.ts.map +1 -0
  127. package/lib/typescript/commonjs/ui/hooks/useWebSSO.d.ts +13 -52
  128. package/lib/typescript/commonjs/ui/hooks/useWebSSO.d.ts.map +1 -1
  129. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  130. package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts +15 -0
  131. package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts.map +1 -0
  132. package/lib/typescript/commonjs/ui/session/index.d.ts +15 -0
  133. package/lib/typescript/commonjs/ui/session/index.d.ts.map +1 -0
  134. package/lib/typescript/commonjs/ui/session/projectSessionState.d.ts +45 -0
  135. package/lib/typescript/commonjs/ui/session/projectSessionState.d.ts.map +1 -0
  136. package/lib/typescript/commonjs/ui/session/sessionClientHost.d.ts +14 -0
  137. package/lib/typescript/commonjs/ui/session/sessionClientHost.d.ts.map +1 -0
  138. package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts +24 -0
  139. package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts.map +1 -0
  140. package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts +21 -29
  141. package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts.map +1 -1
  142. package/lib/typescript/commonjs/ui/utils/sessionHelpers.d.ts +0 -19
  143. package/lib/typescript/commonjs/ui/utils/sessionHelpers.d.ts.map +1 -1
  144. package/lib/typescript/commonjs/utils/deviceFlowSignIn.d.ts +51 -19
  145. package/lib/typescript/commonjs/utils/deviceFlowSignIn.d.ts.map +1 -1
  146. package/lib/typescript/module/index.d.ts +2 -3
  147. package/lib/typescript/module/index.d.ts.map +1 -1
  148. package/lib/typescript/module/ui/components/AccountMenu.d.ts +4 -2
  149. package/lib/typescript/module/ui/components/AccountMenu.d.ts.map +1 -1
  150. package/lib/typescript/module/ui/components/AccountSwitcher.d.ts +7 -8
  151. package/lib/typescript/module/ui/components/AccountSwitcher.d.ts.map +1 -1
  152. package/lib/typescript/module/ui/components/ProfileButton.d.ts +11 -1
  153. package/lib/typescript/module/ui/components/ProfileButton.d.ts.map +1 -1
  154. package/lib/typescript/module/ui/components/ProfileMenu.d.ts +12 -9
  155. package/lib/typescript/module/ui/components/ProfileMenu.d.ts.map +1 -1
  156. package/lib/typescript/module/ui/context/OxyContext.d.ts +16 -13
  157. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  158. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts +27 -3
  159. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  160. package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts +22 -17
  161. package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts.map +1 -1
  162. package/lib/typescript/module/ui/context/silentSessionRestore.d.ts +12 -18
  163. package/lib/typescript/module/ui/context/silentSessionRestore.d.ts.map +1 -1
  164. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +1 -0
  165. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  166. package/lib/typescript/module/ui/hooks/useAuth.d.ts +1 -1
  167. package/lib/typescript/module/ui/hooks/useAuth.d.ts.map +1 -1
  168. package/lib/typescript/module/ui/hooks/useOxyAuthSession.d.ts +2 -2
  169. package/lib/typescript/module/ui/hooks/useOxyAuthSession.d.ts.map +1 -1
  170. package/lib/typescript/module/ui/hooks/useSessionManagement.d.ts.map +1 -1
  171. package/lib/typescript/module/ui/hooks/useSwitchableAccounts.d.ts +162 -0
  172. package/lib/typescript/module/ui/hooks/useSwitchableAccounts.d.ts.map +1 -0
  173. package/lib/typescript/module/ui/hooks/useWebSSO.d.ts +13 -52
  174. package/lib/typescript/module/ui/hooks/useWebSSO.d.ts.map +1 -1
  175. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  176. package/lib/typescript/module/ui/session/createSessionClient.d.ts +15 -0
  177. package/lib/typescript/module/ui/session/createSessionClient.d.ts.map +1 -0
  178. package/lib/typescript/module/ui/session/index.d.ts +15 -0
  179. package/lib/typescript/module/ui/session/index.d.ts.map +1 -0
  180. package/lib/typescript/module/ui/session/projectSessionState.d.ts +45 -0
  181. package/lib/typescript/module/ui/session/projectSessionState.d.ts.map +1 -0
  182. package/lib/typescript/module/ui/session/sessionClientHost.d.ts +14 -0
  183. package/lib/typescript/module/ui/session/sessionClientHost.d.ts.map +1 -0
  184. package/lib/typescript/module/ui/session/tokenTransport.d.ts +24 -0
  185. package/lib/typescript/module/ui/session/tokenTransport.d.ts.map +1 -0
  186. package/lib/typescript/module/ui/utils/activeAuthuser.d.ts +21 -29
  187. package/lib/typescript/module/ui/utils/activeAuthuser.d.ts.map +1 -1
  188. package/lib/typescript/module/ui/utils/sessionHelpers.d.ts +0 -19
  189. package/lib/typescript/module/ui/utils/sessionHelpers.d.ts.map +1 -1
  190. package/lib/typescript/module/utils/deviceFlowSignIn.d.ts +51 -19
  191. package/lib/typescript/module/utils/deviceFlowSignIn.d.ts.map +1 -1
  192. package/package.json +4 -4
  193. package/src/index.ts +9 -9
  194. package/src/ui/components/AccountMenu.tsx +117 -70
  195. package/src/ui/components/AccountSwitcher.tsx +88 -134
  196. package/src/ui/components/ProfileButton.tsx +95 -17
  197. package/src/ui/components/ProfileMenu.tsx +263 -155
  198. package/src/ui/components/SignInModal.tsx +5 -5
  199. package/src/ui/context/OxyContext.tsx +560 -475
  200. package/src/ui/context/hooks/useAuthOperations.ts +72 -63
  201. package/src/ui/context/inSessionTokenRefresh.ts +23 -42
  202. package/src/ui/context/silentSessionRestore.ts +12 -26
  203. package/src/ui/hooks/useAuth.ts +4 -3
  204. package/src/ui/hooks/useOxyAuthSession.ts +13 -8
  205. package/src/ui/hooks/useSessionManagement.ts +16 -87
  206. package/src/ui/hooks/useSwitchableAccounts.ts +397 -0
  207. package/src/ui/hooks/useWebSSO.ts +13 -227
  208. package/src/ui/index.ts +0 -1
  209. package/src/ui/screens/OxyAuthScreen.tsx +2 -2
  210. package/src/ui/session/__tests__/createSessionClient.test.ts +41 -0
  211. package/src/ui/session/__tests__/projectSessionState.test.ts +114 -0
  212. package/src/ui/session/__tests__/sessionClientHost.test.ts +43 -0
  213. package/src/ui/session/__tests__/tokenTransport.test.ts +142 -0
  214. package/src/ui/session/createSessionClient.ts +21 -0
  215. package/src/ui/session/index.ts +19 -0
  216. package/src/ui/session/projectSessionState.ts +85 -0
  217. package/src/ui/session/sessionClientHost.ts +27 -0
  218. package/src/ui/session/tokenTransport.ts +76 -0
  219. package/src/ui/utils/activeAuthuser.ts +34 -76
  220. package/src/ui/utils/sessionHelpers.ts +0 -66
  221. package/src/utils/__tests__/deviceFlowSignIn.test.ts +243 -34
  222. package/src/utils/deviceFlowSignIn.ts +98 -21
  223. package/lib/commonjs/ui/components/accountMenuRows.js +0 -33
  224. package/lib/commonjs/ui/components/accountMenuRows.js.map +0 -1
  225. package/lib/commonjs/ui/hooks/useDeviceAccounts.js +0 -280
  226. package/lib/commonjs/ui/hooks/useDeviceAccounts.js.map +0 -1
  227. package/lib/commonjs/ui/hooks/useSessionSocket.js +0 -203
  228. package/lib/commonjs/ui/hooks/useSessionSocket.js.map +0 -1
  229. package/lib/commonjs/utils/silentGuardKey.js +0 -54
  230. package/lib/commonjs/utils/silentGuardKey.js.map +0 -1
  231. package/lib/module/ui/components/accountMenuRows.js +0 -29
  232. package/lib/module/ui/components/accountMenuRows.js.map +0 -1
  233. package/lib/module/ui/hooks/useDeviceAccounts.js +0 -276
  234. package/lib/module/ui/hooks/useDeviceAccounts.js.map +0 -1
  235. package/lib/module/ui/hooks/useSessionSocket.js +0 -199
  236. package/lib/module/ui/hooks/useSessionSocket.js.map +0 -1
  237. package/lib/module/utils/silentGuardKey.js +0 -49
  238. package/lib/module/utils/silentGuardKey.js.map +0 -1
  239. package/lib/typescript/commonjs/ui/components/accountMenuRows.d.ts +0 -34
  240. package/lib/typescript/commonjs/ui/components/accountMenuRows.d.ts.map +0 -1
  241. package/lib/typescript/commonjs/ui/hooks/useDeviceAccounts.d.ts +0 -133
  242. package/lib/typescript/commonjs/ui/hooks/useDeviceAccounts.d.ts.map +0 -1
  243. package/lib/typescript/commonjs/ui/hooks/useSessionSocket.d.ts +0 -14
  244. package/lib/typescript/commonjs/ui/hooks/useSessionSocket.d.ts.map +0 -1
  245. package/lib/typescript/commonjs/utils/silentGuardKey.d.ts +0 -31
  246. package/lib/typescript/commonjs/utils/silentGuardKey.d.ts.map +0 -1
  247. package/lib/typescript/module/ui/components/accountMenuRows.d.ts +0 -34
  248. package/lib/typescript/module/ui/components/accountMenuRows.d.ts.map +0 -1
  249. package/lib/typescript/module/ui/hooks/useDeviceAccounts.d.ts +0 -133
  250. package/lib/typescript/module/ui/hooks/useDeviceAccounts.d.ts.map +0 -1
  251. package/lib/typescript/module/ui/hooks/useSessionSocket.d.ts +0 -14
  252. package/lib/typescript/module/ui/hooks/useSessionSocket.d.ts.map +0 -1
  253. package/lib/typescript/module/utils/silentGuardKey.d.ts +0 -31
  254. package/lib/typescript/module/utils/silentGuardKey.d.ts.map +0 -1
  255. package/src/ui/components/accountMenuRows.ts +0 -48
  256. package/src/ui/hooks/useDeviceAccounts.ts +0 -344
  257. package/src/ui/hooks/useSessionSocket.ts +0 -233
  258. package/src/ui/utils/__tests__/activeAuthuser.test.ts +0 -75
  259. package/src/utils/__tests__/silentGuardKey.test.ts +0 -82
  260. package/src/utils/silentGuardKey.ts +0 -46
@@ -1,17 +1,23 @@
1
1
  /**
2
- * Web-only persistence of the active multi-account slot index.
2
+ * Web-only helpers around the retired `oxy_rt_${authuser}` multi-slot
3
+ * refresh-cookie scheme, PLUS the (unrelated, still load-bearing) deliberate
4
+ * sign-out / SSO-bounce gates.
3
5
  *
4
- * Google-style multi-account sign-in stores ONE refresh cookie per device
5
- * slot (`oxy_rt_${authuser}`, where `authuser` is an integer 0..N). The
6
- * server's `/auth/refresh-all` returns one entry per valid cookie; the
7
- * client must remember WHICH slot is currently active across reloads so
8
- * that the cold-boot snapshot resolves to the user's last selection
9
- * rather than always defaulting to slot 0.
10
- *
11
- * The persisted value is JUST the slot INDEX (a small integer) — never an
12
- * access token, refresh token, session id, or any user-identifying secret.
13
- * It is read by both `OxyContext` (cold-boot active selection) and the
14
- * session-management / auth-operations hooks (switch / logout).
6
+ * NOTE (session-sync cutover, Task 5): the device account SET is now
7
+ * server-authoritative via `SessionClient` (`@oxyhq/core`) nothing in
8
+ * `@oxyhq/services` writes the persisted active-`authuser`-slot key anymore
9
+ * (`writeActiveAuthuser`/`clearActiveAuthuser` were deleted; their only
10
+ * callers were the deleted `establishDeviceRefreshSlot` sign-in registration
11
+ * and the deleted `switchToAccount`/`switchSession` slot bookkeeping).
12
+ * {@link readActiveAuthuser} is KEPT purely as an OPTIONAL restore hint:
13
+ * `OxyContext`'s `restoreStoredSession` cold-boot step reads it only to
14
+ * backfill `clientSession.authuser` for the active session when a legacy value
15
+ * happens to be present. It NO LONGER gates whether restore is attempted the
16
+ * old guard that also required this marker made every web reload with a lapsed
17
+ * local bearer bail before any network validation (P0), and was replaced by a
18
+ * "bail only when there is nothing to restore" guard plus a valid-session
19
+ * election. Since nothing writes the key anymore, the backfill is dormant on
20
+ * fresh installs; it stays for legacy installs and costs nothing.
15
21
  *
16
22
  * Native (React Native) has no equivalent of these device-local cookies
17
23
  * and uses bearer-protected session ids directly, so these helpers no-op
@@ -30,22 +36,8 @@ import {
30
36
 
31
37
  const ACTIVE_AUTHUSER_KEY = 'oxy_active_authuser';
32
38
 
33
- /**
34
- * Safely resolve `window.localStorage`, returning `null` when it is
35
- * unavailable. The PROPERTY ACCESS itself (`window.localStorage`) can throw a
36
- * `SecurityError` synchronously in opaque-origin / sandboxed iframes or when
37
- * storage is disabled — even `typeof window.localStorage` evaluates the getter
38
- * and throws. Every read/write in this module routes through here so the getter
39
- * throw is caught once, at the source, and callers stay clean. Returns `null`
40
- * off-web and on any access failure (fail safe).
41
- */
42
- function getLocalStorage(): Storage | null {
43
- if (typeof window === 'undefined') return null;
44
- try {
45
- return window.localStorage ?? null;
46
- } catch {
47
- return null;
48
- }
39
+ function hasLocalStorage(): boolean {
40
+ return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
49
41
  }
50
42
 
51
43
  function getSessionStorage(): Storage | null {
@@ -65,12 +57,11 @@ function getSessionStorage(): Storage | null {
65
57
  * fall back to deterministic selection (lowest authuser).
66
58
  */
67
59
  export function readActiveAuthuser(): number | null {
68
- const storage = getLocalStorage();
69
- if (!storage) {
60
+ if (!hasLocalStorage()) {
70
61
  return null;
71
62
  }
72
63
  try {
73
- const raw = storage.getItem(ACTIVE_AUTHUSER_KEY);
64
+ const raw = window.localStorage.getItem(ACTIVE_AUTHUSER_KEY);
74
65
  if (raw === null) return null;
75
66
  const parsed = Number.parseInt(raw, 10);
76
67
  if (!Number.isFinite(parsed) || parsed < 0) return null;
@@ -80,50 +71,18 @@ export function readActiveAuthuser(): number | null {
80
71
  }
81
72
  }
82
73
 
83
- /**
84
- * Persist the active `authuser` slot index. No-ops on native and on any
85
- * storage failure (e.g. Safari private mode). Callers MUST NOT depend on
86
- * this succeeding — it is best-effort UX persistence, not authoritative.
87
- */
88
- export function writeActiveAuthuser(authuser: number): void {
89
- if (!Number.isFinite(authuser) || authuser < 0) return;
90
- const storage = getLocalStorage();
91
- if (!storage) return;
92
- try {
93
- storage.setItem(ACTIVE_AUTHUSER_KEY, String(authuser));
94
- } catch {
95
- // Best-effort persistence; swallow QuotaExceededError / SecurityError.
96
- }
97
- }
98
-
99
- /**
100
- * Clear the persisted active `authuser` slot index. Called on full sign-out
101
- * (logoutAll) so that the next cold boot doesn't try to resurrect a
102
- * cleared slot.
103
- */
104
- export function clearActiveAuthuser(): void {
105
- const storage = getLocalStorage();
106
- if (!storage) return;
107
- try {
108
- storage.removeItem(ACTIVE_AUTHUSER_KEY);
109
- } catch {
110
- // Best-effort.
111
- }
112
- }
113
-
114
74
  /**
115
75
  * Mark this origin as DELIBERATELY signed out (durable `localStorage`, via the
116
76
  * core {@link ssoSignedOutKey}). Called ONLY on EXPLICIT full sign-out so that
117
77
  * the next cold boot does NOT silently re-mint a session from a still-live IdP
118
- * session (`fedcm-silent` / per-apex `/auth/silent` iframe). Cleared by any
78
+ * session (the per-apex `/auth/silent` iframe cold-boot step). Cleared by any
119
79
  * deliberate sign-in (see {@link clearSignedOut}). No-ops on native / storage
120
80
  * failure (best-effort).
121
81
  */
122
82
  export function markSignedOut(): void {
123
- const storage = getLocalStorage();
124
- if (!storage) return;
83
+ if (!hasLocalStorage()) return;
125
84
  try {
126
- storage.setItem(ssoSignedOutKey(window.location.origin), '1');
85
+ window.localStorage.setItem(ssoSignedOutKey(window.location.origin), '1');
127
86
  } catch {
128
87
  // Best-effort; swallow QuotaExceededError / SecurityError (private mode).
129
88
  }
@@ -131,15 +90,14 @@ export function markSignedOut(): void {
131
90
 
132
91
  /**
133
92
  * Clear the durable deliberately-signed-out flag. Called on ANY deliberate
134
- * sign-in (password, FedCM, account switch, device claim) so a real sign-in
93
+ * sign-in (password, account switch, device claim) so a real sign-in
135
94
  * fully re-enables automatic silent restore — there is no "stuck signed out"
136
95
  * state. No-ops on native / storage failure.
137
96
  */
138
97
  export function clearSignedOut(): void {
139
- const storage = getLocalStorage();
140
- if (!storage) return;
98
+ if (!hasLocalStorage()) return;
141
99
  try {
142
- storage.removeItem(ssoSignedOutKey(window.location.origin));
100
+ window.localStorage.removeItem(ssoSignedOutKey(window.location.origin));
143
101
  } catch {
144
102
  // Best-effort.
145
103
  }
@@ -150,12 +108,14 @@ export function clearSignedOut(): void {
150
108
  * the user deliberately signed out. Reads the durable flag through the core
151
109
  * {@link silentRestoreSuppressed} predicate. Returns `false` off-web and on any
152
110
  * storage failure (fail safe toward normal restore). Used to gate the
153
- * `fedcm-silent` and `silent-iframe` cold-boot steps.
111
+ * `silent-iframe` cold-boot step.
154
112
  */
155
113
  export function isSilentRestoreSuppressed(): boolean {
156
- const storage = getLocalStorage();
157
- if (!storage) return false;
158
- return silentRestoreSuppressed(storage, window.location.origin);
114
+ // Unlike its try/catch-wrapped siblings this reads `window.location.origin`
115
+ // directly, so guard it: an RN polyfill can expose `localStorage` without a
116
+ // `location`, which would throw here. Fail safe toward normal restore.
117
+ if (!hasLocalStorage() || typeof window.location === 'undefined') return false;
118
+ return silentRestoreSuppressed(window.localStorage, window.location.origin);
159
119
  }
160
120
 
161
121
  /**
@@ -180,5 +140,3 @@ export function clearSsoBounceState(): void {
180
140
  // Best-effort; swallow SecurityError (e.g. Safari private mode).
181
141
  }
182
142
  }
183
-
184
- export { ACTIVE_AUTHUSER_KEY };
@@ -26,19 +26,6 @@ export interface FetchSessionsWithFallbackOptions {
26
26
  logger?: (message: string, error?: unknown) => void;
27
27
  }
28
28
 
29
- export interface ValidateSessionBatchOptions {
30
- useHeaderValidation?: boolean;
31
- maxConcurrency?: number;
32
- }
33
-
34
- export interface SessionValidationResult {
35
- sessionId: string;
36
- valid: boolean;
37
- user?: unknown;
38
- raw?: unknown;
39
- error?: unknown;
40
- }
41
-
42
29
  /**
43
30
  * Normalize backend session payloads into `ClientSession` objects.
44
31
  *
@@ -99,56 +86,3 @@ export const fetchSessionsWithFallback = async (
99
86
  }
100
87
  };
101
88
 
102
- /**
103
- * Validate multiple sessions concurrently with configurable concurrency.
104
- *
105
- * @param oxyServices - Oxy service instance
106
- * @param sessionIds - Session identifiers to validate
107
- * @param options - Validation options
108
- */
109
- export const validateSessionBatch = async (
110
- oxyServices: OxyServicesAny,
111
- sessionIds: string[],
112
- { useHeaderValidation = true, maxConcurrency = 5 }: ValidateSessionBatchOptions = {},
113
- ): Promise<SessionValidationResult[]> => {
114
- if (!sessionIds.length) {
115
- return [];
116
- }
117
-
118
- const uniqueSessionIds = Array.from(new Set(sessionIds));
119
- const safeConcurrency = Math.max(1, Math.min(maxConcurrency, uniqueSessionIds.length));
120
- const results: SessionValidationResult[] = [];
121
- let index = 0;
122
-
123
- const worker = async () => {
124
- while (index < uniqueSessionIds.length) {
125
- const currentIndex = index;
126
- index += 1;
127
- const sessionId = uniqueSessionIds[currentIndex];
128
-
129
- try {
130
- const validation = await oxyServices.validateSession(sessionId, { useHeaderValidation });
131
- const valid = Boolean(validation?.valid);
132
-
133
- results.push({
134
- sessionId,
135
- valid,
136
- user: validation?.user,
137
- raw: validation,
138
- });
139
- } catch (error) {
140
- results.push({
141
- sessionId,
142
- valid: false,
143
- error,
144
- });
145
- }
146
- }
147
- };
148
-
149
- await Promise.all(Array.from({ length: safeConcurrency }, worker));
150
-
151
- return results;
152
- };
153
-
154
-
@@ -1,23 +1,34 @@
1
1
  /**
2
2
  * @jest-environment node
3
3
  *
4
- * Regression coverage for the NATIVE device-flow sign-in bug:
4
+ * Regression coverage for the device-flow sign-in bugs:
5
5
  *
6
- * On native, tapping "Sign In with Oxy" opens `OxyAuthScreen`, which creates
7
- * a device-flow AuthSession and opens auth.oxy.so/authorize. The user signs
8
- * in successfully, the API authorizes the session and notifies the client via
9
- * the auth-session socket — but the screen then called `switchSession`
10
- * DIRECTLY without first claiming the bearer with the secret `sessionToken`.
11
- * The session was authorized server-side but the app never became
12
- * authenticated ("nothing happens").
6
+ * BUG 1 (native): tapping "Sign In with Oxy" opens `OxyAuthScreen`, which
7
+ * creates a device-flow AuthSession and opens auth.oxy.so/authorize. The
8
+ * user signs in successfully, the API authorizes the session and notifies
9
+ * the client via the auth-session socket — but the screen then called
10
+ * `switchSession` DIRECTLY without first claiming the bearer with the
11
+ * secret `sessionToken`. The session was authorized server-side but the app
12
+ * never became authenticated ("nothing happens").
13
13
  *
14
- * The web `SignInModal` already claimed first; the native screen did not.
15
- * `completeDeviceFlowSignIn` consolidates the claim->switch sequence so both
16
- * paths are identical. These tests pin the ORDER (claim before switch) and the
17
- * fail-fast behaviour.
14
+ * BUG 2 (session-sync cutover regression): once claiming was fixed,
15
+ * `completeDeviceFlowSignIn` still committed the claimed session through
16
+ * `switchSession`. After the session-sync cutover, `switchSession` became an
17
+ * account-SWITCH between accounts already registered on the device and
18
+ * throws `No device account found for session "..."` for a freshly-claimed
19
+ * session that was never registered — surfacing as "Authorization
20
+ * successful but failed to complete sign in." The fix commits the claimed
21
+ * session through `commitSession` (`useOxy().handleWebSession`) instead —
22
+ * the same path a fresh password sign-in uses to register the account
23
+ * into the device's session set.
24
+ *
25
+ * These tests pin the order (claim before commit), the fail-fast behaviour
26
+ * on a claim that returns no usable session, and propagation of a
27
+ * `commitSession` failure.
18
28
  */
19
29
 
20
- import type { User } from '@oxyhq/core';
30
+ import type { SessionLoginResponse, User } from '@oxyhq/core';
31
+ import { ssoStateKey, ssoGuardKey, ssoDestKey } from '@oxyhq/core';
21
32
  import {
22
33
  completeDeviceFlowSignIn,
23
34
  type DeviceFlowClient,
@@ -25,67 +36,159 @@ import {
25
36
 
26
37
  const SESSION_ID = 'session-id-123';
27
38
  const SESSION_TOKEN = 'a'.repeat(32);
28
- const USER = { id: 'user-1', username: 'nate', privacySettings: {} } as User;
39
+ const ACCESS_TOKEN = 'access-token-abc';
40
+ const USER = {
41
+ id: 'user-1',
42
+ username: 'nate',
43
+ name: { displayName: 'Nate' },
44
+ privacySettings: {},
45
+ } as User;
46
+ // The session carries only the minimal shape (`id`/`username`/`name`/`avatar`)
47
+ // — this is what `commitSession` receives, not the full claimed `User`.
48
+ const MINIMAL_USER = { id: USER.id, username: USER.username, name: USER.name, avatar: undefined };
29
49
 
30
50
  describe('completeDeviceFlowSignIn', () => {
31
- it('claims the sessionToken BEFORE switching the session', async () => {
51
+ it('claims the sessionToken BEFORE committing the session', async () => {
32
52
  const order: string[] = [];
33
53
 
34
54
  const oxyServices: DeviceFlowClient = {
55
+ requestSsoEstablishUrl: jest.fn(async () => ({ establishUrl: 'https://auth.oxy.so/sso/establish' })),
35
56
  claimSessionByToken: jest.fn(async (token: string) => {
36
57
  expect(token).toBe(SESSION_TOKEN);
37
58
  order.push('claim');
59
+ return {
60
+ accessToken: ACCESS_TOKEN,
61
+ sessionId: SESSION_ID,
62
+ deviceId: 'device-1',
63
+ expiresAt: '2026-01-01T00:00:00.000Z',
64
+ user: USER,
65
+ };
38
66
  }),
39
67
  };
40
- const switchSession = jest.fn(async (sessionId: string): Promise<User> => {
41
- expect(sessionId).toBe(SESSION_ID);
42
- order.push('switch');
43
- return USER;
68
+ const commitSession = jest.fn(async (session: SessionLoginResponse): Promise<void> => {
69
+ expect(session).toEqual({
70
+ sessionId: SESSION_ID,
71
+ deviceId: 'device-1',
72
+ expiresAt: '2026-01-01T00:00:00.000Z',
73
+ user: MINIMAL_USER,
74
+ accessToken: ACCESS_TOKEN,
75
+ });
76
+ order.push('commit');
44
77
  });
45
78
 
46
79
  const user = await completeDeviceFlowSignIn({
47
80
  oxyServices,
48
81
  sessionId: SESSION_ID,
49
82
  sessionToken: SESSION_TOKEN,
50
- switchSession,
83
+ commitSession,
51
84
  });
52
85
 
53
- expect(order).toEqual(['claim', 'switch']);
86
+ expect(order).toEqual(['claim', 'commit']);
54
87
  expect(oxyServices.claimSessionByToken).toHaveBeenCalledWith(SESSION_TOKEN);
55
- expect(switchSession).toHaveBeenCalledWith(SESSION_ID);
88
+ expect(commitSession).toHaveBeenCalledTimes(1);
56
89
  expect(user).toBe(USER);
57
90
  });
58
91
 
59
- it('does NOT switch the session when the claim fails (the native regression)', async () => {
92
+ it('falls back to the delivered sessionId/deviceId/expiresAt when the claim omits them', async () => {
93
+ const oxyServices: DeviceFlowClient = {
94
+ requestSsoEstablishUrl: jest.fn(async () => ({ establishUrl: 'https://auth.oxy.so/sso/establish' })),
95
+ claimSessionByToken: jest.fn(async () => ({
96
+ accessToken: ACCESS_TOKEN,
97
+ user: USER,
98
+ })),
99
+ };
100
+ const commitSession = jest.fn(async (): Promise<void> => {});
101
+
102
+ await completeDeviceFlowSignIn({
103
+ oxyServices,
104
+ sessionId: SESSION_ID,
105
+ sessionToken: SESSION_TOKEN,
106
+ commitSession,
107
+ });
108
+
109
+ expect(commitSession).toHaveBeenCalledWith({
110
+ sessionId: SESSION_ID,
111
+ deviceId: '',
112
+ expiresAt: '',
113
+ user: MINIMAL_USER,
114
+ accessToken: ACCESS_TOKEN,
115
+ });
116
+ });
117
+
118
+ it('does NOT commit the session when the claim fails (the original native regression)', async () => {
60
119
  const claimError = new Error('claim failed (401)');
61
120
  const oxyServices: DeviceFlowClient = {
121
+ requestSsoEstablishUrl: jest.fn(async () => ({ establishUrl: 'https://auth.oxy.so/sso/establish' })),
62
122
  claimSessionByToken: jest.fn(async () => {
63
123
  throw claimError;
64
124
  }),
65
125
  };
66
- const switchSession = jest.fn(async (): Promise<User> => USER);
126
+ const commitSession = jest.fn(async (): Promise<void> => {});
67
127
 
68
128
  await expect(
69
129
  completeDeviceFlowSignIn({
70
130
  oxyServices,
71
131
  sessionId: SESSION_ID,
72
132
  sessionToken: SESSION_TOKEN,
73
- switchSession,
133
+ commitSession,
74
134
  }),
75
135
  ).rejects.toThrow('claim failed (401)');
76
136
 
77
137
  // The bearer was never planted, so we must not attempt the bearer-protected
78
- // switch — surfacing the failure to the caller instead.
79
- expect(switchSession).not.toHaveBeenCalled();
138
+ // commit — surfacing the failure to the caller instead.
139
+ expect(commitSession).not.toHaveBeenCalled();
140
+ });
141
+
142
+ it('throws when the claim returns no accessToken (the session-sync regression guard)', async () => {
143
+ const oxyServices: DeviceFlowClient = {
144
+ requestSsoEstablishUrl: jest.fn(async () => ({ establishUrl: 'https://auth.oxy.so/sso/establish' })),
145
+ claimSessionByToken: jest.fn(async () => ({ sessionId: SESSION_ID, user: USER })),
146
+ };
147
+ const commitSession = jest.fn(async (): Promise<void> => {});
148
+
149
+ await expect(
150
+ completeDeviceFlowSignIn({
151
+ oxyServices,
152
+ sessionId: SESSION_ID,
153
+ sessionToken: SESSION_TOKEN,
154
+ commitSession,
155
+ }),
156
+ ).rejects.toThrow('Device-flow claim did not return a usable session');
157
+
158
+ expect(commitSession).not.toHaveBeenCalled();
159
+ });
160
+
161
+ it('throws when the claim returns no user', async () => {
162
+ const oxyServices: DeviceFlowClient = {
163
+ requestSsoEstablishUrl: jest.fn(async () => ({ establishUrl: 'https://auth.oxy.so/sso/establish' })),
164
+ claimSessionByToken: jest.fn(async () => ({ accessToken: ACCESS_TOKEN, sessionId: SESSION_ID })),
165
+ };
166
+ const commitSession = jest.fn(async (): Promise<void> => {});
167
+
168
+ await expect(
169
+ completeDeviceFlowSignIn({
170
+ oxyServices,
171
+ sessionId: SESSION_ID,
172
+ sessionToken: SESSION_TOKEN,
173
+ commitSession,
174
+ }),
175
+ ).rejects.toThrow('Device-flow claim did not return a usable session');
176
+
177
+ expect(commitSession).not.toHaveBeenCalled();
80
178
  });
81
179
 
82
- it('propagates a switchSession failure after a successful claim', async () => {
83
- const switchError = new Error('session invalid');
180
+ it('propagates a commitSession failure after a successful claim', async () => {
181
+ const commitError = new Error('session invalid');
84
182
  const oxyServices: DeviceFlowClient = {
85
- claimSessionByToken: jest.fn(async () => undefined),
183
+ requestSsoEstablishUrl: jest.fn(async () => ({ establishUrl: 'https://auth.oxy.so/sso/establish' })),
184
+ claimSessionByToken: jest.fn(async () => ({
185
+ accessToken: ACCESS_TOKEN,
186
+ sessionId: SESSION_ID,
187
+ user: USER,
188
+ })),
86
189
  };
87
- const switchSession = jest.fn(async (): Promise<User> => {
88
- throw switchError;
190
+ const commitSession = jest.fn(async (): Promise<void> => {
191
+ throw commitError;
89
192
  });
90
193
 
91
194
  await expect(
@@ -93,11 +196,117 @@ describe('completeDeviceFlowSignIn', () => {
93
196
  oxyServices,
94
197
  sessionId: SESSION_ID,
95
198
  sessionToken: SESSION_TOKEN,
96
- switchSession,
199
+ commitSession,
97
200
  }),
98
201
  ).rejects.toThrow('session invalid');
99
202
 
100
203
  expect(oxyServices.claimSessionByToken).toHaveBeenCalledTimes(1);
101
- expect(switchSession).toHaveBeenCalledTimes(1);
204
+ expect(commitSession).toHaveBeenCalledTimes(1);
205
+ });
206
+
207
+ describe('web durable-session establish hop', () => {
208
+ const RP_ORIGIN = 'https://accounts.oxy.so';
209
+ const RP_HREF = 'https://accounts.oxy.so/settings';
210
+ const ESTABLISH_URL =
211
+ 'https://auth.oxy.so/sso/establish?et=jwt&return_to=https%3A%2F%2Faccounts.oxy.so%2F__oxy%2Fsso-callback&state=state-1';
212
+
213
+ function webDeps(overrides: Record<string, unknown> = {}) {
214
+ const map = new Map<string, string>();
215
+ const navigated: string[] = [];
216
+ const deps = {
217
+ isWeb: () => true,
218
+ storage: {
219
+ getItem: (k: string) => (map.has(k) ? (map.get(k) as string) : null),
220
+ setItem: (k: string, v: string) => { map.set(k, v); },
221
+ },
222
+ location: { origin: RP_ORIGIN, href: RP_HREF },
223
+ navigate: (url: string) => { navigated.push(url); },
224
+ generateState: () => 'state-1',
225
+ now: () => 1_700_000_000_000,
226
+ ...overrides,
227
+ };
228
+ return { deps, map, navigated };
229
+ }
230
+
231
+ function baseClient(
232
+ requestSsoEstablishUrl: DeviceFlowClient['requestSsoEstablishUrl'],
233
+ ): DeviceFlowClient {
234
+ return {
235
+ requestSsoEstablishUrl,
236
+ claimSessionByToken: jest.fn(async () => ({
237
+ accessToken: ACCESS_TOKEN,
238
+ sessionId: SESSION_ID,
239
+ deviceId: 'device-1',
240
+ expiresAt: '2026-01-01T00:00:00.000Z',
241
+ user: USER,
242
+ })),
243
+ };
244
+ }
245
+
246
+ it('fires the establish hop on web: requests the URL with the RP origin + state and navigates once', async () => {
247
+ const requestSsoEstablishUrl = jest.fn(async (origin: string, state: string) => {
248
+ expect(origin).toBe(RP_ORIGIN);
249
+ expect(state).toBe('state-1');
250
+ return { establishUrl: ESTABLISH_URL };
251
+ });
252
+ const oxyServices = baseClient(requestSsoEstablishUrl);
253
+ const { deps, map, navigated } = webDeps();
254
+
255
+ const user = await completeDeviceFlowSignIn({
256
+ oxyServices,
257
+ sessionId: SESSION_ID,
258
+ sessionToken: SESSION_TOKEN,
259
+ commitSession: jest.fn(async () => {}),
260
+ establishDeps: deps,
261
+ });
262
+
263
+ expect(user).toBe(USER);
264
+ expect(requestSsoEstablishUrl).toHaveBeenCalledTimes(1);
265
+ expect(navigated).toEqual([ESTABLISH_URL]);
266
+ // Bounce state persisted so the post-bounce `sso-return` step validates it.
267
+ expect(map.get(ssoStateKey(RP_ORIGIN))).toBe('state-1');
268
+ expect(map.get(ssoGuardKey(RP_ORIGIN))).toBe(String(1_700_000_000_000));
269
+ expect(map.get(ssoDestKey(RP_ORIGIN))).toBe(RP_HREF);
270
+ });
271
+
272
+ it('does NOT fire the hop on native (no request, no navigation)', async () => {
273
+ const requestSsoEstablishUrl = jest.fn(async () => ({ establishUrl: ESTABLISH_URL }));
274
+ const oxyServices = baseClient(requestSsoEstablishUrl);
275
+ const { deps, navigated } = webDeps({ isWeb: () => false });
276
+
277
+ await completeDeviceFlowSignIn({
278
+ oxyServices,
279
+ sessionId: SESSION_ID,
280
+ sessionToken: SESSION_TOKEN,
281
+ commitSession: jest.fn(async () => {}),
282
+ establishDeps: deps,
283
+ });
284
+
285
+ expect(requestSsoEstablishUrl).not.toHaveBeenCalled();
286
+ expect(navigated).toEqual([]);
287
+ });
288
+
289
+ it('completes sign-in even if the establish request fails — exactly one attempt, no navigation', async () => {
290
+ const requestSsoEstablishUrl = jest.fn(async () => {
291
+ throw new Error('403 unapproved');
292
+ });
293
+ const oxyServices = baseClient(requestSsoEstablishUrl);
294
+ const { deps, map, navigated } = webDeps();
295
+
296
+ const user = await completeDeviceFlowSignIn({
297
+ oxyServices,
298
+ sessionId: SESSION_ID,
299
+ sessionToken: SESSION_TOKEN,
300
+ commitSession: jest.fn(async () => {}),
301
+ establishDeps: deps,
302
+ });
303
+
304
+ // Sign-in still completed (the primitive is total — never throws).
305
+ expect(user).toBe(USER);
306
+ expect(requestSsoEstablishUrl).toHaveBeenCalledTimes(1);
307
+ expect(navigated).toEqual([]);
308
+ // No stale bounce state left behind on failure.
309
+ expect(map.size).toBe(0);
310
+ });
102
311
  });
103
312
  });