@oxy.so/core 1.18.0 → 3.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 (662) hide show
  1. package/README.md +13 -7
  2. package/dist/cjs/HttpService.js +189 -112
  3. package/dist/cjs/OxyServices.errors.js +48 -1
  4. package/dist/cjs/OxyServices.js +263 -60
  5. package/dist/cjs/api/accounts.js +231 -0
  6. package/dist/cjs/api/agency.js +102 -0
  7. package/dist/cjs/api/appData.js +45 -0
  8. package/dist/cjs/api/apps.js +197 -0
  9. package/dist/cjs/api/assets.js +329 -0
  10. package/dist/cjs/api/auth.js +900 -0
  11. package/dist/cjs/api/billing.js +44 -0
  12. package/dist/cjs/api/civic.js +289 -0
  13. package/dist/cjs/api/contacts.js +16 -0
  14. package/dist/cjs/api/devices.js +351 -0
  15. package/dist/cjs/api/follows.js +240 -0
  16. package/dist/cjs/api/identity.js +569 -0
  17. package/dist/cjs/api/linkedAccounts.js +48 -0
  18. package/dist/cjs/api/nodes.js +75 -0
  19. package/dist/cjs/api/notifications.js +75 -0
  20. package/dist/cjs/api/privacy.js +105 -0
  21. package/dist/cjs/api/reputation.js +74 -0
  22. package/dist/cjs/api/session.js +177 -0
  23. package/dist/cjs/api/store.js +199 -0
  24. package/dist/cjs/api/topics.js +66 -0
  25. package/dist/cjs/api/users.js +309 -0
  26. package/dist/cjs/boot/sessionColdBoot.js +4 -4
  27. package/dist/cjs/civic/index.js +16 -0
  28. package/dist/cjs/civic/payloads.js +156 -0
  29. package/dist/cjs/client/context.js +2 -0
  30. package/dist/cjs/crypto/identityLink.js +5 -5
  31. package/dist/cjs/crypto/index.js +44 -0
  32. package/dist/cjs/crypto/internal.js +37 -0
  33. package/dist/cjs/crypto/keyManager.js +2 -2
  34. package/dist/cjs/crypto/polyfill.js +9 -80
  35. package/dist/cjs/crypto/recoveryPhrase.js +1 -0
  36. package/dist/cjs/crypto/registrationPow.js +1 -1
  37. package/dist/cjs/crypto/signatureService.js +1 -0
  38. package/dist/cjs/i18n/accountCategoryLabels.js +3 -3
  39. package/dist/cjs/i18n/accountRoleLabels.js +7 -7
  40. package/dist/cjs/i18n/index.js +130 -52
  41. package/dist/cjs/i18n/locales/{ar-SA.json → ar-SA.js} +134 -52
  42. package/dist/cjs/i18n/locales/{ca-ES.json → ca-ES.js} +134 -52
  43. package/dist/{esm/i18n/locales/de-DE.json → cjs/i18n/locales/de-DE.js} +136 -54
  44. package/dist/{esm/i18n/locales/en-US.json → cjs/i18n/locales/en-US.js} +163 -245
  45. package/dist/{esm/i18n/locales/es-ES.json → cjs/i18n/locales/es-ES.js} +163 -245
  46. package/dist/cjs/i18n/locales/{fr-FR.json → fr-FR.js} +134 -52
  47. package/dist/cjs/i18n/locales/{it-IT.json → it-IT.js} +134 -52
  48. package/dist/{esm/i18n/locales/ja-JP.json → cjs/i18n/locales/ja-JP.js} +134 -52
  49. package/dist/cjs/i18n/locales/{ko-KR.json → ko-KR.js} +134 -52
  50. package/dist/{esm/i18n/locales/pt-PT.json → cjs/i18n/locales/pt-PT.js} +136 -54
  51. package/dist/{esm/i18n/locales/zh-CN.json → cjs/i18n/locales/zh-CN.js} +135 -53
  52. package/dist/cjs/i18n/reputationCategoryLabels.js +2 -2
  53. package/dist/cjs/i18n/trustTierLabels.js +2 -2
  54. package/dist/cjs/index.js +55 -263
  55. package/dist/cjs/inference/index.js +21 -0
  56. package/dist/cjs/server/OxyServer.js +521 -0
  57. package/dist/cjs/server/auth.js +2 -2
  58. package/dist/cjs/server/index.js +23 -6
  59. package/dist/cjs/server/middleware.js +1154 -0
  60. package/dist/cjs/server/namespaces.js +214 -0
  61. package/dist/cjs/server/rateLimit.js +2 -2
  62. package/dist/cjs/server/requesterAssertion.js +5 -5
  63. package/dist/cjs/server/securityHeaders.js +3 -3
  64. package/dist/cjs/server/traffic.js +7 -6
  65. package/dist/cjs/server/trafficNodeHttp.js +9 -3
  66. package/dist/cjs/server/userInvalidation.js +5 -5
  67. package/dist/cjs/session/accountDialogController.js +23 -23
  68. package/dist/cjs/session/authStateStore.js +2 -2
  69. package/dist/cjs/session/identitySession.js +3 -3
  70. package/dist/cjs/session/index.js +131 -0
  71. package/dist/cjs/session/refresh.js +37 -30
  72. package/dist/cjs/session/sessionClientHost.js +5 -5
  73. package/dist/cjs/session/sharedDeviceCredential.js +3 -2
  74. package/dist/cjs/shared/utils/networkUtils.js +2 -2
  75. package/dist/cjs/utils/accountCacheSweep.js +11 -15
  76. package/dist/cjs/utils/asyncUtils.js +22 -163
  77. package/dist/cjs/utils/authHelpers.js +2 -2
  78. package/dist/cjs/utils/authWebUrl.js +3 -4
  79. package/dist/cjs/utils/avatarUtils.js +2 -2
  80. package/dist/cjs/utils/cache.js +32 -2
  81. package/dist/cjs/utils/cacheKey.js +5 -10
  82. package/dist/cjs/utils/deviceManager.js +17 -16
  83. package/dist/cjs/utils/edgeRegion.js +2 -3
  84. package/dist/cjs/utils/identityCacheSweep.js +6 -10
  85. package/dist/cjs/utils/oxyServiceEnvironment.js +3 -7
  86. package/dist/cjs/utils/requestUtils.js +40 -33
  87. package/dist/cjs/utils/sessionUtils.js +0 -2
  88. package/dist/cjs/utils/tokenClaims.js +34 -0
  89. package/dist/esm/HttpService.js +190 -113
  90. package/dist/esm/OxyServices.errors.js +45 -0
  91. package/dist/esm/OxyServices.js +262 -57
  92. package/dist/esm/api/accounts.js +218 -0
  93. package/dist/esm/api/agency.js +95 -0
  94. package/dist/esm/api/appData.js +40 -0
  95. package/dist/esm/api/apps.js +191 -0
  96. package/dist/esm/api/assets.js +324 -0
  97. package/dist/esm/api/auth.js +858 -0
  98. package/dist/esm/api/billing.js +40 -0
  99. package/dist/esm/api/civic.js +250 -0
  100. package/dist/esm/api/contacts.js +12 -0
  101. package/dist/esm/api/devices.js +346 -0
  102. package/dist/esm/api/follows.js +236 -0
  103. package/dist/esm/api/identity.js +527 -0
  104. package/dist/esm/api/linkedAccounts.js +44 -0
  105. package/dist/esm/api/nodes.js +71 -0
  106. package/dist/esm/api/notifications.js +71 -0
  107. package/dist/esm/api/privacy.js +101 -0
  108. package/dist/esm/api/reputation.js +70 -0
  109. package/dist/esm/api/session.js +173 -0
  110. package/dist/esm/api/store.js +192 -0
  111. package/dist/esm/api/topics.js +62 -0
  112. package/dist/esm/api/users.js +272 -0
  113. package/dist/esm/boot/sessionColdBoot.js +4 -4
  114. package/dist/esm/civic/index.js +10 -0
  115. package/dist/esm/civic/payloads.js +118 -0
  116. package/dist/esm/client/context.js +1 -0
  117. package/dist/esm/crypto/identityLink.js +5 -5
  118. package/dist/esm/crypto/index.js +24 -0
  119. package/dist/esm/crypto/internal.js +19 -0
  120. package/dist/esm/crypto/keyManager.js +2 -2
  121. package/dist/esm/crypto/polyfill.js +10 -81
  122. package/dist/esm/crypto/recoveryPhrase.js +1 -0
  123. package/dist/esm/crypto/registrationPow.js +1 -1
  124. package/dist/esm/crypto/signatureService.js +1 -0
  125. package/dist/esm/i18n/accountCategoryLabels.js +2 -2
  126. package/dist/esm/i18n/accountRoleLabels.js +1 -1
  127. package/dist/esm/i18n/index.js +93 -52
  128. package/dist/esm/i18n/locales/{ar-SA.json → ar-SA.js} +132 -52
  129. package/dist/esm/i18n/locales/{ca-ES.json → ca-ES.js} +132 -52
  130. package/dist/{cjs/i18n/locales/de-DE.json → esm/i18n/locales/de-DE.js} +134 -54
  131. package/dist/{cjs/i18n/locales/en-US.json → esm/i18n/locales/en-US.js} +161 -245
  132. package/dist/{cjs/i18n/locales/es-ES.json → esm/i18n/locales/es-ES.js} +161 -245
  133. package/dist/esm/i18n/locales/{fr-FR.json → fr-FR.js} +132 -52
  134. package/dist/esm/i18n/locales/{it-IT.json → it-IT.js} +132 -52
  135. package/dist/{cjs/i18n/locales/ja-JP.json → esm/i18n/locales/ja-JP.js} +132 -52
  136. package/dist/esm/i18n/locales/{ko-KR.json → ko-KR.js} +132 -52
  137. package/dist/{cjs/i18n/locales/pt-PT.json → esm/i18n/locales/pt-PT.js} +134 -54
  138. package/dist/{cjs/i18n/locales/zh-CN.json → esm/i18n/locales/zh-CN.js} +133 -53
  139. package/dist/esm/i18n/reputationCategoryLabels.js +1 -1
  140. package/dist/esm/i18n/trustTierLabels.js +1 -1
  141. package/dist/esm/index.js +30 -166
  142. package/dist/esm/inference/index.js +13 -0
  143. package/dist/esm/server/OxyServer.js +516 -0
  144. package/dist/esm/server/auth.js +2 -2
  145. package/dist/esm/server/index.js +10 -5
  146. package/dist/esm/server/middleware.js +1147 -0
  147. package/dist/esm/server/namespaces.js +206 -0
  148. package/dist/esm/server/rateLimit.js +2 -2
  149. package/dist/esm/server/requesterAssertion.js +5 -5
  150. package/dist/esm/server/securityHeaders.js +3 -3
  151. package/dist/esm/server/traffic.js +7 -6
  152. package/dist/esm/server/trafficNodeHttp.js +9 -3
  153. package/dist/esm/server/userInvalidation.js +5 -5
  154. package/dist/esm/session/accountDialogController.js +23 -23
  155. package/dist/esm/session/authStateStore.js +2 -2
  156. package/dist/esm/session/identitySession.js +3 -3
  157. package/dist/esm/session/index.js +82 -0
  158. package/dist/esm/session/refresh.js +37 -30
  159. package/dist/esm/session/sessionClientHost.js +5 -5
  160. package/dist/esm/session/sharedDeviceCredential.js +3 -2
  161. package/dist/esm/shared/utils/networkUtils.js +2 -2
  162. package/dist/esm/utils/accountCacheSweep.js +11 -15
  163. package/dist/esm/utils/asyncUtils.js +22 -151
  164. package/dist/esm/utils/authHelpers.js +2 -2
  165. package/dist/esm/utils/authWebUrl.js +3 -4
  166. package/dist/esm/utils/avatarUtils.js +2 -2
  167. package/dist/esm/utils/cache.js +32 -2
  168. package/dist/esm/utils/cacheKey.js +5 -10
  169. package/dist/esm/utils/deviceManager.js +17 -16
  170. package/dist/esm/utils/edgeRegion.js +1 -2
  171. package/dist/esm/utils/identityCacheSweep.js +6 -10
  172. package/dist/esm/utils/oxyServiceEnvironment.js +3 -7
  173. package/dist/esm/utils/requestUtils.js +40 -33
  174. package/dist/esm/utils/sessionUtils.js +0 -2
  175. package/dist/esm/utils/tokenClaims.js +31 -0
  176. package/dist/types/HttpService.d.ts +47 -9
  177. package/dist/types/OxyServices.d.ts +160 -131
  178. package/dist/types/OxyServices.errors.d.ts +42 -0
  179. package/dist/types/api/accounts.d.ts +415 -0
  180. package/dist/types/api/agency.d.ts +135 -0
  181. package/dist/types/api/appData.d.ts +27 -0
  182. package/dist/types/api/apps.d.ts +433 -0
  183. package/dist/types/api/assets.d.ts +174 -0
  184. package/dist/types/api/auth.d.ts +766 -0
  185. package/dist/types/api/billing.d.ts +113 -0
  186. package/dist/types/api/civic.d.ts +203 -0
  187. package/dist/types/api/contacts.d.ts +31 -0
  188. package/{src/mixins/OxyServices.deviceBoot.ts → dist/types/api/devices.d.ts} +130 -110
  189. package/dist/types/api/follows.d.ts +236 -0
  190. package/dist/types/api/identity.d.ts +275 -0
  191. package/dist/types/api/linkedAccounts.d.ts +48 -0
  192. package/dist/types/api/nodes.d.ts +102 -0
  193. package/dist/types/api/notifications.d.ts +77 -0
  194. package/dist/types/api/privacy.d.ts +43 -0
  195. package/dist/types/api/reputation.d.ts +50 -0
  196. package/dist/types/api/session.d.ts +85 -0
  197. package/dist/types/api/store.d.ts +278 -0
  198. package/dist/types/api/topics.d.ts +34 -0
  199. package/dist/types/api/users.d.ts +134 -0
  200. package/dist/types/boot/sessionColdBoot.d.ts +1 -1
  201. package/dist/types/civic/index.d.ts +10 -0
  202. package/dist/types/civic/payloads.d.ts +83 -0
  203. package/dist/types/client/context.d.ts +33 -0
  204. package/dist/types/crypto/identityLink.d.ts +5 -5
  205. package/dist/types/crypto/index.d.ts +24 -0
  206. package/dist/types/crypto/internal.d.ts +19 -0
  207. package/dist/types/crypto/polyfill.d.ts +6 -9
  208. package/dist/types/crypto/registrationPow.d.ts +1 -1
  209. package/dist/types/i18n/accountCategoryLabels.d.ts +1 -1
  210. package/dist/types/i18n/accountRoleLabels.d.ts +1 -1
  211. package/dist/types/i18n/index.d.ts +20 -1
  212. package/dist/types/i18n/locales/ar-SA.d.ts +386 -0
  213. package/dist/types/i18n/locales/ca-ES.d.ts +386 -0
  214. package/dist/types/i18n/locales/de-DE.d.ts +386 -0
  215. package/dist/types/i18n/locales/en-US.d.ts +2034 -0
  216. package/dist/types/i18n/locales/es-ES.d.ts +2034 -0
  217. package/dist/types/i18n/locales/fr-FR.d.ts +386 -0
  218. package/dist/types/i18n/locales/it-IT.d.ts +386 -0
  219. package/dist/types/i18n/locales/ja-JP.d.ts +386 -0
  220. package/dist/types/i18n/locales/ko-KR.d.ts +386 -0
  221. package/dist/types/i18n/locales/pt-PT.d.ts +386 -0
  222. package/dist/types/i18n/locales/zh-CN.d.ts +386 -0
  223. package/dist/types/index.d.ts +46 -95
  224. package/dist/types/inference/index.d.ts +22 -0
  225. package/dist/types/models/interfaces.d.ts +1 -1
  226. package/dist/types/server/OxyServer.d.ts +183 -0
  227. package/dist/types/server/auth.d.ts +10 -7
  228. package/dist/types/server/index.d.ts +10 -4
  229. package/dist/types/server/middleware.d.ts +291 -0
  230. package/dist/types/server/namespaces.d.ts +98 -0
  231. package/dist/types/server/rateLimit.d.ts +7 -5
  232. package/dist/types/server/requesterAssertion.d.ts +14 -11
  233. package/dist/types/server/securityHeaders.d.ts +4 -5
  234. package/dist/types/server/userInvalidation.d.ts +10 -8
  235. package/dist/types/session/accountDelegationAccess.d.ts +1 -1
  236. package/dist/types/session/accountDialogController.d.ts +8 -8
  237. package/dist/types/session/accountSwitchTargets.d.ts +1 -1
  238. package/dist/types/session/authStateStore.d.ts +7 -6
  239. package/dist/types/session/index.d.ts +40 -0
  240. package/dist/types/utils/accountCacheSweep.d.ts +7 -10
  241. package/dist/types/utils/apiUtils.d.ts +3 -3
  242. package/dist/types/utils/asyncUtils.d.ts +11 -58
  243. package/dist/types/utils/authWebUrl.d.ts +3 -4
  244. package/dist/types/utils/avatarUtils.d.ts +5 -6
  245. package/dist/types/utils/cache.d.ts +11 -3
  246. package/dist/types/utils/commonsApproval.d.ts +1 -1
  247. package/dist/types/utils/edgeRegion.d.ts +1 -2
  248. package/dist/types/utils/identityCacheSweep.d.ts +6 -4
  249. package/dist/types/utils/oauthPkce.d.ts +2 -2
  250. package/dist/types/utils/oxyServiceEnvironment.d.ts +3 -7
  251. package/dist/types/utils/requestUtils.d.ts +18 -14
  252. package/dist/types/utils/tokenClaims.d.ts +9 -0
  253. package/package.json +68 -7
  254. package/src/HttpService.ts +201 -132
  255. package/src/OxyServices.errors.ts +73 -0
  256. package/src/OxyServices.ts +289 -164
  257. package/src/api/accounts.ts +579 -0
  258. package/src/api/agency.ts +277 -0
  259. package/src/api/appData.ts +63 -0
  260. package/src/api/apps.ts +596 -0
  261. package/src/api/assets.ts +416 -0
  262. package/src/api/auth.ts +1457 -0
  263. package/src/api/billing.ts +144 -0
  264. package/src/api/civic.ts +428 -0
  265. package/src/api/contacts.ts +41 -0
  266. package/src/api/devices.ts +438 -0
  267. package/src/api/follows.ts +414 -0
  268. package/src/api/identity.ts +727 -0
  269. package/src/api/linkedAccounts.ts +78 -0
  270. package/src/api/nodes.ts +169 -0
  271. package/src/api/notifications.ts +135 -0
  272. package/src/api/privacy.ts +120 -0
  273. package/src/api/reputation.ts +134 -0
  274. package/src/api/session.ts +210 -0
  275. package/src/api/store.ts +435 -0
  276. package/src/api/topics.ts +83 -0
  277. package/src/api/users.ts +357 -0
  278. package/src/boot/sessionColdBoot.ts +4 -4
  279. package/src/civic/index.ts +12 -0
  280. package/src/civic/payloads.ts +175 -0
  281. package/src/client/context.ts +34 -0
  282. package/src/crypto/identityLink.ts +5 -5
  283. package/src/crypto/index.ts +39 -0
  284. package/src/crypto/internal.ts +20 -0
  285. package/src/crypto/keyManager.ts +2 -2
  286. package/src/crypto/polyfill.ts +9 -89
  287. package/src/crypto/recoveryPhrase.ts +1 -0
  288. package/src/crypto/registrationPow.ts +1 -1
  289. package/src/crypto/signatureService.ts +1 -0
  290. package/src/i18n/accountCategoryLabels.ts +2 -2
  291. package/src/i18n/accountRoleLabels.ts +2 -2
  292. package/src/i18n/index.ts +105 -54
  293. package/src/i18n/locales/{ar-SA.json → ar-SA.ts} +132 -52
  294. package/src/i18n/locales/{ca-ES.json → ca-ES.ts} +132 -52
  295. package/{dist/cjs/i18n/locales/locales/de-DE.json → src/i18n/locales/de-DE.ts} +134 -54
  296. package/src/i18n/locales/{en-US.json → en-US.ts} +161 -245
  297. package/{dist/cjs/i18n/locales/locales/es-ES.json → src/i18n/locales/es-ES.ts} +161 -245
  298. package/src/i18n/locales/{fr-FR.json → fr-FR.ts} +132 -52
  299. package/src/i18n/locales/{it-IT.json → it-IT.ts} +132 -52
  300. package/{dist/cjs/i18n/locales/locales/ja-JP.json → src/i18n/locales/ja-JP.ts} +132 -52
  301. package/src/i18n/locales/{ko-KR.json → ko-KR.ts} +132 -52
  302. package/src/i18n/locales/{pt-PT.json → pt-PT.ts} +134 -54
  303. package/src/i18n/locales/{zh-CN.json → zh-CN.ts} +133 -53
  304. package/src/i18n/reputationCategoryLabels.ts +1 -1
  305. package/src/i18n/trustTierLabels.ts +1 -1
  306. package/src/index.ts +170 -501
  307. package/src/inference/index.ts +48 -0
  308. package/src/models/interfaces.ts +1 -1
  309. package/src/server/OxyServer.ts +637 -0
  310. package/src/server/auth.ts +12 -8
  311. package/src/server/index.ts +28 -5
  312. package/src/server/middleware.ts +1520 -0
  313. package/src/server/namespaces.ts +257 -0
  314. package/src/server/rateLimit.ts +10 -9
  315. package/src/server/requesterAssertion.ts +13 -10
  316. package/src/server/securityHeaders.ts +7 -8
  317. package/src/server/traffic.ts +6 -6
  318. package/src/server/trafficNodeHttp.ts +7 -3
  319. package/src/server/userInvalidation.ts +9 -9
  320. package/src/session/accountDelegationAccess.ts +1 -1
  321. package/src/session/accountDialogController.ts +24 -24
  322. package/src/session/accountSwitchTargets.ts +1 -1
  323. package/src/session/authStateStore.ts +9 -8
  324. package/src/session/identitySession.ts +3 -3
  325. package/src/session/index.ts +210 -0
  326. package/src/session/refresh.ts +36 -30
  327. package/src/session/sessionClientHost.ts +5 -5
  328. package/src/session/sharedDeviceCredential.ts +3 -2
  329. package/src/shared/utils/networkUtils.ts +2 -2
  330. package/src/utils/accountCacheSweep.ts +11 -15
  331. package/src/utils/apiUtils.ts +3 -3
  332. package/src/utils/asyncUtils.ts +38 -235
  333. package/src/utils/authHelpers.ts +2 -2
  334. package/src/utils/authWebUrl.ts +3 -4
  335. package/src/utils/avatarUtils.ts +4 -7
  336. package/src/utils/cache.ts +36 -5
  337. package/src/utils/cacheKey.ts +5 -9
  338. package/src/utils/commonsApproval.ts +1 -1
  339. package/src/utils/deviceManager.ts +17 -16
  340. package/src/utils/edgeRegion.ts +1 -2
  341. package/src/utils/identityCacheSweep.ts +7 -12
  342. package/src/utils/oauthPkce.ts +2 -2
  343. package/src/utils/oxyServiceEnvironment.ts +3 -7
  344. package/src/utils/requestUtils.ts +48 -44
  345. package/src/utils/sessionUtils.ts +0 -2
  346. package/src/utils/tokenClaims.ts +38 -0
  347. package/dist/cjs/.tsbuildinfo +0 -1
  348. package/dist/cjs/OxyServices.base.js +0 -392
  349. package/dist/cjs/i18n/locales/locales/ar-SA.json +0 -306
  350. package/dist/cjs/i18n/locales/locales/ca-ES.json +0 -306
  351. package/dist/cjs/i18n/locales/locales/en-US.json +0 -2127
  352. package/dist/cjs/i18n/locales/locales/fr-FR.json +0 -306
  353. package/dist/cjs/i18n/locales/locales/it-IT.json +0 -306
  354. package/dist/cjs/i18n/locales/locales/ko-KR.json +0 -306
  355. package/dist/cjs/i18n/locales/locales/pt-PT.json +0 -306
  356. package/dist/cjs/i18n/locales/locales/zh-CN.json +0 -306
  357. package/dist/cjs/mixins/OxyServices.accounts.js +0 -555
  358. package/dist/cjs/mixins/OxyServices.agency.js +0 -154
  359. package/dist/cjs/mixins/OxyServices.analytics.js +0 -49
  360. package/dist/cjs/mixins/OxyServices.appData.js +0 -108
  361. package/dist/cjs/mixins/OxyServices.assets.js +0 -854
  362. package/dist/cjs/mixins/OxyServices.auth.js +0 -1440
  363. package/dist/cjs/mixins/OxyServices.chains.js +0 -73
  364. package/dist/cjs/mixins/OxyServices.civic.js +0 -611
  365. package/dist/cjs/mixins/OxyServices.connectedApps.js +0 -95
  366. package/dist/cjs/mixins/OxyServices.contacts.js +0 -50
  367. package/dist/cjs/mixins/OxyServices.deviceBoot.js +0 -191
  368. package/dist/cjs/mixins/OxyServices.devices.js +0 -116
  369. package/dist/cjs/mixins/OxyServices.features.js +0 -335
  370. package/dist/cjs/mixins/OxyServices.followGraph.js +0 -221
  371. package/dist/cjs/mixins/OxyServices.identity.js +0 -562
  372. package/dist/cjs/mixins/OxyServices.identityBackup.js +0 -172
  373. package/dist/cjs/mixins/OxyServices.inference.js +0 -59
  374. package/dist/cjs/mixins/OxyServices.language.js +0 -125
  375. package/dist/cjs/mixins/OxyServices.linkedAccounts.js +0 -79
  376. package/dist/cjs/mixins/OxyServices.location.js +0 -43
  377. package/dist/cjs/mixins/OxyServices.nodes.js +0 -175
  378. package/dist/cjs/mixins/OxyServices.notifications.js +0 -63
  379. package/dist/cjs/mixins/OxyServices.payment.js +0 -158
  380. package/dist/cjs/mixins/OxyServices.privacy.js +0 -199
  381. package/dist/cjs/mixins/OxyServices.reputation.js +0 -289
  382. package/dist/cjs/mixins/OxyServices.security.js +0 -89
  383. package/dist/cjs/mixins/OxyServices.store.js +0 -266
  384. package/dist/cjs/mixins/OxyServices.topics.js +0 -137
  385. package/dist/cjs/mixins/OxyServices.user.js +0 -954
  386. package/dist/cjs/mixins/OxyServices.utility.js +0 -1402
  387. package/dist/cjs/mixins/index.js +0 -136
  388. package/dist/cjs/mixins/mixinHelpers.js +0 -53
  389. package/dist/cjs/utils/webauthnOrigin.js +0 -51
  390. package/dist/esm/.tsbuildinfo +0 -1
  391. package/dist/esm/OxyServices.base.js +0 -388
  392. package/dist/esm/i18n/locales/locales/ar-SA.json +0 -306
  393. package/dist/esm/i18n/locales/locales/ca-ES.json +0 -306
  394. package/dist/esm/i18n/locales/locales/de-DE.json +0 -306
  395. package/dist/esm/i18n/locales/locales/en-US.json +0 -2127
  396. package/dist/esm/i18n/locales/locales/es-ES.json +0 -2127
  397. package/dist/esm/i18n/locales/locales/fr-FR.json +0 -306
  398. package/dist/esm/i18n/locales/locales/it-IT.json +0 -306
  399. package/dist/esm/i18n/locales/locales/ja-JP.json +0 -306
  400. package/dist/esm/i18n/locales/locales/ko-KR.json +0 -306
  401. package/dist/esm/i18n/locales/locales/pt-PT.json +0 -306
  402. package/dist/esm/i18n/locales/locales/zh-CN.json +0 -306
  403. package/dist/esm/mixins/OxyServices.accounts.js +0 -543
  404. package/dist/esm/mixins/OxyServices.agency.js +0 -151
  405. package/dist/esm/mixins/OxyServices.analytics.js +0 -46
  406. package/dist/esm/mixins/OxyServices.appData.js +0 -103
  407. package/dist/esm/mixins/OxyServices.assets.js +0 -851
  408. package/dist/esm/mixins/OxyServices.auth.js +0 -1400
  409. package/dist/esm/mixins/OxyServices.chains.js +0 -70
  410. package/dist/esm/mixins/OxyServices.civic.js +0 -605
  411. package/dist/esm/mixins/OxyServices.connectedApps.js +0 -92
  412. package/dist/esm/mixins/OxyServices.contacts.js +0 -47
  413. package/dist/esm/mixins/OxyServices.deviceBoot.js +0 -186
  414. package/dist/esm/mixins/OxyServices.devices.js +0 -113
  415. package/dist/esm/mixins/OxyServices.features.js +0 -332
  416. package/dist/esm/mixins/OxyServices.followGraph.js +0 -218
  417. package/dist/esm/mixins/OxyServices.identity.js +0 -558
  418. package/dist/esm/mixins/OxyServices.identityBackup.js +0 -169
  419. package/dist/esm/mixins/OxyServices.inference.js +0 -56
  420. package/dist/esm/mixins/OxyServices.language.js +0 -122
  421. package/dist/esm/mixins/OxyServices.linkedAccounts.js +0 -76
  422. package/dist/esm/mixins/OxyServices.location.js +0 -40
  423. package/dist/esm/mixins/OxyServices.nodes.js +0 -172
  424. package/dist/esm/mixins/OxyServices.notifications.js +0 -60
  425. package/dist/esm/mixins/OxyServices.payment.js +0 -155
  426. package/dist/esm/mixins/OxyServices.privacy.js +0 -196
  427. package/dist/esm/mixins/OxyServices.reputation.js +0 -286
  428. package/dist/esm/mixins/OxyServices.security.js +0 -86
  429. package/dist/esm/mixins/OxyServices.store.js +0 -263
  430. package/dist/esm/mixins/OxyServices.topics.js +0 -134
  431. package/dist/esm/mixins/OxyServices.user.js +0 -951
  432. package/dist/esm/mixins/OxyServices.utility.js +0 -1397
  433. package/dist/esm/mixins/index.js +0 -133
  434. package/dist/esm/mixins/mixinHelpers.js +0 -48
  435. package/dist/esm/utils/webauthnOrigin.js +0 -48
  436. package/dist/types/.tsbuildinfo +0 -1
  437. package/dist/types/OxyServices.base.d.ts +0 -189
  438. package/dist/types/mixins/OxyServices.accounts.d.ts +0 -868
  439. package/dist/types/mixins/OxyServices.agency.d.ts +0 -176
  440. package/dist/types/mixins/OxyServices.analytics.d.ts +0 -72
  441. package/dist/types/mixins/OxyServices.appData.d.ts +0 -108
  442. package/dist/types/mixins/OxyServices.assets.d.ts +0 -371
  443. package/dist/types/mixins/OxyServices.auth.d.ts +0 -965
  444. package/dist/types/mixins/OxyServices.chains.d.ts +0 -156
  445. package/dist/types/mixins/OxyServices.civic.d.ts +0 -520
  446. package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -184
  447. package/dist/types/mixins/OxyServices.contacts.d.ts +0 -100
  448. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +0 -185
  449. package/dist/types/mixins/OxyServices.devices.d.ts +0 -103
  450. package/dist/types/mixins/OxyServices.features.d.ts +0 -250
  451. package/dist/types/mixins/OxyServices.followGraph.d.ts +0 -223
  452. package/dist/types/mixins/OxyServices.identity.d.ts +0 -337
  453. package/dist/types/mixins/OxyServices.identityBackup.d.ts +0 -129
  454. package/dist/types/mixins/OxyServices.inference.d.ts +0 -95
  455. package/dist/types/mixins/OxyServices.language.d.ts +0 -87
  456. package/dist/types/mixins/OxyServices.linkedAccounts.d.ts +0 -107
  457. package/dist/types/mixins/OxyServices.location.d.ts +0 -70
  458. package/dist/types/mixins/OxyServices.nodes.d.ts +0 -198
  459. package/dist/types/mixins/OxyServices.notifications.d.ts +0 -143
  460. package/dist/types/mixins/OxyServices.payment.d.ts +0 -117
  461. package/dist/types/mixins/OxyServices.privacy.d.ts +0 -146
  462. package/dist/types/mixins/OxyServices.reputation.d.ts +0 -202
  463. package/dist/types/mixins/OxyServices.security.d.ts +0 -84
  464. package/dist/types/mixins/OxyServices.store.d.ts +0 -334
  465. package/dist/types/mixins/OxyServices.topics.d.ts +0 -110
  466. package/dist/types/mixins/OxyServices.user.d.ts +0 -600
  467. package/dist/types/mixins/OxyServices.utility.d.ts +0 -526
  468. package/dist/types/mixins/index.d.ts +0 -87
  469. package/dist/types/mixins/mixinHelpers.d.ts +0 -31
  470. package/dist/types/utils/webauthnOrigin.d.ts +0 -32
  471. package/src/OxyServices.base.ts +0 -480
  472. package/src/__tests__/authSocket.test.ts +0 -96
  473. package/src/__tests__/fixEsmImports.test.ts +0 -132
  474. package/src/__tests__/httpServiceAbort.test.ts +0 -308
  475. package/src/__tests__/httpServiceActivityId.test.ts +0 -49
  476. package/src/__tests__/httpServiceAuthSelfAwait.test.ts +0 -108
  477. package/src/__tests__/httpServiceCache.test.ts +0 -318
  478. package/src/__tests__/httpServiceFormEncoded.test.ts +0 -139
  479. package/src/__tests__/httpServiceQueueDeadlock.test.ts +0 -128
  480. package/src/__tests__/httpServiceRateLimitedRefresh.test.ts +0 -160
  481. package/src/__tests__/httpServiceUnwrapEnvelope.test.ts +0 -115
  482. package/src/__tests__/httpServiceWrites.test.ts +0 -175
  483. package/src/__tests__/inSessionRefresh.test.ts +0 -313
  484. package/src/__tests__/linkedClient.test.ts +0 -363
  485. package/src/__tests__/nearExpiryMintStorm.test.ts +0 -165
  486. package/src/__tests__/parseHttpErrorBody.test.ts +0 -116
  487. package/src/__tests__/publicInterfaceParity.test.ts +0 -75
  488. package/src/__tests__/serverValueImportsDeclared.test.ts +0 -120
  489. package/src/__tests__/serviceTokenWorkloadFallback.test.ts +0 -85
  490. package/src/__tests__/userIdentity.test.ts +0 -143
  491. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +0 -393
  492. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +0 -325
  493. package/src/boot/__tests__/sessionColdBoot.test.ts +0 -570
  494. package/src/crypto/__tests__/backupMaterial.test.ts +0 -86
  495. package/src/crypto/__tests__/cryptoPrimitives.test.ts +0 -226
  496. package/src/crypto/__tests__/identityLink.test.ts +0 -18
  497. package/src/crypto/__tests__/identityMocks.ts +0 -125
  498. package/src/crypto/__tests__/identityProof.test.ts +0 -81
  499. package/src/crypto/__tests__/keyManager.atomicity.test.ts +0 -272
  500. package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +0 -175
  501. package/src/crypto/__tests__/keyManager.deviceBackup.test.ts +0 -310
  502. package/src/crypto/__tests__/keyManager.identityStatus.test.ts +0 -217
  503. package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +0 -179
  504. package/src/crypto/__tests__/keyManager.recoveryMnemonic.test.ts +0 -138
  505. package/src/crypto/__tests__/keyManager.sharedSlotSync.test.ts +0 -150
  506. package/src/crypto/__tests__/keyManager.storageMigration.test.ts +0 -227
  507. package/src/crypto/__tests__/keyManager.test.ts +0 -329
  508. package/src/crypto/__tests__/polyfill.test.ts +0 -109
  509. package/src/crypto/__tests__/polyfillOrder.test.ts +0 -114
  510. package/src/crypto/__tests__/recoveryPhrase.test.ts +0 -61
  511. package/src/crypto/__tests__/registrationPow.test.ts +0 -61
  512. package/src/crypto/__tests__/scopedSeed.test.ts +0 -126
  513. package/src/crypto/__tests__/signChallengeShared.test.ts +0 -63
  514. package/src/crypto/__tests__/signedRecord.test.ts +0 -230
  515. package/src/i18n/__tests__/accountCategoryLabels.test.ts +0 -62
  516. package/src/i18n/__tests__/accountRoleLabels.test.ts +0 -54
  517. package/src/i18n/__tests__/reputationCategoryLabels.test.ts +0 -56
  518. package/src/i18n/__tests__/trustTierLabels.test.ts +0 -47
  519. package/src/i18n/locales/de-DE.json +0 -306
  520. package/src/i18n/locales/es-ES.json +0 -2127
  521. package/src/i18n/locales/ja-JP.json +0 -306
  522. package/src/inference/__tests__/OxyInferenceClient.test.ts +0 -899
  523. package/src/logger/__tests__/logger.test.ts +0 -207
  524. package/src/mixins/OxyServices.accounts.ts +0 -1330
  525. package/src/mixins/OxyServices.agency.ts +0 -353
  526. package/src/mixins/OxyServices.analytics.ts +0 -53
  527. package/src/mixins/OxyServices.appData.ts +0 -158
  528. package/src/mixins/OxyServices.assets.ts +0 -964
  529. package/src/mixins/OxyServices.auth.ts +0 -2028
  530. package/src/mixins/OxyServices.chains.ts +0 -134
  531. package/src/mixins/OxyServices.civic.ts +0 -945
  532. package/src/mixins/OxyServices.connectedApps.ts +0 -212
  533. package/src/mixins/OxyServices.contacts.ts +0 -73
  534. package/src/mixins/OxyServices.devices.ts +0 -120
  535. package/src/mixins/OxyServices.features.ts +0 -454
  536. package/src/mixins/OxyServices.followGraph.ts +0 -293
  537. package/src/mixins/OxyServices.identity.ts +0 -787
  538. package/src/mixins/OxyServices.identityBackup.ts +0 -250
  539. package/src/mixins/OxyServices.inference.ts +0 -57
  540. package/src/mixins/OxyServices.language.ts +0 -144
  541. package/src/mixins/OxyServices.linkedAccounts.ts +0 -129
  542. package/src/mixins/OxyServices.location.ts +0 -46
  543. package/src/mixins/OxyServices.nodes.ts +0 -337
  544. package/src/mixins/OxyServices.notifications.ts +0 -142
  545. package/src/mixins/OxyServices.payment.ts +0 -163
  546. package/src/mixins/OxyServices.privacy.ts +0 -219
  547. package/src/mixins/OxyServices.reputation.ts +0 -436
  548. package/src/mixins/OxyServices.security.ts +0 -110
  549. package/src/mixins/OxyServices.store.ts +0 -585
  550. package/src/mixins/OxyServices.topics.ts +0 -169
  551. package/src/mixins/OxyServices.user.ts +0 -1208
  552. package/src/mixins/OxyServices.utility.ts +0 -1803
  553. package/src/mixins/__tests__/OxyServices.civic.test.ts +0 -1095
  554. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +0 -124
  555. package/src/mixins/__tests__/OxyServices.identity.test.ts +0 -301
  556. package/src/mixins/__tests__/OxyServices.nodes.test.ts +0 -341
  557. package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +0 -323
  558. package/src/mixins/__tests__/OxyServices.serviceAssetMetadata.test.ts +0 -192
  559. package/src/mixins/__tests__/OxyServices.serviceAssetMetadataBySha.test.ts +0 -135
  560. package/src/mixins/__tests__/OxyServices.serviceLinkedDownloadUrl.test.ts +0 -139
  561. package/src/mixins/__tests__/accountEvents.test.ts +0 -158
  562. package/src/mixins/__tests__/accounts.test.ts +0 -727
  563. package/src/mixins/__tests__/agency.test.ts +0 -120
  564. package/src/mixins/__tests__/appData.test.ts +0 -203
  565. package/src/mixins/__tests__/assetCredentials.test.ts +0 -47
  566. package/src/mixins/__tests__/assetUpload.test.ts +0 -191
  567. package/src/mixins/__tests__/authRefusalObservability.test.ts +0 -396
  568. package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +0 -130
  569. package/src/mixins/__tests__/chains.test.ts +0 -113
  570. package/src/mixins/__tests__/commonsSignIn.test.ts +0 -1147
  571. package/src/mixins/__tests__/connectedApps.test.ts +0 -146
  572. package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +0 -113
  573. package/src/mixins/__tests__/discoveryErrorHandling.test.ts +0 -265
  574. package/src/mixins/__tests__/followCacheInvalidation.test.ts +0 -173
  575. package/src/mixins/__tests__/followGraph.test.ts +0 -147
  576. package/src/mixins/__tests__/followGraphPagination.test.ts +0 -271
  577. package/src/mixins/__tests__/getFileDownloadUrl.test.ts +0 -346
  578. package/src/mixins/__tests__/getFollowStatuses.test.ts +0 -95
  579. package/src/mixins/__tests__/getUsersByIds.test.ts +0 -176
  580. package/src/mixins/__tests__/identityBackup.test.ts +0 -315
  581. package/src/mixins/__tests__/identityLink.test.ts +0 -89
  582. package/src/mixins/__tests__/identityWriteCacheInvalidation.test.ts +0 -407
  583. package/src/mixins/__tests__/inferenceFactory.test.ts +0 -58
  584. package/src/mixins/__tests__/linkedAccounts.test.ts +0 -109
  585. package/src/mixins/__tests__/notifications.read.test.ts +0 -82
  586. package/src/mixins/__tests__/onTokensChanged.test.ts +0 -129
  587. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +0 -284
  588. package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +0 -155
  589. package/src/mixins/__tests__/reputation.test.ts +0 -522
  590. package/src/mixins/__tests__/serviceAuth.test.ts +0 -889
  591. package/src/mixins/__tests__/serviceTokenJwks.test.ts +0 -208
  592. package/src/mixins/__tests__/store.test.ts +0 -304
  593. package/src/mixins/__tests__/topics.test.ts +0 -155
  594. package/src/mixins/__tests__/userReadCacheBypass.test.ts +0 -129
  595. package/src/mixins/__tests__/userTokenAuth.test.ts +0 -746
  596. package/src/mixins/__tests__/verifyChallenge.test.ts +0 -161
  597. package/src/mixins/__tests__/verifyServiceActingAs.test.ts +0 -167
  598. package/src/mixins/__tests__/webauthnAuth.test.ts +0 -279
  599. package/src/mixins/index.ts +0 -203
  600. package/src/mixins/mixinHelpers.ts +0 -69
  601. package/src/server/__tests__/auth.test.ts +0 -78
  602. package/src/server/__tests__/capabilityTicket.test.ts +0 -111
  603. package/src/server/__tests__/cors.socket.test.ts +0 -225
  604. package/src/server/__tests__/cors.test.ts +0 -158
  605. package/src/server/__tests__/rateLimit.test.ts +0 -257
  606. package/src/server/__tests__/requesterAssertion.test.ts +0 -359
  607. package/src/server/__tests__/safeFetch.test.ts +0 -232
  608. package/src/server/__tests__/securityHeaders.test.ts +0 -387
  609. package/src/server/__tests__/serviceTokenAttribution.test.ts +0 -396
  610. package/src/server/__tests__/traffic.test.ts +0 -180
  611. package/src/server/__tests__/trafficNodeHttp.test.ts +0 -38
  612. package/src/server/__tests__/userInvalidation.test.ts +0 -203
  613. package/src/server/__tests__/verifySecret.test.ts +0 -40
  614. package/src/server/__tests__/workloadIdentity.client.test.ts +0 -16
  615. package/src/server/__tests__/workloadIdentity.test.ts +0 -128
  616. package/src/session/__tests__/SessionClient.additive.test.ts +0 -153
  617. package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +0 -114
  618. package/src/session/__tests__/SessionClient.diagnostics.test.ts +0 -91
  619. package/src/session/__tests__/SessionClient.directory.test.ts +0 -688
  620. package/src/session/__tests__/SessionClient.httpIntegration.test.ts +0 -65
  621. package/src/session/__tests__/SessionClient.identityPin.test.ts +0 -175
  622. package/src/session/__tests__/SessionClient.rest.test.ts +0 -119
  623. package/src/session/__tests__/SessionClient.serverEvents.test.ts +0 -72
  624. package/src/session/__tests__/SessionClient.socket.test.ts +0 -202
  625. package/src/session/__tests__/SessionClient.socketFactory.test.ts +0 -81
  626. package/src/session/__tests__/SessionClient.state.test.ts +0 -78
  627. package/src/session/__tests__/SessionClient.switchTokenOrder.test.ts +0 -170
  628. package/src/session/__tests__/accountDelegationAccess.test.ts +0 -104
  629. package/src/session/__tests__/accountDialogController.test.ts +0 -2502
  630. package/src/session/__tests__/accountDialogShape.test.ts +0 -120
  631. package/src/session/__tests__/accountSwitchTargets.test.ts +0 -137
  632. package/src/session/__tests__/authStateStore.test.ts +0 -451
  633. package/src/session/__tests__/deviceDirectory.test.ts +0 -422
  634. package/src/session/__tests__/deviceSwitcherRows.test.ts +0 -223
  635. package/src/session/__tests__/identityPin.test.ts +0 -131
  636. package/src/session/__tests__/identitySession.test.ts +0 -194
  637. package/src/session/__tests__/projectSessionState.test.ts +0 -105
  638. package/src/session/__tests__/refresh.identityPin.test.ts +0 -307
  639. package/src/session/__tests__/refresh.test.ts +0 -606
  640. package/src/session/__tests__/sessionIntegration.test.ts +0 -209
  641. package/src/session/__tests__/sharedDeviceCredential.test.ts +0 -300
  642. package/src/utils/__tests__/accountUtils.test.ts +0 -151
  643. package/src/utils/__tests__/activityId.test.ts +0 -45
  644. package/src/utils/__tests__/asyncUtils.test.ts +0 -279
  645. package/src/utils/__tests__/authWebUrl.test.ts +0 -15
  646. package/src/utils/__tests__/cacheKey.test.ts +0 -0
  647. package/src/utils/__tests__/cacheLifecycle.test.ts +0 -65
  648. package/src/utils/__tests__/coldBoot.test.ts +0 -496
  649. package/src/utils/__tests__/commonsApproval.test.ts +0 -60
  650. package/src/utils/__tests__/identityCacheSweep.test.ts +0 -151
  651. package/src/utils/__tests__/languageUtils.test.ts +0 -244
  652. package/src/utils/__tests__/oauthPkce.test.ts +0 -303
  653. package/src/utils/__tests__/officialOrigins.test.ts +0 -31
  654. package/src/utils/__tests__/oxyServiceEnvironment.test.ts +0 -7
  655. package/src/utils/__tests__/profileLinks.test.ts +0 -180
  656. package/src/utils/__tests__/redactUrl.test.ts +0 -33
  657. package/src/utils/__tests__/registrableApex.test.ts +0 -62
  658. package/src/utils/__tests__/requestUtilsAbort.test.ts +0 -155
  659. package/src/utils/__tests__/textNormalization.test.ts +0 -205
  660. package/src/utils/__tests__/validationUtils.test.ts +0 -703
  661. package/src/utils/__tests__/webauthnOrigin.test.ts +0 -83
  662. package/src/utils/webauthnOrigin.ts +0 -52
@@ -1,2502 +0,0 @@
1
- import type { DeviceSessionState } from '@oxy.so/contracts';
2
- import type { OxyServices } from '../../OxyServices';
3
- import type { User } from '../../models/interfaces';
4
- import type { SessionLoginResponse, MinimalUserData } from '../../models/session';
5
- import type { AccountNode } from '../../mixins/OxyServices.accounts';
6
- import { SessionClient, type SessionClientHost } from '../SessionClient';
7
- import type { MinimalSocket, SocketIOFactory } from '../socketLoader';
8
- import { logger } from '../../logger';
9
- import { setPlatformOS } from '../../utils/platform';
10
- import {
11
- AccountDialogController,
12
- createAccountDialogController,
13
- } from '../accountDialogController';
14
-
15
- // A SessionClient whose applied state can be driven directly (applyState is
16
- // protected on the base) — mirrors the existing TestClient pattern.
17
- class TestSessionClient extends SessionClient {
18
- static readonly instances = new Set<TestSessionClient>();
19
-
20
- constructor(clientHost: SessionClientHost) {
21
- super(clientHost);
22
- TestSessionClient.instances.add(this);
23
- }
24
-
25
- set(state: DeviceSessionState): void {
26
- this.applyState(state);
27
- }
28
- }
29
-
30
- afterEach(() => {
31
- for (const client of TestSessionClient.instances) client.stop();
32
- TestSessionClient.instances.clear();
33
- });
34
-
35
- /**
36
- * A valid, empty directory for the host below.
37
- *
38
- * `refresh()` reads `GET /session/device/directory` now, so the shared host has
39
- * to answer it with something the contract accepts — otherwise every test in
40
- * this file logs "discarded invalid device directory" and the ones asserting on
41
- * the logger fail for a reason that has nothing to do with their subject.
42
- *
43
- * Its `revision` sits deliberately ahead of every state revision these tests
44
- * use, so the directory-settle re-read (which has its own suite) never fires
45
- * here and each test's request count stays about the thing it is testing.
46
- */
47
- const EMPTY_DIRECTORY = {
48
- deviceId: 'device-1',
49
- revision: 9_000,
50
- activeContextId: null,
51
- principals: [],
52
- updatedAt: 1_720_000_000_000,
53
- };
54
-
55
- interface RecordingHost {
56
- host: SessionClientHost;
57
- /** Every path the client has requested, in order. */
58
- urls: string[];
59
- /** Make the next and every subsequent directory read reject with `error`. */
60
- failDirectory: (error: unknown) => void;
61
- }
62
-
63
- function host(): RecordingHost {
64
- const urls: string[] = [];
65
- let directoryError: unknown = null;
66
- return {
67
- urls,
68
- failDirectory: (error: unknown) => {
69
- directoryError = error;
70
- },
71
- host: {
72
- makeRequest: jest.fn(async (_method: string, url: string) => {
73
- urls.push(url);
74
- if (url === '/session/device/directory') {
75
- if (directoryError !== null) throw directoryError;
76
- return EMPTY_DIRECTORY;
77
- }
78
- return undefined;
79
- }),
80
- getBaseURL: () => 'http://test.invalid',
81
- getAccessToken: () => 'token',
82
- getDeviceCredential: () => null,
83
- onTokensChanged: () => () => undefined,
84
- setTokens: jest.fn(),
85
- getCurrentAccountId: () => null,
86
- },
87
- };
88
- }
89
-
90
- function state(
91
- accounts: Array<{ accountId: string; sessionId: string }>,
92
- activeAccountId: string | null,
93
- revision = 1,
94
- ): DeviceSessionState {
95
- return {
96
- deviceId: 'device-1',
97
- accounts: accounts.map((a) => ({ accountId: a.accountId, sessionId: a.sessionId, authuser: 0 })),
98
- activeAccountId,
99
- revision,
100
- updatedAt: 1_720_000_000_000,
101
- };
102
- }
103
-
104
- function user(id: string, over: Partial<User> = {}): User {
105
- return {
106
- id,
107
- publicKey: `pk_${id}`,
108
- username: `user_${id}`,
109
- name: { displayName: `User ${id}` },
110
- ...over,
111
- } as User;
112
- }
113
-
114
- function graphNode(id: string, over: Partial<AccountNode> = {}): AccountNode {
115
- return {
116
- accountId: id,
117
- kind: 'organization',
118
- parentAccountId: null,
119
- account: user(id),
120
- relationship: 'owner',
121
- callerMembership: null,
122
- ...over,
123
- };
124
- }
125
-
126
- interface OxyMock {
127
- getAccessToken: jest.Mock;
128
- getBaseURL: jest.Mock;
129
- onTokensChanged: jest.Mock;
130
- listAccounts: jest.Mock;
131
- getUsersByIds: jest.Mock;
132
- getFileDownloadUrl: jest.Mock;
133
- switchToAccount: jest.Mock;
134
- startCommonsSignIn: jest.Mock;
135
- deliverCommonsSignIn: jest.Mock;
136
- pollCommonsSignIn: jest.Mock;
137
- denyCommonsSignIn: jest.Mock;
138
- claimSessionByToken: jest.Mock;
139
- signInWithSharedIdentity: jest.Mock;
140
- /** Plant / clear the bearer — both fire `onTokensChanged`, like `OxyServices`. */
141
- setTokens: jest.Mock;
142
- clearTokens: jest.Mock;
143
- /**
144
- * Test helper: set the current access token and fire every registered
145
- * `onTokensChanged` listener (mirrors `OxyServices.setTokens`/`clearTokens`).
146
- * With no listener yet registered (before `start()`), it just sets the token.
147
- */
148
- emitTokenChange: (token: string | null) => void;
149
- }
150
-
151
- function makeOxy(): OxyMock {
152
- const tokenListeners = new Set<(token: string | null) => void>();
153
- // Authenticated by default (mirrors a warm start with a planted bearer).
154
- let currentToken: string | null = 'access-token';
155
- const emitTokenChange = (token: string | null) => {
156
- currentToken = token;
157
- for (const listener of tokenListeners) {
158
- listener(token);
159
- }
160
- };
161
- return {
162
- getAccessToken: jest.fn(() => currentToken),
163
- getBaseURL: jest.fn(() => 'http://test.invalid'),
164
- onTokensChanged: jest.fn((listener: (token: string | null) => void) => {
165
- tokenListeners.add(listener);
166
- return () => tokenListeners.delete(listener);
167
- }),
168
- listAccounts: jest.fn().mockResolvedValue([]),
169
- getUsersByIds: jest.fn().mockResolvedValue([]),
170
- getFileDownloadUrl: jest.fn((id: string) => `https://cdn/${id}`),
171
- switchToAccount: jest.fn(),
172
- startCommonsSignIn: jest.fn(),
173
- pollCommonsSignIn: jest.fn(),
174
- // Default: no capable Commons installation is registered. A NORMAL outcome
175
- // that resolves the primary route to QR — never an error.
176
- deliverCommonsSignIn: jest.fn().mockResolvedValue({ delivered: false, targets: 0 }),
177
- denyCommonsSignIn: jest.fn().mockResolvedValue({ success: true }),
178
- claimSessionByToken: jest.fn(),
179
- signInWithSharedIdentity: jest.fn().mockResolvedValue(null),
180
- setTokens: jest.fn((token: string) => emitTokenChange(token)),
181
- clearTokens: jest.fn(() => emitTokenChange(null)),
182
- emitTokenChange,
183
- };
184
- }
185
-
186
- /** Flush pending microtasks (a `start()`-triggered `refresh()` cannot be awaited directly). */
187
- const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
188
-
189
- interface Harness {
190
- controller: AccountDialogController;
191
- oxy: OxyMock;
192
- sc: TestSessionClient;
193
- commitSession: jest.Mock;
194
- onSignedIn: jest.Mock;
195
- /** Every path the session client has requested, in order. */
196
- urls: string[];
197
- /** Make every directory read reject with `error`. */
198
- failDirectory: (error: unknown) => void;
199
- }
200
-
201
- function makeHarness(
202
- over: Partial<{
203
- clientId: string | null;
204
- platform: import('../../utils/commonsDelivery').CommonsDeliveryPlatform;
205
- openUrl: (url: string) => void;
206
- canOpenApp: (url: string) => Promise<boolean>;
207
- }> = {},
208
- ): Harness {
209
- const oxy = makeOxy();
210
- const recording = host();
211
- const sc = new TestSessionClient(recording.host);
212
- const commitSession = jest.fn().mockResolvedValue(undefined);
213
- const onSignedIn = jest.fn();
214
- const controller = createAccountDialogController({
215
- oxyServices: oxy as unknown as OxyServices,
216
- sessionClient: sc,
217
- clientId: 'clientId' in over ? over.clientId : 'oxy_dk_test',
218
- commitSession,
219
- onSignedIn,
220
- pollIntervalMs: 1000,
221
- platform: over.platform,
222
- openUrl: over.openUrl,
223
- canOpenApp: over.canOpenApp,
224
- });
225
- return {
226
- controller,
227
- oxy,
228
- sc,
229
- commitSession,
230
- onSignedIn,
231
- urls: recording.urls,
232
- failDirectory: recording.failDirectory,
233
- };
234
- }
235
-
236
- /** How many times the device directory has been read. */
237
- const directoryReads = (urls: string[]): number =>
238
- urls.filter((url) => url === '/session/device/directory').length;
239
-
240
- describe('AccountDialogController — back, and the menu needs a session', () => {
241
- /** A harness with no bearer — nobody is signed in on this device. */
242
- function signedOut(): Harness {
243
- const harness = makeHarness();
244
- harness.oxy.emitTokenChange(null);
245
- return harness;
246
- }
247
-
248
- it('signed out, Back from "Create your account" returns to the sign-in entry, not the account menu', () => {
249
- // The Android QA repro (OxyHQ/oxy#1375 item 1): Sign In → "New to Oxy?
250
- // Create one" → ‹. The host's back used to be a hard-coded
251
- // `setView('accounts')`, which opened "Switch account … Sign out" for nobody.
252
- const { controller } = signedOut();
253
- controller.setView('signin');
254
- controller.startSignup();
255
- expect(controller.getSnapshot().backView).toBe('signin');
256
-
257
- expect(controller.back()).toBe(true);
258
-
259
- expect(controller.getSnapshot().view).toBe('signin');
260
- });
261
-
262
- it('signed out, the sign-in entry is the first view: back reports nothing to go back to', () => {
263
- const { controller } = signedOut();
264
- controller.setView('signin');
265
- const before = controller.getSnapshot();
266
- expect(before.backView).toBeNull();
267
-
268
- expect(controller.back()).toBe(false);
269
-
270
- expect(controller.getSnapshot()).toBe(before);
271
- });
272
-
273
- it('signed out, the account menu is never the view — it resolves to the sign-in entry', async () => {
274
- const { controller } = signedOut();
275
- // A controller is constructed on `accounts`; started with no bearer, it
276
- // shows the entry.
277
- controller.start();
278
- await flush();
279
- expect(controller.getSnapshot().view).toBe('signin');
280
-
281
- controller.setView('qr');
282
- controller.setView('accounts');
283
- expect(controller.getSnapshot().view).toBe('signin');
284
-
285
- controller.add();
286
- expect(controller.getSnapshot().backView).toBeNull();
287
- expect(controller.back()).toBe(false);
288
- expect(controller.getSnapshot().view).toBe('add');
289
- controller.destroy();
290
- });
291
-
292
- it('signed out, Back from the QR request withdraws it and returns to the entry', async () => {
293
- const { controller, oxy } = signedOut();
294
- oxy.startCommonsSignIn.mockResolvedValue({
295
- sessionToken: 'secret-tok',
296
- authorizeCode: 'AUTH-CODE',
297
- qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
298
- expiresAt: Date.now() + 300_000,
299
- status: 'pending',
300
- });
301
- controller.setView('signin');
302
- await controller.showQr();
303
- expect(controller.getSnapshot().view).toBe('qr');
304
- expect(controller.getSnapshot().signIn.phase).toBe('waiting');
305
-
306
- expect(controller.back()).toBe(true);
307
-
308
- const snap = controller.getSnapshot();
309
- expect(snap.view).toBe('signin');
310
- expect(snap.signIn.phase).toBe('idle');
311
- expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
312
- });
313
-
314
- it('signed in, back walks signup → add → accounts, and the menu is the first view', () => {
315
- const { controller } = makeHarness();
316
- controller.add();
317
- controller.startSignup();
318
- expect(controller.getSnapshot().backView).toBe('add');
319
-
320
- expect(controller.back()).toBe(true);
321
- expect(controller.getSnapshot().view).toBe('add');
322
- expect(controller.getSnapshot().backView).toBe('accounts');
323
-
324
- expect(controller.back()).toBe(true);
325
- expect(controller.getSnapshot().view).toBe('accounts');
326
- expect(controller.getSnapshot().backView).toBeNull();
327
-
328
- expect(controller.back()).toBe(false);
329
- expect(controller.getSnapshot().view).toBe('accounts');
330
- });
331
-
332
- it('signing out while the account menu is open leaves the menu for the sign-in entry', async () => {
333
- const { controller, oxy } = makeHarness();
334
- controller.start();
335
- await flush();
336
- expect(controller.getSnapshot().view).toBe('accounts');
337
-
338
- oxy.emitTokenChange(null);
339
-
340
- expect(controller.getSnapshot().view).toBe('signin');
341
- controller.destroy();
342
- });
343
- });
344
-
345
- describe('AccountDialogController — initial + views', () => {
346
- it('starts on the accounts view with an empty list and idle sign-in', () => {
347
- const { controller } = makeHarness();
348
- const snap = controller.getSnapshot();
349
- expect(snap.view).toBe('accounts');
350
- expect(snap.directory).toBeNull();
351
- expect(snap.activeContext).toBeNull();
352
- expect(snap.loading).toBe(false);
353
- expect(snap.activatingContextId).toBeNull();
354
- expect(snap.removingContextId).toBeNull();
355
- expect(snap.removingPrincipalId).toBeNull();
356
- expect(snap.signIn.phase).toBe('idle');
357
- expect(snap.signIn.route).toBeNull();
358
- expect(snap.signIn.routeFailed).toBe(false);
359
- expect(snap.signIn.pushSentAt).toBeNull();
360
- expect(snap.signIn.openedAt).toBeNull();
361
- expect(snap.signIn.progress).toBe('idle');
362
- expect(snap.commonsAvailability).toBe('unknown');
363
- });
364
-
365
- it('setView / add move between views and notify subscribers', () => {
366
- const { controller } = makeHarness();
367
- const seen: string[] = [];
368
- controller.subscribe((s) => seen.push(s.view));
369
-
370
- controller.add();
371
- expect(controller.getSnapshot().view).toBe('add');
372
- controller.setView('signin');
373
- expect(controller.getSnapshot().view).toBe('signin');
374
- controller.setView('accounts');
375
- expect(controller.getSnapshot().view).toBe('accounts');
376
-
377
- expect(seen).toEqual(['add', 'signin', 'accounts']);
378
- });
379
-
380
- it('startSignup moves to the signup view and notifies subscribers', () => {
381
- const { controller } = makeHarness();
382
- const seen: string[] = [];
383
- controller.subscribe((s) => seen.push(s.view));
384
-
385
- controller.startSignup();
386
- expect(controller.getSnapshot().view).toBe('signup');
387
- controller.setView('accounts');
388
- expect(controller.getSnapshot().view).toBe('accounts');
389
-
390
- expect(seen).toEqual(['signup', 'accounts']);
391
- });
392
-
393
- it('getSnapshot returns a stable reference until a change occurs', () => {
394
- const { controller } = makeHarness();
395
- const a = controller.getSnapshot();
396
- expect(controller.getSnapshot()).toBe(a);
397
- controller.setView('add');
398
- expect(controller.getSnapshot()).not.toBe(a);
399
- });
400
- });
401
-
402
- describe('AccountDialogController — auth-gated directory read', () => {
403
- it('start() while signed out makes no directory call and does not error', async () => {
404
- const { controller, oxy, sc, urls } = makeHarness();
405
- // Cold boot: no bearer planted yet (no listeners registered pre-start).
406
- oxy.emitTokenChange(null);
407
- sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
408
-
409
- controller.start();
410
- await flush();
411
-
412
- // The directory read is a PRIVATE call. Issuing it before the cold-boot
413
- // restore plants the bearer 401s, which clears the token and signs the user
414
- // out — the failure this gate exists for.
415
- expect(urls).not.toContain('/session/device/directory');
416
- const snap = controller.getSnapshot();
417
- expect(snap.error).toBeNull();
418
- expect(snap.loading).toBe(false);
419
- controller.destroy();
420
- });
421
-
422
- it('start() while authenticated reads the directory exactly once', async () => {
423
- const { controller, sc, urls } = makeHarness();
424
- sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
425
-
426
- controller.start();
427
- await flush();
428
-
429
- expect(directoryReads(urls)).toBe(1);
430
- controller.destroy();
431
- });
432
-
433
- it('reads the directory once when the bearer is planted after a signed-out start', async () => {
434
- const { controller, oxy, urls } = makeHarness();
435
- oxy.emitTokenChange(null);
436
- controller.start();
437
- await flush();
438
- expect(urls).not.toContain('/session/device/directory');
439
-
440
- // Cold-boot restore plants the token → onTokensChanged → one read.
441
- oxy.emitTokenChange('access-token');
442
- await flush();
443
- expect(directoryReads(urls)).toBe(1);
444
- controller.destroy();
445
- });
446
-
447
- it('never reconstructs the account graph — no listAccounts, no getUsersByIds', async () => {
448
- const { controller, oxy, sc } = makeHarness();
449
- sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
450
-
451
- controller.start();
452
- await flush();
453
- // A device change used to refetch profiles for any newly-seen account id.
454
- sc.set(state([{ accountId: 'a1', sessionId: 's1' }, { accountId: 'a2', sessionId: 's2' }], 'a2', 2));
455
- await flush();
456
-
457
- // ADR 0002's whole point: the client holds ONE caller's account graph and
458
- // cannot enumerate another principal's, so a switcher assembled from it is
459
- // one person's answer presented as the device's. The directory carries the
460
- // rows AND the display metadata, so neither private call is made at all.
461
- expect(oxy.listAccounts).not.toHaveBeenCalled();
462
- expect(oxy.getUsersByIds).not.toHaveBeenCalled();
463
- controller.destroy();
464
- });
465
-
466
- it('does not loop when the directory read rejects — one call per refresh, no re-trigger on device changes', async () => {
467
- const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
468
- const { controller, sc, urls, failDirectory } = makeHarness();
469
- failDirectory(new Error('directory boom'));
470
- sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
471
-
472
- controller.start();
473
- await flush();
474
- expect(directoryReads(urls)).toBe(1);
475
- expect(controller.getSnapshot().error).toBe('directory boom');
476
-
477
- // A subsequent device push must not re-trigger it (the auth edge did not
478
- // move → `reconcileAuth` is a no-op → no storm).
479
- sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1', 2));
480
- await flush();
481
- expect(directoryReads(urls)).toBe(1);
482
- controller.destroy();
483
- warnSpy.mockRestore();
484
- });
485
-
486
- it('treats a 401 from the directory read as the signed-out edge — debug, no surfaced error, no warn', async () => {
487
- const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
488
- const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
489
- const { controller, failDirectory } = makeHarness();
490
- failDirectory(
491
- Object.assign(new Error('Invalid or missing authorization header'), { status: 401 }),
492
- );
493
-
494
- await controller.refresh();
495
-
496
- // A signed-out device is a normal state, not a warning.
497
- expect(controller.getSnapshot().error).toBeNull();
498
- expect(warnSpy).not.toHaveBeenCalled();
499
- expect(debugSpy).toHaveBeenCalledWith(
500
- '[AccountDialogController] directory unauthorized (signed out)',
501
- { component: 'AccountDialogController' },
502
- expect.objectContaining({ status: 401 }),
503
- );
504
- warnSpy.mockRestore();
505
- debugSpy.mockRestore();
506
- });
507
- });
508
-
509
- describe('AccountDialogController — sign in with Oxy', () => {
510
- it('completes silently when a shared identity mints a session', async () => {
511
- const { controller, oxy, commitSession, onSignedIn } = makeHarness();
512
- const session: SessionLoginResponse = {
513
- sessionId: 'sess-shared',
514
- deviceId: 'device-1',
515
- expiresAt: '2030-01-01T00:00:00Z',
516
- accessToken: 'access-shared',
517
- user: { id: 'a1', username: 'user_a1', name: { displayName: 'User a1' } },
518
- };
519
- oxy.signInWithSharedIdentity.mockResolvedValue(session);
520
-
521
- await controller.signInWithOxy();
522
-
523
- expect(commitSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'sess-shared' }));
524
- expect(onSignedIn).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' }));
525
- expect(controller.getSnapshot().view).toBe('accounts');
526
- // Terminal SUCCESS — the surface gets one frame to show "Identity confirmed".
527
- expect(controller.getSnapshot().signIn.phase).toBe('completed');
528
- expect(controller.getSnapshot().signIn.progress).toBe('identity-confirmed');
529
- expect(oxy.startCommonsSignIn).not.toHaveBeenCalled();
530
-
531
- // …and a new intention clears it, so the next entry never opens on the
532
- // previous sign-in's terminal state.
533
- controller.add();
534
- expect(controller.getSnapshot().signIn.phase).toBe('idle');
535
- expect(controller.getSnapshot().signIn.progress).toBe('idle');
536
- });
537
-
538
- it('falls through to the QR handoff when no shared identity is present', async () => {
539
- const { controller, oxy } = makeHarness();
540
- oxy.signInWithSharedIdentity.mockResolvedValue(null);
541
- oxy.startCommonsSignIn.mockResolvedValue({
542
- sessionToken: 'secret-tok',
543
- authorizeCode: 'AUTH-CODE',
544
- qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
545
- expiresAt: Date.now() + 300_000,
546
- status: 'pending',
547
- });
548
-
549
- await controller.signInWithOxy();
550
-
551
- expect(oxy.startCommonsSignIn).toHaveBeenCalledWith({ clientId: 'oxy_dk_test' });
552
- const snap = controller.getSnapshot();
553
- expect(snap.view).toBe('qr');
554
- expect(snap.signIn.phase).toBe('waiting');
555
- expect(snap.signIn.authorizeCode).toBe('AUTH-CODE');
556
- expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
557
- expect(oxy.deliverCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
558
- expect(snap.signIn.route).toBe('qr');
559
- controller.cancelSignIn();
560
- });
561
-
562
- it('selects await-push when delivery succeeds for a signed-in user', async () => {
563
- const { controller, oxy } = makeHarness();
564
- oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: true, targets: 1 });
565
- oxy.startCommonsSignIn.mockResolvedValue({
566
- sessionToken: 'secret-tok',
567
- authorizeCode: 'AUTH-CODE',
568
- qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
569
- expiresAt: Date.now() + 300_000,
570
- status: 'pending',
571
- });
572
-
573
- await controller.showQr();
574
-
575
- expect(oxy.deliverCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
576
- expect(controller.getSnapshot().signIn.route).toBe('await-push');
577
- controller.cancelSignIn();
578
- });
579
-
580
- it('skips deliver when no bearer is planted', async () => {
581
- const { controller, oxy } = makeHarness();
582
- oxy.emitTokenChange(null);
583
- oxy.startCommonsSignIn.mockResolvedValue({
584
- sessionToken: 'secret-tok',
585
- authorizeCode: 'AUTH-CODE',
586
- qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
587
- expiresAt: Date.now() + 300_000,
588
- status: 'pending',
589
- });
590
-
591
- await controller.showQr();
592
-
593
- expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
594
- expect(controller.getSnapshot().signIn.route).toBe('qr');
595
- controller.cancelSignIn();
596
- });
597
-
598
- it('errors when showQr is called without a clientId', async () => {
599
- const { controller } = makeHarness({ clientId: null });
600
- await controller.showQr();
601
- const snap = controller.getSnapshot();
602
- expect(snap.signIn.phase).toBe('error');
603
- expect(snap.signIn.error).toMatch(/clientId/);
604
- expect(snap.signIn.failure).toBe('not-configured');
605
- });
606
-
607
- it('polls, claims, and commits when the QR flow is authorized', async () => {
608
- jest.useFakeTimers();
609
- try {
610
- const { controller, oxy, commitSession, onSignedIn } = makeHarness();
611
- oxy.startCommonsSignIn.mockResolvedValue({
612
- sessionToken: 'secret-tok',
613
- authorizeCode: 'AUTH-CODE',
614
- qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
615
- expiresAt: Date.now() + 600_000,
616
- status: 'pending',
617
- });
618
- oxy.pollCommonsSignIn
619
- .mockResolvedValueOnce({ authorized: false, status: 'pending' })
620
- .mockResolvedValueOnce({ authorized: true, sessionId: 'sess-1', status: 'authorized' });
621
- oxy.claimSessionByToken.mockResolvedValue({
622
- accessToken: 'access-1',
623
- sessionId: 'sess-1',
624
- deviceId: 'device-1',
625
- deviceSecret: 'claimed-secret',
626
- expiresAt: '2030-01-01T00:00:00Z',
627
- user: user('a1'),
628
- });
629
-
630
- await controller.showQr();
631
- expect(controller.getSnapshot().signIn.phase).toBe('waiting');
632
-
633
- await jest.advanceTimersByTimeAsync(1000); // first poll → pending
634
- expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(1);
635
-
636
- await jest.advanceTimersByTimeAsync(1000); // second poll → authorized → claim
637
- expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok', { plantTokens: false });
638
- expect(commitSession).toHaveBeenCalledWith(
639
- expect.objectContaining({
640
- sessionId: 'sess-1',
641
- accessToken: 'access-1',
642
- deviceSecret: 'claimed-secret',
643
- }),
644
- );
645
- expect(onSignedIn).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' }));
646
- expect(controller.getSnapshot().view).toBe('accounts');
647
- } finally {
648
- jest.useRealTimers();
649
- }
650
- });
651
-
652
- it('surfaces a denied QR authorization as an error and stops polling', async () => {
653
- jest.useFakeTimers();
654
- try {
655
- const { controller, oxy } = makeHarness();
656
- oxy.startCommonsSignIn.mockResolvedValue({
657
- sessionToken: 'secret-tok',
658
- authorizeCode: 'AUTH-CODE',
659
- qrPayload: 'oxycommons://approve',
660
- expiresAt: Date.now() + 600_000,
661
- status: 'pending',
662
- });
663
- oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'cancelled' });
664
-
665
- await controller.showQr();
666
- await jest.advanceTimersByTimeAsync(1000);
667
-
668
- expect(controller.getSnapshot().signIn.phase).toBe('error');
669
- expect(controller.getSnapshot().signIn.error).toMatch(/denied/i);
670
- expect(controller.getSnapshot().signIn.failure).toBe('denied');
671
-
672
- // No further polls after the terminal error.
673
- await jest.advanceTimersByTimeAsync(5000);
674
- expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(1);
675
- } finally {
676
- jest.useRealTimers();
677
- }
678
- });
679
-
680
- it('cancelSignIn stops the poll and resets to idle', async () => {
681
- jest.useFakeTimers();
682
- try {
683
- const { controller, oxy } = makeHarness();
684
- oxy.startCommonsSignIn.mockResolvedValue({
685
- sessionToken: 'secret-tok',
686
- authorizeCode: 'AUTH-CODE',
687
- qrPayload: 'oxycommons://approve',
688
- expiresAt: Date.now() + 600_000,
689
- status: 'pending',
690
- });
691
- oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
692
-
693
- await controller.showQr();
694
- controller.cancelSignIn();
695
- expect(controller.getSnapshot().signIn.phase).toBe('idle');
696
- expect(controller.getSnapshot().signIn.progress).toBe('idle');
697
- // Cancellation converges server-side too: the abandoned request is
698
- // withdrawn so a stale QR can never be approved later.
699
- expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
700
-
701
- await jest.advanceTimersByTimeAsync(5000);
702
- expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
703
- } finally {
704
- jest.useRealTimers();
705
- }
706
- });
707
- });
708
-
709
- describe('AccountDialogController — startInlineQr (the sign-in entry\'s embedded QR)', () => {
710
- const pending = (code: string) => ({
711
- sessionToken: `secret-${code}`,
712
- authorizeCode: code,
713
- qrPayload: `oxycommons://approve?v=1&code=${code}`,
714
- expiresAt: Date.now() + 300_000,
715
- status: 'pending',
716
- });
717
-
718
- it('starts a QR-only request without leaving the entry — no push, no Commons, no view change', async () => {
719
- const { controller, oxy } = makeHarness();
720
- oxy.startCommonsSignIn.mockResolvedValue(pending('AUTH-CODE'));
721
- controller.setView('signin');
722
-
723
- await controller.startInlineQr();
724
-
725
- const snap = controller.getSnapshot();
726
- expect(snap.view).toBe('signin');
727
- expect(snap.signIn.inline).toBe(true);
728
- expect(snap.signIn.phase).toBe('waiting');
729
- expect(snap.signIn.route).toBe('qr');
730
- expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
731
- // A bearer is planted in this harness, so an asked-for request WOULD push.
732
- expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
733
- controller.cancelSignIn();
734
- });
735
-
736
- it('"Try again" repeats the embedded QR', async () => {
737
- const { controller, oxy } = makeHarness();
738
- oxy.startCommonsSignIn.mockResolvedValue(pending('AUTH-CODE'));
739
- await controller.startInlineQr();
740
-
741
- await controller.retrySignIn();
742
-
743
- expect(oxy.startCommonsSignIn).toHaveBeenCalledTimes(2);
744
- expect(controller.getSnapshot().signIn.inline).toBe(true);
745
- controller.cancelSignIn();
746
- });
747
-
748
- it('gives way to a sign-in the person chooses, withdrawing its own request', async () => {
749
- const { controller, oxy } = makeHarness();
750
- oxy.startCommonsSignIn.mockResolvedValueOnce(pending('INLINE')).mockResolvedValueOnce(pending('ASKED'));
751
- await controller.startInlineQr();
752
-
753
- await controller.showQr();
754
-
755
- expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('INLINE');
756
- const snap = controller.getSnapshot();
757
- expect(snap.signIn.inline).toBe(false);
758
- expect(snap.signIn.authorizeCode).toBe('ASKED');
759
- controller.cancelSignIn();
760
- });
761
- });
762
-
763
- describe('AccountDialogController — Commons availability (canOpenApp)', () => {
764
- const START_HANDLE = {
765
- sessionToken: 'secret-tok',
766
- authorizeCode: 'AUTH-CODE',
767
- qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
768
- expiresAt: Date.now() + 600_000,
769
- status: 'pending' as const,
770
- };
771
-
772
- function makeController(opts: {
773
- openUrl?: jest.Mock;
774
- canOpenApp?: jest.Mock;
775
- }): { controller: AccountDialogController; oxy: OxyMock } {
776
- const oxy = makeOxy();
777
- oxy.startCommonsSignIn.mockResolvedValue(START_HANDLE);
778
- oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
779
- const controller = new AccountDialogController({
780
- oxyServices: oxy as unknown as OxyServices,
781
- sessionClient: new TestSessionClient(host().host),
782
- clientId: 'oxy_dk_test',
783
- pollIntervalMs: 1000,
784
- openUrl: opts.openUrl,
785
- canOpenApp: opts.canOpenApp,
786
- // The deep-link route is mobile-only (`selectCommonsDelivery`), and a
787
- // `canOpenApp` probe is only ever injected by a native (mobile) host.
788
- platform: 'mobile',
789
- });
790
- return { controller, oxy };
791
- }
792
-
793
- it('deep-links into Commons via openUrl when canOpenApp reports it installed, keeping the QR/polling fallback', async () => {
794
- setPlatformOS('ios');
795
- const openUrl = jest.fn();
796
- const canOpenApp = jest.fn().mockResolvedValue(true);
797
- const { controller } = makeController({ openUrl, canOpenApp });
798
-
799
- await controller.showQr();
800
- await flush(); // let the (non-awaited) canOpenApp probe resolve
801
-
802
- expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
803
- expect(openUrl).toHaveBeenCalledWith('oxycommons://approve?v=1&code=AUTH-CODE');
804
- // The QR + polling remain the fallback path — the flow is still waiting.
805
- const snap = controller.getSnapshot();
806
- expect(snap.view).toBe('qr');
807
- expect(snap.signIn.phase).toBe('waiting');
808
- expect(snap.signIn.route).toBe('open-commons');
809
- expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
810
- expect(snap.commonsAvailability).toBe('available');
811
- controller.cancelSignIn();
812
- setPlatformOS('web');
813
- });
814
-
815
- it('does NOT open Commons when canOpenApp reports it absent (renders QR only)', async () => {
816
- const openUrl = jest.fn();
817
- const canOpenApp = jest.fn().mockResolvedValue(false);
818
- const { controller } = makeController({ openUrl, canOpenApp });
819
-
820
- await controller.showQr();
821
- await flush();
822
-
823
- expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
824
- expect(openUrl).not.toHaveBeenCalled();
825
- expect(controller.getSnapshot().signIn.phase).toBe('waiting');
826
- expect(controller.getSnapshot().commonsAvailability).toBe('unavailable');
827
- controller.cancelSignIn();
828
- });
829
-
830
- it('never probes or opens when canOpenApp is absent (web — unchanged behavior)', async () => {
831
- const openUrl = jest.fn();
832
- const { controller } = makeController({ openUrl });
833
-
834
- await controller.showQr();
835
- await flush();
836
-
837
- expect(openUrl).not.toHaveBeenCalled();
838
- expect(controller.getSnapshot().signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
839
- expect(controller.getSnapshot().commonsAvailability).toBe('unknown');
840
- controller.cancelSignIn();
841
- });
842
-
843
- it('swallows a canOpenApp probe rejection, keeps the QR fallback, and records unavailable', async () => {
844
- const openUrl = jest.fn();
845
- const canOpenApp = jest.fn().mockRejectedValue(new Error('probe boom'));
846
- const { controller } = makeController({ openUrl, canOpenApp });
847
-
848
- await controller.showQr();
849
- await flush();
850
-
851
- expect(openUrl).not.toHaveBeenCalled();
852
- expect(controller.getSnapshot().signIn.phase).toBe('waiting');
853
- expect(controller.getSnapshot().commonsAvailability).toBe('unavailable');
854
- controller.cancelSignIn();
855
- });
856
-
857
- it('start() eagerly resolves commonsAvailability without requiring a QR flow', async () => {
858
- const canOpenApp = jest.fn().mockResolvedValue(true);
859
- const { controller } = makeController({ canOpenApp });
860
-
861
- controller.start();
862
- await flush();
863
-
864
- expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
865
- expect(controller.getSnapshot().commonsAvailability).toBe('available');
866
- controller.destroy();
867
- });
868
- });
869
-
870
- describe('AccountDialogController — /auth-session socket (instant QR wake)', () => {
871
- type Handler = (...args: unknown[]) => void;
872
- class FakeAuthSocket implements MinimalSocket {
873
- connected = false;
874
- disconnected = false;
875
- handlers = new Map<string, Handler[]>();
876
- emitted: Array<{ event: string; args: unknown[] }> = [];
877
- on(event: string, cb: Handler) { const l = this.handlers.get(event) ?? []; l.push(cb); this.handlers.set(event, l); }
878
- off(event: string, cb?: Handler) { if (!cb) { this.handlers.delete(event); return; } this.handlers.set(event, (this.handlers.get(event) ?? []).filter((h) => h !== cb)); }
879
- emit(event: string, ...args: unknown[]) { this.emitted.push({ event, args }); }
880
- connect() { this.connected = true; }
881
- disconnect() { this.connected = false; this.disconnected = true; }
882
- /** Simulate a server→client push on this socket. */
883
- server(event: string, payload?: unknown) { for (const h of this.handlers.get(event) ?? []) h(payload); }
884
- }
885
-
886
- const START_HANDLE = {
887
- sessionToken: 'secret-tok',
888
- authorizeCode: 'AUTH-CODE',
889
- qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
890
- expiresAt: Date.now() + 600_000,
891
- status: 'pending' as const,
892
- };
893
-
894
- function makeSocketHarness(): { controller: AccountDialogController; oxy: OxyMock; created: () => FakeAuthSocket | null; factory: jest.Mock; commitSession: jest.Mock } {
895
- const oxy = makeOxy();
896
- oxy.startCommonsSignIn.mockResolvedValue(START_HANDLE);
897
- let socket: FakeAuthSocket | null = null;
898
- const factory = jest.fn((_uri: string, _opts?: Record<string, unknown>): MinimalSocket => {
899
- socket = new FakeAuthSocket();
900
- socket.connected = true; // real io autoConnect resolves before we inspect
901
- return socket;
902
- });
903
- const commitSession = jest.fn().mockResolvedValue(undefined);
904
- const controller = new AccountDialogController({
905
- oxyServices: oxy as unknown as OxyServices,
906
- sessionClient: new TestSessionClient(host().host),
907
- clientId: 'oxy_dk_test',
908
- commitSession,
909
- socketFactory: factory as unknown as SocketIOFactory,
910
- });
911
- return { controller, oxy, created: () => socket, factory, commitSession };
912
- }
913
-
914
- it('connects to /auth-session, joins the flow room, and wakes the claim on auth_update — no timer advance', async () => {
915
- const { controller, oxy, created, factory, commitSession } = makeSocketHarness();
916
- oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-1', status: 'authorized' });
917
- oxy.claimSessionByToken.mockResolvedValue({
918
- accessToken: 'access-1', sessionId: 'sess-1', deviceId: 'device-1', expiresAt: '2030-01-01T00:00:00Z', user: user('a1'),
919
- });
920
-
921
- await controller.showQr();
922
-
923
- expect(factory).toHaveBeenCalledWith('http://test.invalid/auth-session', expect.any(Object));
924
- const sock = created();
925
- if (!sock) throw new Error('socket not created');
926
- expect(sock.emitted).toContainEqual({ event: 'join', args: ['secret-tok'] });
927
-
928
- // The server pushes auth_update → immediate status check + claim, without any poll timer firing.
929
- sock.server('auth_update', { status: 'authorized', sessionId: 'sess-1' });
930
- await flush();
931
-
932
- expect(oxy.pollCommonsSignIn).toHaveBeenCalledWith('secret-tok');
933
- expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok', { plantTokens: false });
934
- expect(commitSession).toHaveBeenCalled();
935
- expect(controller.getSnapshot().view).toBe('accounts');
936
- expect(sock.disconnected).toBe(true); // torn down on completion
937
- });
938
-
939
- it('re-joins the room on reconnect (connect event) and tears the socket down on cancelSignIn', async () => {
940
- const { controller, oxy, created } = makeSocketHarness();
941
- oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
942
-
943
- await controller.showQr();
944
- const sock = created();
945
- if (!sock) throw new Error('socket not created');
946
- expect(sock.emitted.filter((e) => e.event === 'join')).toHaveLength(1);
947
-
948
- // A reconnect fires `connect` again → the join is re-issued so it survives drops.
949
- sock.server('connect');
950
- expect(sock.emitted.filter((e) => e.event === 'join')).toHaveLength(2);
951
-
952
- controller.cancelSignIn();
953
- expect(sock.disconnected).toBe(true);
954
- });
955
-
956
- it('a stale auth_update after the flow was cancelled does not re-poll', async () => {
957
- const { controller, oxy, created } = makeSocketHarness();
958
- oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
959
-
960
- await controller.showQr();
961
- const sock = created();
962
- if (!sock) throw new Error('socket not created');
963
- oxy.pollCommonsSignIn.mockClear();
964
-
965
- controller.cancelSignIn();
966
- // Even if a late auth_update slips through on the (now-detached) socket, the
967
- // superseded-token guard drops it.
968
- sock.server('auth_update', { status: 'authorized' });
969
- await flush();
970
- expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
971
- });
972
-
973
- it('stops polling when an OAuth-bound session is authorized without a sessionId', async () => {
974
- const { controller, oxy, created } = makeSocketHarness();
975
- oxy.pollCommonsSignIn.mockResolvedValue({
976
- authorized: true,
977
- purpose: 'oauth_authorization',
978
- status: 'authorized',
979
- });
980
-
981
- await controller.showQr();
982
- const sock = created();
983
- if (!sock) throw new Error('socket not created');
984
- sock.server('auth_update', { status: 'authorized' });
985
- await flush();
986
-
987
- expect(oxy.claimSessionByToken).not.toHaveBeenCalled();
988
- expect(controller.getSnapshot().signIn.phase).toBe('error');
989
- expect(controller.getSnapshot().signIn.error).toContain('OAuth sign-in');
990
- });
991
- });
992
-
993
- // ===========================================================================
994
- // Issue #691, Phase 5 — automatic delivery selection + honest progress
995
- // ===========================================================================
996
-
997
- type DeliveryPlatform = import('../../utils/commonsDelivery').CommonsDeliveryPlatform;
998
-
999
- const DELIVERY_HANDLE = {
1000
- sessionToken: 'secret-tok',
1001
- authorizeCode: 'AUTH-CODE',
1002
- qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
1003
- status: 'pending' as const,
1004
- };
1005
-
1006
- function makeDeliveryHarness(opts: {
1007
- platform?: DeliveryPlatform;
1008
- canOpenApp?: jest.Mock;
1009
- openUrl?: jest.Mock;
1010
- /** Default `true` — a planted bearer, the only state that may push. */
1011
- authenticated?: boolean;
1012
- pollIntervalMs?: number;
1013
- }): { controller: AccountDialogController; oxy: OxyMock } {
1014
- const oxy = makeOxy();
1015
- oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
1016
- oxy.pollCommonsSignIn.mockResolvedValue({
1017
- authorized: false,
1018
- status: 'pending',
1019
- pushSentAt: null,
1020
- openedAt: null,
1021
- });
1022
- if (opts.authenticated === false) oxy.emitTokenChange(null);
1023
- const controller = new AccountDialogController({
1024
- oxyServices: oxy as unknown as OxyServices,
1025
- sessionClient: new TestSessionClient(host().host),
1026
- clientId: 'oxy_dk_test',
1027
- // Every real consumer wires the provider's commit funnel; without it the
1028
- // controller falls back to `SessionClient.registerAndActivate`, which opens
1029
- // a BroadcastChannel this harness has no reason to exercise.
1030
- commitSession: jest.fn().mockResolvedValue(undefined),
1031
- pollIntervalMs: opts.pollIntervalMs ?? 1000,
1032
- platform: opts.platform,
1033
- canOpenApp: opts.canOpenApp,
1034
- openUrl: opts.openUrl,
1035
- });
1036
- return { controller, oxy };
1037
- }
1038
-
1039
- describe('AccountDialogController — automatic delivery selection (#691 phase 5)', () => {
1040
- it('chooses open-commons on mobile with a verified Commons link, and never pushes as well', async () => {
1041
- const openUrl = jest.fn();
1042
- const { controller, oxy } = makeDeliveryHarness({
1043
- platform: 'mobile',
1044
- canOpenApp: jest.fn().mockResolvedValue(true),
1045
- openUrl,
1046
- });
1047
-
1048
- await controller.showQr();
1049
- await flush();
1050
-
1051
- const snap = controller.getSnapshot();
1052
- expect(snap.signIn.route).toBe('open-commons');
1053
- expect(snap.signIn.routeFailed).toBe(false);
1054
- expect(openUrl).toHaveBeenCalledWith('oxycommons://approve?v=1&code=AUTH-CODE');
1055
- // The identity is reachable on THIS device — pushing would light up a second
1056
- // surface for a request the user is about to confirm here.
1057
- expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
1058
- expect(snap.signIn.progress).toBe('awaiting-approval');
1059
- controller.cancelSignIn();
1060
- });
1061
-
1062
- it('chooses await-push when the server delivered to at least one known Commons installation', async () => {
1063
- const openUrl = jest.fn();
1064
- const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop', openUrl });
1065
- oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: true, targets: 2 });
1066
-
1067
- await controller.showQr();
1068
- await flush();
1069
-
1070
- const snap = controller.getSnapshot();
1071
- expect(oxy.deliverCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
1072
- expect(snap.signIn.route).toBe('await-push');
1073
- expect(snap.signIn.routeFailed).toBe(false);
1074
- // "Check Commons on your phone" — the server CONFIRMED the dispatch.
1075
- expect(snap.signIn.progress).toBe('delivered-to-commons');
1076
- expect(openUrl).not.toHaveBeenCalled();
1077
- controller.cancelSignIn();
1078
- });
1079
-
1080
- it('falls through to QR when the delivery reached zero installations — no error surfaced', async () => {
1081
- const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
1082
- oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: false, targets: 0 });
1083
-
1084
- await controller.showQr();
1085
- await flush();
1086
-
1087
- const snap = controller.getSnapshot();
1088
- expect(snap.signIn.route).toBe('qr');
1089
- expect(snap.signIn.routeFailed).toBe(false);
1090
- expect(snap.signIn.phase).toBe('waiting');
1091
- expect(snap.signIn.error).toBeNull();
1092
- expect(snap.error).toBeNull();
1093
- expect(snap.signIn.progress).toBe('awaiting-approval');
1094
- controller.cancelSignIn();
1095
- });
1096
-
1097
- it('falls through to QR when the delivery call FAILS — silently, as a normal outcome', async () => {
1098
- const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
1099
- const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
1100
- const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
1101
- oxy.deliverCommonsSignIn.mockRejectedValue(new Error('delivery boom'));
1102
-
1103
- await controller.showQr();
1104
- await flush();
1105
-
1106
- const snap = controller.getSnapshot();
1107
- expect(snap.signIn.route).toBe('qr');
1108
- expect(snap.signIn.phase).toBe('waiting');
1109
- expect(snap.signIn.error).toBeNull();
1110
- expect(snap.error).toBeNull();
1111
- expect(warnSpy).not.toHaveBeenCalled();
1112
- expect(debugSpy).toHaveBeenCalledWith(
1113
- '[AccountDialogController] Commons delivery unavailable (QR route)',
1114
- { component: 'AccountDialogController' },
1115
- expect.any(Error),
1116
- );
1117
- controller.cancelSignIn();
1118
- debugSpy.mockRestore();
1119
- warnSpy.mockRestore();
1120
- });
1121
-
1122
- it('treats a delivered:false response with a non-zero target count as zero targets (fail-safe to QR)', async () => {
1123
- const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
1124
- oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: false, targets: 3 });
1125
-
1126
- await controller.showQr();
1127
- await flush();
1128
-
1129
- expect(controller.getSnapshot().signIn.route).toBe('qr');
1130
- controller.cancelSignIn();
1131
- });
1132
-
1133
- it('NEVER attempts delivery without a bearer — the unauthenticated surface cannot ring a phone', async () => {
1134
- const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop', authenticated: false });
1135
-
1136
- await controller.showQr();
1137
- await flush();
1138
-
1139
- expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
1140
- const snap = controller.getSnapshot();
1141
- expect(snap.signIn.route).toBe('qr');
1142
- expect(snap.signIn.error).toBeNull();
1143
- controller.cancelSignIn();
1144
- });
1145
-
1146
- it('pushes on mobile when no verified Commons link is available on this device', async () => {
1147
- const openUrl = jest.fn();
1148
- const { controller, oxy } = makeDeliveryHarness({
1149
- platform: 'mobile',
1150
- canOpenApp: jest.fn().mockResolvedValue(false),
1151
- openUrl,
1152
- });
1153
- oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: true, targets: 1 });
1154
-
1155
- await controller.showQr();
1156
- await flush();
1157
-
1158
- expect(oxy.deliverCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
1159
- expect(controller.getSnapshot().signIn.route).toBe('await-push');
1160
- expect(openUrl).not.toHaveBeenCalled();
1161
- controller.cancelSignIn();
1162
- });
1163
-
1164
- it('never deep-links from an unclassified surface, even with Commons installed', async () => {
1165
- const openUrl = jest.fn();
1166
- const { controller } = makeDeliveryHarness({
1167
- // No `platform` supplied → 'unknown': the controller says so rather than
1168
- // guessing, and an unresolved custom-scheme navigation is a dead end.
1169
- canOpenApp: jest.fn().mockResolvedValue(true),
1170
- openUrl,
1171
- });
1172
-
1173
- await controller.showQr();
1174
- await flush();
1175
-
1176
- expect(openUrl).not.toHaveBeenCalled();
1177
- expect(controller.getSnapshot().signIn.route).toBe('qr');
1178
- expect(controller.getSnapshot().commonsAvailability).toBe('available');
1179
- controller.cancelSignIn();
1180
- });
1181
-
1182
- it('marks the primary route FAILED (never auto-cascades) when the Commons link cannot be opened', async () => {
1183
- // Route chosen: open-commons. No URL opener was injected, so the one action
1184
- // the route implies cannot happen — the UI needs that fact to reveal
1185
- // "Having trouble?"; the controller must not silently switch to another route.
1186
- const { controller } = makeDeliveryHarness({
1187
- platform: 'mobile',
1188
- canOpenApp: jest.fn().mockResolvedValue(true),
1189
- });
1190
-
1191
- await controller.showQr();
1192
- await flush();
1193
-
1194
- const snap = controller.getSnapshot();
1195
- expect(snap.signIn.route).toBe('open-commons');
1196
- expect(snap.signIn.routeFailed).toBe(true);
1197
- // Still a live request — the QR beneath it stays valid.
1198
- expect(snap.signIn.phase).toBe('waiting');
1199
- expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
1200
- controller.cancelSignIn();
1201
- });
1202
-
1203
- it('marks the primary route FAILED when opening the Commons link throws', async () => {
1204
- const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
1205
- const openUrl = jest.fn(() => {
1206
- throw new Error('no handler for scheme');
1207
- });
1208
- const { controller } = makeDeliveryHarness({
1209
- platform: 'mobile',
1210
- canOpenApp: jest.fn().mockResolvedValue(true),
1211
- openUrl,
1212
- });
1213
-
1214
- await controller.showQr();
1215
- await flush();
1216
-
1217
- expect(controller.getSnapshot().signIn.route).toBe('open-commons');
1218
- expect(controller.getSnapshot().signIn.routeFailed).toBe(true);
1219
- expect(controller.getSnapshot().signIn.error).toBeNull();
1220
- controller.cancelSignIn();
1221
- debugSpy.mockRestore();
1222
- });
1223
-
1224
- it('reports "preparing" until a route is actually resolved — it is never guessed', async () => {
1225
- let releaseDelivery: () => void = () => undefined;
1226
- const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
1227
- oxy.deliverCommonsSignIn.mockImplementation(
1228
- () =>
1229
- new Promise((resolve) => {
1230
- releaseDelivery = () => resolve({ delivered: true, targets: 1 });
1231
- }),
1232
- );
1233
-
1234
- await controller.showQr();
1235
- // The request exists and the QR is renderable, but no route has been chosen.
1236
- expect(controller.getSnapshot().signIn.phase).toBe('waiting');
1237
- expect(controller.getSnapshot().signIn.route).toBeNull();
1238
- expect(controller.getSnapshot().signIn.progress).toBe('preparing');
1239
-
1240
- releaseDelivery();
1241
- await flush();
1242
- expect(controller.getSnapshot().signIn.route).toBe('await-push');
1243
- expect(controller.getSnapshot().signIn.progress).toBe('delivered-to-commons');
1244
- controller.cancelSignIn();
1245
- });
1246
-
1247
- it('abandons route resolution when the flow was cancelled while it was in flight', async () => {
1248
- const openUrl = jest.fn();
1249
- let releaseProbe: (installed: boolean) => void = () => undefined;
1250
- const { controller, oxy } = makeDeliveryHarness({
1251
- platform: 'mobile',
1252
- canOpenApp: jest.fn(
1253
- () =>
1254
- new Promise<boolean>((resolve) => {
1255
- releaseProbe = resolve;
1256
- }),
1257
- ),
1258
- openUrl,
1259
- });
1260
-
1261
- await controller.showQr();
1262
- controller.cancelSignIn();
1263
- releaseProbe(true);
1264
- await flush();
1265
-
1266
- expect(openUrl).not.toHaveBeenCalled();
1267
- expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
1268
- expect(controller.getSnapshot().signIn.route).toBeNull();
1269
- });
1270
-
1271
- it('abandons route resolution when the request was approved while it was in flight', async () => {
1272
- jest.useFakeTimers();
1273
- try {
1274
- const openUrl = jest.fn();
1275
- let releaseProbe: (installed: boolean) => void = () => undefined;
1276
- const { controller, oxy } = makeDeliveryHarness({
1277
- platform: 'mobile',
1278
- canOpenApp: jest.fn(
1279
- () =>
1280
- new Promise<boolean>((resolve) => {
1281
- releaseProbe = resolve;
1282
- }),
1283
- ),
1284
- openUrl,
1285
- });
1286
- oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-1', status: 'authorized' });
1287
- oxy.claimSessionByToken.mockResolvedValue({
1288
- accessToken: 'access-1',
1289
- sessionId: 'sess-1',
1290
- deviceId: 'device-1',
1291
- expiresAt: '2030-01-01T00:00:00Z',
1292
- user: user('a1'),
1293
- });
1294
-
1295
- await controller.showQr();
1296
- await jest.advanceTimersByTimeAsync(1000); // approval lands first
1297
- expect(controller.getSnapshot().signIn.phase).toBe('completed');
1298
-
1299
- releaseProbe(true);
1300
- await jest.advanceTimersByTimeAsync(0);
1301
-
1302
- // Opening Commons after the session is already signed in would be a
1303
- // pointless app switch — the resolution must abandon itself.
1304
- expect(openUrl).not.toHaveBeenCalled();
1305
- expect(controller.getSnapshot().signIn.route).toBeNull();
1306
- } finally {
1307
- jest.useRealTimers();
1308
- }
1309
- });
1310
-
1311
- it('never leaks the secret device-flow token into the snapshot', async () => {
1312
- const { controller } = makeDeliveryHarness({ platform: 'desktop' });
1313
-
1314
- await controller.showQr();
1315
- await flush();
1316
-
1317
- const snap = controller.getSnapshot();
1318
- expect(JSON.stringify(snap)).not.toContain('secret-tok');
1319
- expect(Object.keys(snap.signIn)).toEqual([
1320
- 'phase',
1321
- 'authorizeCode',
1322
- 'qrPayload',
1323
- 'expiresAt',
1324
- 'error',
1325
- 'failure',
1326
- 'route',
1327
- 'routeFailed',
1328
- 'pushSentAt',
1329
- 'openedAt',
1330
- 'progress',
1331
- 'attempt',
1332
- 'inline',
1333
- ]);
1334
- // Only the PUBLIC handles are exposed.
1335
- expect(snap.signIn.authorizeCode).toBe('AUTH-CODE');
1336
- controller.cancelSignIn();
1337
- });
1338
- });
1339
-
1340
- describe('AccountDialogController — sign-in progress (#691 phase 5)', () => {
1341
- it('advances only on real signals, in order, to "Identity confirmed"', async () => {
1342
- jest.useFakeTimers();
1343
- try {
1344
- const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
1345
- oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: true, targets: 1 });
1346
- oxy.pollCommonsSignIn
1347
- // 1st tick: still nothing but the confirmed push.
1348
- .mockResolvedValueOnce({
1349
- authorized: false,
1350
- status: 'pending',
1351
- pushSentAt: '2026-07-27T10:00:00.000Z',
1352
- openedAt: null,
1353
- })
1354
- // 2nd tick: the approver opened the request.
1355
- .mockResolvedValueOnce({
1356
- authorized: false,
1357
- status: 'pending',
1358
- pushSentAt: '2026-07-27T10:00:00.000Z',
1359
- openedAt: '2026-07-27T10:00:12.000Z',
1360
- })
1361
- // 3rd tick: approved.
1362
- .mockResolvedValue({
1363
- authorized: true,
1364
- sessionId: 'sess-1',
1365
- status: 'authorized',
1366
- pushSentAt: '2026-07-27T10:00:00.000Z',
1367
- openedAt: '2026-07-27T10:00:12.000Z',
1368
- });
1369
- let releaseClaim: () => void = () => undefined;
1370
- oxy.claimSessionByToken.mockImplementation(
1371
- () =>
1372
- new Promise((resolve) => {
1373
- releaseClaim = () =>
1374
- resolve({
1375
- accessToken: 'access-1',
1376
- sessionId: 'sess-1',
1377
- deviceId: 'device-1',
1378
- expiresAt: '2030-01-01T00:00:00Z',
1379
- user: user('a1'),
1380
- });
1381
- }),
1382
- );
1383
-
1384
- await controller.showQr();
1385
- await jest.advanceTimersByTimeAsync(0); // let the route resolve
1386
- expect(controller.getSnapshot().signIn.progress).toBe('delivered-to-commons');
1387
-
1388
- await jest.advanceTimersByTimeAsync(1000);
1389
- expect(controller.getSnapshot().signIn.pushSentAt).toBe('2026-07-27T10:00:00.000Z');
1390
- expect(controller.getSnapshot().signIn.progress).toBe('delivered-to-commons');
1391
-
1392
- await jest.advanceTimersByTimeAsync(1000);
1393
- expect(controller.getSnapshot().signIn.openedAt).toBe('2026-07-27T10:00:12.000Z');
1394
- expect(controller.getSnapshot().signIn.progress).toBe('opened-in-commons');
1395
-
1396
- await jest.advanceTimersByTimeAsync(1000);
1397
- // Approved: the claim/commit is running — "Confirming identity".
1398
- expect(controller.getSnapshot().signIn.phase).toBe('authorized');
1399
- expect(controller.getSnapshot().signIn.progress).toBe('confirming-identity');
1400
-
1401
- releaseClaim();
1402
- await jest.advanceTimersByTimeAsync(0);
1403
- expect(controller.getSnapshot().signIn.phase).toBe('completed');
1404
- expect(controller.getSnapshot().signIn.progress).toBe('identity-confirmed');
1405
- expect(controller.getSnapshot().view).toBe('accounts');
1406
- } finally {
1407
- jest.useRealTimers();
1408
- }
1409
- });
1410
-
1411
- it('does not advance without a signal — repeated empty polls keep it waiting', async () => {
1412
- jest.useFakeTimers();
1413
- try {
1414
- const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
1415
- oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: false, targets: 0 });
1416
- oxy.pollCommonsSignIn.mockResolvedValue({
1417
- authorized: false,
1418
- status: 'pending',
1419
- pushSentAt: null,
1420
- openedAt: null,
1421
- });
1422
-
1423
- await controller.showQr();
1424
- await jest.advanceTimersByTimeAsync(0);
1425
- expect(controller.getSnapshot().signIn.progress).toBe('awaiting-approval');
1426
-
1427
- await jest.advanceTimersByTimeAsync(5000); // five fallback polls
1428
- expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(5);
1429
- expect(controller.getSnapshot().signIn.progress).toBe('awaiting-approval');
1430
- expect(controller.getSnapshot().signIn.pushSentAt).toBeNull();
1431
- expect(controller.getSnapshot().signIn.openedAt).toBeNull();
1432
-
1433
- controller.cancelSignIn();
1434
- } finally {
1435
- jest.useRealTimers();
1436
- }
1437
- });
1438
-
1439
- it('is monotone — a later, emptier status response never walks progress backwards', async () => {
1440
- jest.useFakeTimers();
1441
- try {
1442
- const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
1443
- oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: false, targets: 0 });
1444
- oxy.pollCommonsSignIn
1445
- .mockResolvedValueOnce({
1446
- authorized: false,
1447
- status: 'pending',
1448
- pushSentAt: '2026-07-27T10:00:00.000Z',
1449
- openedAt: '2026-07-27T10:00:12.000Z',
1450
- })
1451
- // An older API build (or a partial payload) omits the progress fields.
1452
- .mockResolvedValue({ authorized: false, status: 'pending' });
1453
-
1454
- await controller.showQr();
1455
- await jest.advanceTimersByTimeAsync(1000);
1456
- expect(controller.getSnapshot().signIn.progress).toBe('opened-in-commons');
1457
-
1458
- await jest.advanceTimersByTimeAsync(2000);
1459
- expect(controller.getSnapshot().signIn.openedAt).toBe('2026-07-27T10:00:12.000Z');
1460
- expect(controller.getSnapshot().signIn.progress).toBe('opened-in-commons');
1461
-
1462
- controller.cancelSignIn();
1463
- } finally {
1464
- jest.useRealTimers();
1465
- }
1466
- });
1467
-
1468
- it('treats "opened in Commons" as progress only — it never claims a session', async () => {
1469
- jest.useFakeTimers();
1470
- try {
1471
- const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
1472
- oxy.pollCommonsSignIn.mockResolvedValue({
1473
- authorized: false,
1474
- status: 'pending',
1475
- pushSentAt: '2026-07-27T10:00:00.000Z',
1476
- openedAt: '2026-07-27T10:00:12.000Z',
1477
- });
1478
-
1479
- await controller.showQr();
1480
- await jest.advanceTimersByTimeAsync(3000);
1481
-
1482
- expect(controller.getSnapshot().signIn.progress).toBe('opened-in-commons');
1483
- expect(oxy.claimSessionByToken).not.toHaveBeenCalled();
1484
- expect(controller.getSnapshot().signIn.phase).toBe('waiting');
1485
-
1486
- controller.cancelSignIn();
1487
- } finally {
1488
- jest.useRealTimers();
1489
- }
1490
- });
1491
- });
1492
-
1493
- describe('AccountDialogController — cancellation converges (#691 phase 5)', () => {
1494
- it('a denial in Commons converges on the waiting surface and stops the flow', async () => {
1495
- jest.useFakeTimers();
1496
- try {
1497
- const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
1498
- oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'cancelled' });
1499
-
1500
- await controller.showQr();
1501
- await jest.advanceTimersByTimeAsync(1000);
1502
-
1503
- const snap = controller.getSnapshot();
1504
- expect(snap.signIn.phase).toBe('error');
1505
- expect(snap.signIn.error).toMatch(/denied/i);
1506
- expect(snap.signIn.progress).toBe('idle');
1507
-
1508
- await jest.advanceTimersByTimeAsync(10_000);
1509
- expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(1);
1510
- expect(oxy.claimSessionByToken).not.toHaveBeenCalled();
1511
- } finally {
1512
- jest.useRealTimers();
1513
- }
1514
- });
1515
-
1516
- it('a server-reported expiry converges and stops the flow', async () => {
1517
- jest.useFakeTimers();
1518
- try {
1519
- const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
1520
- oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'expired' });
1521
-
1522
- await controller.showQr();
1523
- await jest.advanceTimersByTimeAsync(1000);
1524
-
1525
- expect(controller.getSnapshot().signIn.phase).toBe('error');
1526
- expect(controller.getSnapshot().signIn.error).toMatch(/expired/i);
1527
-
1528
- await jest.advanceTimersByTimeAsync(10_000);
1529
- expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(1);
1530
- } finally {
1531
- jest.useRealTimers();
1532
- }
1533
- });
1534
-
1535
- it('a locally-observed expiry converges without even asking the server', async () => {
1536
- jest.useFakeTimers();
1537
- try {
1538
- const oxy = makeOxy();
1539
- oxy.startCommonsSignIn.mockResolvedValue({
1540
- ...DELIVERY_HANDLE,
1541
- expiresAt: Date.now() + 500, // expires before the first fallback poll
1542
- });
1543
- oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
1544
- const controller = new AccountDialogController({
1545
- oxyServices: oxy as unknown as OxyServices,
1546
- sessionClient: new TestSessionClient(host().host),
1547
- clientId: 'oxy_dk_test',
1548
- pollIntervalMs: 1000,
1549
- platform: 'desktop',
1550
- });
1551
-
1552
- await controller.showQr();
1553
- await jest.advanceTimersByTimeAsync(1000);
1554
-
1555
- expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
1556
- expect(controller.getSnapshot().signIn.phase).toBe('error');
1557
- expect(controller.getSnapshot().signIn.error).toMatch(/expired/i);
1558
- } finally {
1559
- jest.useRealTimers();
1560
- }
1561
- });
1562
-
1563
- it('closing the surface withdraws the request server-side and stops every timer', async () => {
1564
- jest.useFakeTimers();
1565
- try {
1566
- const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
1567
-
1568
- await controller.showQr();
1569
- await jest.advanceTimersByTimeAsync(0);
1570
- expect(controller.getSnapshot().signIn.phase).toBe('waiting');
1571
-
1572
- controller.cancelSignIn();
1573
-
1574
- expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
1575
- expect(controller.getSnapshot().signIn).toEqual({
1576
- phase: 'idle',
1577
- authorizeCode: null,
1578
- qrPayload: null,
1579
- expiresAt: null,
1580
- error: null,
1581
- failure: null,
1582
- route: null,
1583
- routeFailed: false,
1584
- pushSentAt: null,
1585
- openedAt: null,
1586
- progress: 'idle',
1587
- attempt: expect.any(Number),
1588
- inline: false,
1589
- });
1590
- await jest.advanceTimersByTimeAsync(10_000);
1591
- expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
1592
- } finally {
1593
- jest.useRealTimers();
1594
- }
1595
- });
1596
-
1597
- it('does not withdraw a request that already reached a terminal state', async () => {
1598
- jest.useFakeTimers();
1599
- try {
1600
- const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
1601
- oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'cancelled' });
1602
-
1603
- await controller.showQr();
1604
- await jest.advanceTimersByTimeAsync(1000);
1605
- expect(controller.getSnapshot().signIn.phase).toBe('error');
1606
-
1607
- controller.cancelSignIn();
1608
- // Commons already cancelled it — nothing left to withdraw.
1609
- expect(oxy.denyCommonsSignIn).not.toHaveBeenCalled();
1610
- } finally {
1611
- jest.useRealTimers();
1612
- }
1613
- });
1614
-
1615
- it('swallows a failed withdrawal (an approval may have raced it) without surfacing an error', async () => {
1616
- jest.useFakeTimers();
1617
- const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
1618
- try {
1619
- const { controller, oxy } = makeDeliveryHarness({ platform: 'desktop' });
1620
- oxy.denyCommonsSignIn.mockRejectedValue(new Error('already authorized'));
1621
-
1622
- await controller.showQr();
1623
- await jest.advanceTimersByTimeAsync(0);
1624
- controller.cancelSignIn();
1625
- await jest.advanceTimersByTimeAsync(0);
1626
-
1627
- expect(controller.getSnapshot().signIn.phase).toBe('idle');
1628
- expect(controller.getSnapshot().error).toBeNull();
1629
- expect(debugSpy).toHaveBeenCalledWith(
1630
- '[AccountDialogController] request withdrawal failed',
1631
- { component: 'AccountDialogController' },
1632
- expect.any(Error),
1633
- );
1634
- } finally {
1635
- jest.useRealTimers();
1636
- debugSpy.mockRestore();
1637
- }
1638
- });
1639
-
1640
- it('destroy tears down the poll timer and the auth-session socket', async () => {
1641
- jest.useFakeTimers();
1642
- try {
1643
- const oxy = makeOxy();
1644
- oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
1645
- oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
1646
- let socket: { disconnected: boolean } | null = null;
1647
- const factory = jest.fn((): MinimalSocket => {
1648
- const s: MinimalSocket & { disconnected: boolean } = {
1649
- connected: true,
1650
- disconnected: false,
1651
- on: () => undefined,
1652
- off: () => undefined,
1653
- emit: () => undefined,
1654
- connect: () => undefined,
1655
- disconnect() {
1656
- this.disconnected = true;
1657
- },
1658
- };
1659
- socket = s;
1660
- return s;
1661
- });
1662
- const controller = new AccountDialogController({
1663
- oxyServices: oxy as unknown as OxyServices,
1664
- sessionClient: new TestSessionClient(host().host),
1665
- clientId: 'oxy_dk_test',
1666
- pollIntervalMs: 1000,
1667
- platform: 'desktop',
1668
- socketFactory: factory as unknown as SocketIOFactory,
1669
- });
1670
-
1671
- await controller.showQr();
1672
- await jest.advanceTimersByTimeAsync(0);
1673
- expect(controller.getSnapshot().signIn.phase).toBe('waiting');
1674
-
1675
- controller.destroy();
1676
-
1677
- expect(socket).not.toBeNull();
1678
- expect(socket?.disconnected).toBe(true);
1679
- // No poll timer survives the teardown.
1680
- await jest.advanceTimersByTimeAsync(30_000);
1681
- expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
1682
- } finally {
1683
- jest.useRealTimers();
1684
- }
1685
- });
1686
- });
1687
-
1688
- describe('AccountDialogController — lifecycle', () => {
1689
- it('destroy unsubscribes so later device-state changes do not notify', async () => {
1690
- const { controller, oxy, sc } = makeHarness();
1691
- oxy.getUsersByIds.mockResolvedValue([user('a1')]);
1692
- controller.start();
1693
- await Promise.resolve();
1694
- const seen: string[] = [];
1695
- controller.subscribe((s) => seen.push(s.view));
1696
- controller.destroy();
1697
- // destroy clears all listeners; a subsequent state push notifies nobody.
1698
- sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
1699
- expect(seen).toEqual([]);
1700
- });
1701
- });
1702
-
1703
- describe('AccountDialogController — the directory (ADR 0002)', () => {
1704
- /** A two-principal device where `org` is reachable through BOTH people. */
1705
- const sharedDirectory = (revision: number, activeContextId: string | null) => ({
1706
- deviceId: 'device-1',
1707
- revision,
1708
- activeContextId,
1709
- updatedAt: 1_720_000_000_000,
1710
- principals: [
1711
- {
1712
- id: 'p-nate',
1713
- userId: 'nate',
1714
- authuser: 0,
1715
- user: { id: 'nate', username: 'nate' },
1716
- contexts: [
1717
- {
1718
- id: 'ctx-nate-org',
1719
- accountId: 'org',
1720
- kind: 'organization' as const,
1721
- relationship: 'owner' as const,
1722
- account: { id: 'org', username: 'oxy' },
1723
- onDevice: true,
1724
- available: true,
1725
- active: activeContextId === 'ctx-nate-org',
1726
- lastUsedAt: null,
1727
- },
1728
- ],
1729
- },
1730
- {
1731
- id: 'p-alice',
1732
- userId: 'alice',
1733
- authuser: 1,
1734
- user: { id: 'alice', username: 'alice' },
1735
- contexts: [
1736
- {
1737
- id: 'ctx-alice-org',
1738
- accountId: 'org',
1739
- kind: 'organization' as const,
1740
- relationship: 'member' as const,
1741
- account: { id: 'org', username: 'oxy' },
1742
- onDevice: true,
1743
- available: true,
1744
- active: activeContextId === 'ctx-alice-org',
1745
- lastUsedAt: null,
1746
- },
1747
- ],
1748
- },
1749
- ],
1750
- });
1751
-
1752
- function directoryHarness(activeContextId: string | null) {
1753
- const oxy = makeOxy();
1754
- const urls: string[] = [];
1755
- const bodies: unknown[] = [];
1756
- const sc = new TestSessionClient({
1757
- makeRequest: jest.fn(async (_method: string, url: string, data?: unknown) => {
1758
- urls.push(url);
1759
- bodies.push(data);
1760
- if (url === '/session/device/directory') return sharedDirectory(5, activeContextId);
1761
- if (url === '/session/device/activate') {
1762
- return { directory: sharedDirectory(6, 'ctx-alice-org'), activeToken: null };
1763
- }
1764
- if (url === '/session/device/signout') {
1765
- // The context-aware removals answer with their OWN contract — the
1766
- // directory AND the flat state, because a removal elects a
1767
- // replacement active context and both halves move together.
1768
- return {
1769
- directory: sharedDirectory(7, null),
1770
- state: state([{ accountId: 'org', sessionId: 's-org' }], 'org', 7),
1771
- activeToken: null,
1772
- };
1773
- }
1774
- return undefined;
1775
- }),
1776
- getBaseURL: () => 'http://test.invalid',
1777
- getAccessToken: () => 'token',
1778
- getDeviceCredential: () => null,
1779
- onTokensChanged: () => () => undefined,
1780
- setTokens: jest.fn(),
1781
- getCurrentAccountId: () => null,
1782
- });
1783
- const controller = createAccountDialogController({
1784
- oxyServices: oxy as unknown as OxyServices,
1785
- sessionClient: sc,
1786
- clientId: 'oxy_dk_test',
1787
- commitSession: jest.fn().mockResolvedValue(undefined),
1788
- onSignedIn: jest.fn(),
1789
- });
1790
- return { controller, oxy, sc, urls, bodies };
1791
- }
1792
-
1793
- it('publishes the directory and the active context on the snapshot', async () => {
1794
- const { controller, oxy } = directoryHarness('ctx-alice-org');
1795
- oxy.listAccounts.mockResolvedValue([]);
1796
- oxy.getUsersByIds.mockResolvedValue([]);
1797
-
1798
- await controller.refresh();
1799
-
1800
- const snap = controller.getSnapshot();
1801
- expect(snap.directory?.revision).toBe(5);
1802
- // The flat `accounts` list cannot express this: it is keyed by account id,
1803
- // so ONE of these two routes to `org` would be all a consumer ever saw.
1804
- expect(snap.directory?.principals.map((p) => p.userId)).toEqual(['nate', 'alice']);
1805
- expect(snap.activeContext?.contextId).toBe('ctx-alice-org');
1806
- expect(snap.activeContext?.actor.userId).toBe('alice');
1807
- expect(snap.activeContext?.subject.accountId).toBe('org');
1808
- expect(snap.activeContext?.isDelegated).toBe(true);
1809
- });
1810
-
1811
- it('activateContext POSTs the contextId and reports the switch in flight', async () => {
1812
- const { controller, oxy, urls, bodies } = directoryHarness('ctx-nate-org');
1813
- oxy.listAccounts.mockResolvedValue([]);
1814
- oxy.getUsersByIds.mockResolvedValue([]);
1815
- await controller.refresh();
1816
-
1817
- const inFlight: Array<string | null> = [];
1818
- controller.subscribe((s) => inFlight.push(s.activatingContextId));
1819
-
1820
- expect(await controller.activateContext('ctx-alice-org')).toBe(true);
1821
-
1822
- expect(urls).toContain('/session/device/activate');
1823
- expect(bodies[urls.indexOf('/session/device/activate')]).toEqual({ contextId: 'ctx-alice-org' });
1824
- // Reported while running, cleared after — a row can show a spinner.
1825
- expect(inFlight).toContain('ctx-alice-org');
1826
- expect(controller.getSnapshot().activatingContextId).toBeNull();
1827
- expect(controller.getSnapshot().activeContext?.actor.userId).toBe('alice');
1828
- });
1829
-
1830
- it('refuses a second activation while one is in flight', async () => {
1831
- const { controller, oxy } = directoryHarness('ctx-nate-org');
1832
- oxy.listAccounts.mockResolvedValue([]);
1833
- oxy.getUsersByIds.mockResolvedValue([]);
1834
- await controller.refresh();
1835
-
1836
- const first = controller.activateContext('ctx-alice-org');
1837
- expect(await controller.activateContext('ctx-nate-org')).toBe(false);
1838
- expect(await first).toBe(true);
1839
- });
1840
-
1841
- it('surfaces an activation failure as a dialog error without wedging the flag', async () => {
1842
- const { controller, oxy, sc } = directoryHarness('ctx-nate-org');
1843
- oxy.listAccounts.mockResolvedValue([]);
1844
- oxy.getUsersByIds.mockResolvedValue([]);
1845
- await controller.refresh();
1846
- // A stale context id is an ordinary outcome, not a bug: the server 404s and
1847
- // heals the row away.
1848
- jest.spyOn(sc, 'activateContext').mockRejectedValue(new Error('Context not on this device'));
1849
-
1850
- expect(await controller.activateContext('ctx-gone')).toBe(false);
1851
-
1852
- expect(controller.getSnapshot().error).toBe('Context not on this device');
1853
- expect(controller.getSnapshot().activatingContextId).toBeNull();
1854
- });
1855
-
1856
- it('keeps the directory it already holds when a later read fails, and says so', async () => {
1857
- const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
1858
- const { controller, failDirectory } = makeHarness();
1859
-
1860
- await controller.refresh();
1861
- expect(controller.getSnapshot().directory?.revision).toBe(9_000);
1862
-
1863
- failDirectory(new Error('boom'));
1864
- await controller.refresh();
1865
-
1866
- // Degraded, not blank. A transient outage must not empty a switcher the
1867
- // user is looking at — but it must not pretend either, so the error is on
1868
- // the snapshot for the surface to render.
1869
- const snap = controller.getSnapshot();
1870
- expect(snap.directory?.revision).toBe(9_000);
1871
- expect(snap.error).toBe('boom');
1872
- expect(snap.loading).toBe(false);
1873
- expect(warnSpy).toHaveBeenCalledWith(
1874
- '[AccountDialogController] directory refresh failed',
1875
- { component: 'AccountDialogController' },
1876
- expect.any(Error),
1877
- );
1878
- warnSpy.mockRestore();
1879
- });
1880
-
1881
- it('spins only while there is nothing to show', async () => {
1882
- const { controller } = makeHarness();
1883
- const loading: boolean[] = [];
1884
- controller.subscribe((s) => loading.push(s.loading));
1885
-
1886
- // First read: nothing held, so the switcher has nothing to render.
1887
- await controller.refresh();
1888
- expect(loading).toContain(true);
1889
- expect(controller.getSnapshot().loading).toBe(false);
1890
-
1891
- // Second read, behind a directory already on screen: refreshed in place,
1892
- // never blanked back to a spinner.
1893
- loading.length = 0;
1894
- await controller.refresh();
1895
- expect(loading).not.toContain(true);
1896
- });
1897
-
1898
- it('removes ONE route to a shared account, naming the pair and not the account', async () => {
1899
- const { controller, urls, bodies } = directoryHarness('ctx-nate-org');
1900
- await controller.refresh();
1901
-
1902
- const inFlight: Array<string | null> = [];
1903
- controller.subscribe((s) => inFlight.push(s.removingContextId));
1904
-
1905
- expect(await controller.signOutContext('ctx-alice-org')).toBe(true);
1906
-
1907
- const index = urls.indexOf('/session/device/signout');
1908
- expect(index).toBeGreaterThanOrEqual(0);
1909
- // `{ contextId }`, never `{ accountId }`. Both people reach `org` here, and
1910
- // an account id would revoke Nate's route as a side effect of Alice tidying
1911
- // her own list.
1912
- expect(bodies[index]).toEqual({ contextId: 'ctx-alice-org' });
1913
- expect(inFlight).toContain('ctx-alice-org');
1914
- expect(controller.getSnapshot().removingContextId).toBeNull();
1915
- });
1916
-
1917
- it('removes ONE PERSON through the principal id, not through their contexts', async () => {
1918
- const { controller, urls, bodies } = directoryHarness('ctx-nate-org');
1919
- await controller.refresh();
1920
-
1921
- const inFlight: Array<string | null> = [];
1922
- controller.subscribe((s) => inFlight.push(s.removingPrincipalId));
1923
-
1924
- expect(await controller.signOutPrincipal('p-alice')).toBe(true);
1925
-
1926
- const index = urls.indexOf('/session/device/signout');
1927
- // `{ principalId }` — one call, not a loop over that person's contexts, and
1928
- // emphatically not the context endpoint: pointing this at `{ contextId }`
1929
- // would drop one pair and leave the person on the device.
1930
- expect(bodies[index]).toEqual({ principalId: 'p-alice' });
1931
- expect(urls.filter((u) => u === '/session/device/signout')).toHaveLength(1);
1932
- expect(inFlight).toContain('p-alice');
1933
- expect(controller.getSnapshot().removingPrincipalId).toBeNull();
1934
- });
1935
-
1936
- it('refuses a second removal while one is in flight, of either kind', async () => {
1937
- const { controller, sc } = directoryHarness('ctx-nate-org');
1938
- await controller.refresh();
1939
- let release: () => void = () => undefined;
1940
- jest.spyOn(sc, 'signOutContext').mockImplementation(
1941
- () => new Promise<void>((resolve) => { release = resolve; }),
1942
- );
1943
- const principalSpy = jest.spyOn(sc, 'signOutPrincipal').mockResolvedValue(undefined);
1944
-
1945
- const first = controller.signOutContext('ctx-alice-org');
1946
- // The two removals share one gate: they mutate the same device and a
1947
- // concurrent pair would race for which replacement context ends up active.
1948
- expect(await controller.signOutPrincipal('p-alice')).toBe(false);
1949
- expect(principalSpy).not.toHaveBeenCalled();
1950
-
1951
- release();
1952
- expect(await first).toBe(true);
1953
- });
1954
-
1955
- it('surfaces a failed removal without wedging the flag', async () => {
1956
- const { controller, sc } = directoryHarness('ctx-nate-org');
1957
- await controller.refresh();
1958
- // A context id is not stable across a removal, so a stale one is ordinary:
1959
- // the server refuses and the next read simply does not offer it.
1960
- jest.spyOn(sc, 'signOutContext').mockRejectedValue(new Error('Context not on this device'));
1961
-
1962
- expect(await controller.signOutContext('ctx-gone')).toBe(false);
1963
-
1964
- expect(controller.getSnapshot().error).toBe('Context not on this device');
1965
- expect(controller.getSnapshot().removingContextId).toBeNull();
1966
- });
1967
-
1968
- it('makes no directory call while signed out', async () => {
1969
- const { controller, oxy, urls } = directoryHarness(null);
1970
- oxy.getAccessToken.mockReturnValue(null);
1971
- oxy.listAccounts.mockResolvedValue([]);
1972
-
1973
- await controller.refresh();
1974
-
1975
- expect(urls).not.toContain('/session/device/directory');
1976
- expect(controller.getSnapshot().directory).toBeNull();
1977
- });
1978
- });
1979
-
1980
- /** A promise whose settlement the test controls — a slow network, on demand. */
1981
- function deferred<T>(): {
1982
- promise: Promise<T>;
1983
- resolve: (value: T) => void;
1984
- reject: (error: unknown) => void;
1985
- } {
1986
- let resolve: (value: T) => void = () => undefined;
1987
- let reject: (error: unknown) => void = () => undefined;
1988
- const promise = new Promise<T>((res, rej) => {
1989
- resolve = res;
1990
- reject = rej;
1991
- });
1992
- return { promise, resolve, reject };
1993
- }
1994
-
1995
- const CLAIMED_SESSION = {
1996
- accessToken: 'access-claimed',
1997
- sessionId: 'sess-claimed',
1998
- deviceId: 'device-1',
1999
- expiresAt: '2030-01-01T00:00:00Z',
2000
- user: user('b1'),
2001
- };
2002
-
2003
- describe('AccountDialogController — an abandoned attempt stays abandoned', () => {
2004
- it('withdraws a request that is created only after the user closed the dialog, and wires nothing up', async () => {
2005
- jest.useFakeTimers();
2006
- try {
2007
- const { controller, oxy } = makeHarness();
2008
- const created = deferred<typeof DELIVERY_HANDLE & { expiresAt: number }>();
2009
- oxy.startCommonsSignIn.mockReturnValue(created.promise);
2010
-
2011
- const running = controller.showQr();
2012
- expect(controller.getSnapshot().signIn.phase).toBe('starting');
2013
- // Closed on a slow connection: there is no authorize code to withdraw yet.
2014
- controller.cancelSignIn();
2015
- expect(oxy.denyCommonsSignIn).not.toHaveBeenCalled();
2016
-
2017
- created.resolve({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
2018
- await running;
2019
-
2020
- // The late request is withdrawn the moment it exists, and nothing revives:
2021
- // no waiting surface, no delivery, no poll.
2022
- expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
2023
- expect(controller.getSnapshot().signIn.phase).toBe('idle');
2024
- expect(controller.getSnapshot().signIn.authorizeCode).toBeNull();
2025
- expect(oxy.deliverCommonsSignIn).not.toHaveBeenCalled();
2026
- await jest.advanceTimersByTimeAsync(10_000);
2027
- expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
2028
- } finally {
2029
- jest.useRealTimers();
2030
- }
2031
- });
2032
-
2033
- it('lets a newer attempt win over a slower earlier one', async () => {
2034
- const { controller, oxy } = makeHarness();
2035
- const first = deferred<unknown>();
2036
- oxy.startCommonsSignIn
2037
- .mockReturnValueOnce(first.promise)
2038
- .mockResolvedValueOnce({
2039
- sessionToken: 'tok-2',
2040
- authorizeCode: 'CODE-2',
2041
- qrPayload: 'oxycommons://approve?code=CODE-2',
2042
- expiresAt: Date.now() + 600_000,
2043
- status: 'pending',
2044
- });
2045
-
2046
- const slow = controller.showQr();
2047
- await controller.showQr();
2048
- first.resolve({
2049
- sessionToken: 'tok-1',
2050
- authorizeCode: 'CODE-1',
2051
- qrPayload: 'oxycommons://approve?code=CODE-1',
2052
- expiresAt: Date.now() + 600_000,
2053
- status: 'pending',
2054
- });
2055
- await slow;
2056
-
2057
- expect(controller.getSnapshot().signIn.authorizeCode).toBe('CODE-2');
2058
- expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('CODE-1');
2059
- expect(oxy.denyCommonsSignIn).not.toHaveBeenCalledWith('CODE-2');
2060
- controller.cancelSignIn();
2061
- });
2062
-
2063
- it('never installs a shared-identity session that was minted after the user cancelled', async () => {
2064
- const { controller, oxy, commitSession, onSignedIn } = makeHarness();
2065
- const minted = deferred<SessionLoginResponse>();
2066
- oxy.signInWithSharedIdentity.mockReturnValue(minted.promise);
2067
-
2068
- const running = controller.signInWithOxy();
2069
- // Minted without planting: the controller decides whether to install it.
2070
- expect(oxy.signInWithSharedIdentity).toHaveBeenCalledWith({ plantTokens: false });
2071
- controller.cancelSignIn();
2072
- minted.resolve({
2073
- sessionId: 'sess-shared',
2074
- deviceId: 'device-1',
2075
- expiresAt: '2030-01-01T00:00:00Z',
2076
- accessToken: 'access-shared',
2077
- user: { id: 'a1', username: 'user_a1' },
2078
- });
2079
- await running;
2080
-
2081
- expect(oxy.setTokens).not.toHaveBeenCalled();
2082
- expect(commitSession).not.toHaveBeenCalled();
2083
- expect(onSignedIn).not.toHaveBeenCalled();
2084
- // …and the abandoned attempt does not fall through to a QR request either.
2085
- expect(oxy.startCommonsSignIn).not.toHaveBeenCalled();
2086
- expect(controller.getSnapshot().signIn.phase).toBe('idle');
2087
- });
2088
-
2089
- it('never installs a session whose claim returned after the user cancelled', async () => {
2090
- jest.useFakeTimers();
2091
- try {
2092
- const { controller, oxy, commitSession, onSignedIn } = makeHarness();
2093
- oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
2094
- oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-claimed', status: 'authorized' });
2095
- const claim = deferred<typeof CLAIMED_SESSION>();
2096
- oxy.claimSessionByToken.mockReturnValue(claim.promise);
2097
-
2098
- await controller.showQr();
2099
- await jest.advanceTimersByTimeAsync(1000); // poll → authorized → claim in flight
2100
- expect(controller.getSnapshot().signIn.phase).toBe('authorized');
2101
-
2102
- controller.cancelSignIn();
2103
- claim.resolve(CLAIMED_SESSION);
2104
- await jest.advanceTimersByTimeAsync(0);
2105
-
2106
- expect(oxy.setTokens).not.toHaveBeenCalled();
2107
- expect(commitSession).not.toHaveBeenCalled();
2108
- expect(onSignedIn).not.toHaveBeenCalled();
2109
- expect(controller.getSnapshot().signIn.phase).toBe('idle');
2110
- } finally {
2111
- jest.useRealTimers();
2112
- }
2113
- });
2114
-
2115
- it('plants the claimed bearer at the install point, immediately before the commit', async () => {
2116
- jest.useFakeTimers();
2117
- try {
2118
- const { controller, oxy, commitSession } = makeHarness();
2119
- oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
2120
- oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-claimed', status: 'authorized' });
2121
- oxy.claimSessionByToken.mockResolvedValue(CLAIMED_SESSION);
2122
-
2123
- await controller.showQr();
2124
- await jest.advanceTimersByTimeAsync(1000);
2125
-
2126
- expect(oxy.setTokens).toHaveBeenCalledWith('access-claimed');
2127
- expect(oxy.setTokens.mock.invocationCallOrder[0]).toBeLessThan(
2128
- commitSession.mock.invocationCallOrder[0],
2129
- );
2130
- expect(controller.getSnapshot().signIn.phase).toBe('completed');
2131
- } finally {
2132
- jest.useRealTimers();
2133
- }
2134
- });
2135
-
2136
- it('restores the previous bearer when the commit fails, and fails the attempt', async () => {
2137
- jest.useFakeTimers();
2138
- try {
2139
- const { controller, oxy, commitSession, onSignedIn } = makeHarness();
2140
- commitSession.mockRejectedValue(new Error('persist failed'));
2141
- oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
2142
- oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-claimed', status: 'authorized' });
2143
- oxy.claimSessionByToken.mockResolvedValue(CLAIMED_SESSION);
2144
-
2145
- await controller.showQr();
2146
- await jest.advanceTimersByTimeAsync(1000);
2147
-
2148
- // Requests must not keep going out as an account the device never took.
2149
- expect(oxy.getAccessToken()).toBe('access-token');
2150
- expect(onSignedIn).not.toHaveBeenCalled();
2151
- expect(controller.getSnapshot().signIn.phase).toBe('error');
2152
- expect(controller.getSnapshot().signIn.failure).toBe('unknown');
2153
- } finally {
2154
- jest.useRealTimers();
2155
- }
2156
- });
2157
-
2158
- it('falls through to the QR handoff when a shared-identity session cannot be committed', async () => {
2159
- const { controller, oxy, commitSession } = makeHarness();
2160
- const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
2161
- commitSession.mockRejectedValue(new Error('persist failed'));
2162
- oxy.signInWithSharedIdentity.mockResolvedValue({
2163
- sessionId: 'sess-shared',
2164
- deviceId: 'device-1',
2165
- expiresAt: '2030-01-01T00:00:00Z',
2166
- accessToken: 'access-shared',
2167
- user: { id: 'a1', username: 'user_a1' },
2168
- });
2169
- oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
2170
-
2171
- await controller.signInWithOxy();
2172
-
2173
- expect(oxy.getAccessToken()).toBe('access-token');
2174
- expect(controller.getSnapshot().signIn.phase).toBe('waiting');
2175
- controller.cancelSignIn();
2176
- warnSpy.mockRestore();
2177
- });
2178
-
2179
- it('classifies an unreachable server as a network failure', async () => {
2180
- const { controller, oxy } = makeHarness();
2181
- oxy.startCommonsSignIn.mockRejectedValue(
2182
- Object.assign(new Error('Network error - failed to connect to server'), { code: 'NETWORK_ERROR', status: 0 }),
2183
- );
2184
-
2185
- await controller.showQr();
2186
-
2187
- expect(controller.getSnapshot().signIn.phase).toBe('error');
2188
- expect(controller.getSnapshot().signIn.failure).toBe('network');
2189
- });
2190
-
2191
- it('moves the attempt identity on every new attempt, and keeps it on the failure it belongs to', async () => {
2192
- const { controller, oxy } = makeHarness({ clientId: null });
2193
- oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
2194
-
2195
- await controller.showQr();
2196
- const firstFailure = controller.getSnapshot().signIn.attempt;
2197
- await controller.showQr();
2198
-
2199
- // Same message, different attempt — a surface can tell a repeat of the
2200
- // failure it already reported from a new one.
2201
- expect(controller.getSnapshot().signIn.failure).toBe('not-configured');
2202
- expect(controller.getSnapshot().signIn.attempt).not.toBe(firstFailure);
2203
- });
2204
- });
2205
-
2206
- describe('AccountDialogController — "Try again" repeats the user\'s choice', () => {
2207
- it('retries "Sign in with Oxy" after a failed Oxy attempt', async () => {
2208
- const { controller, oxy } = makeHarness();
2209
- oxy.startCommonsSignIn.mockRejectedValueOnce(new Error('boom'));
2210
-
2211
- await controller.signInWithOxy();
2212
- expect(controller.getSnapshot().signIn.phase).toBe('error');
2213
-
2214
- oxy.startCommonsSignIn.mockResolvedValue({ ...DELIVERY_HANDLE, expiresAt: Date.now() + 600_000 });
2215
- await controller.retrySignIn();
2216
-
2217
- expect(oxy.signInWithSharedIdentity).toHaveBeenCalledTimes(2);
2218
- expect(controller.getSnapshot().signIn.phase).toBe('waiting');
2219
- controller.cancelSignIn();
2220
- });
2221
-
2222
- });
2223
-
2224
- describe('AccountDialogController — device mutations report their own outcome', () => {
2225
- function directoryClient() {
2226
- const sc = new TestSessionClient(host().host);
2227
- const controller = createAccountDialogController({
2228
- oxyServices: makeOxy() as unknown as OxyServices,
2229
- sessionClient: sc,
2230
- clientId: 'oxy_dk_test',
2231
- commitSession: jest.fn().mockResolvedValue(undefined),
2232
- });
2233
- return { controller, sc };
2234
- }
2235
-
2236
- it('reports a switch that happened as a switch, even when the re-read after it fails', async () => {
2237
- const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
2238
- const { controller, sc } = directoryClient();
2239
- jest.spyOn(sc, 'activateContext').mockResolvedValue(undefined);
2240
- jest.spyOn(sc, 'refreshDirectory').mockRejectedValue(new Error('directory boom'));
2241
-
2242
- // The subject DID change. Calling that a failed switch would invite the user
2243
- // to repeat it — and skip the cache reset a switch requires.
2244
- expect(await controller.activateContext('ctx-b')).toBe(true);
2245
- // The re-read's failure is still reported, as the directory's.
2246
- expect(controller.getSnapshot().error).toBe('directory boom');
2247
- expect(controller.getSnapshot().activatingContextId).toBeNull();
2248
- warnSpy.mockRestore();
2249
- });
2250
-
2251
- it('never lets an older refresh failure overwrite a newer successful read', async () => {
2252
- const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
2253
- const { controller, sc } = directoryClient();
2254
- const older = deferred<void>();
2255
- jest
2256
- .spyOn(sc, 'refreshDirectory')
2257
- .mockReturnValueOnce(older.promise)
2258
- .mockResolvedValueOnce(undefined);
2259
-
2260
- const first = controller.refresh();
2261
- expect(await controller.refresh()).toBe(true);
2262
- older.reject(new Error('stale boom'));
2263
- expect(await first).toBe(false);
2264
-
2265
- expect(controller.getSnapshot().error).toBeNull();
2266
- expect(controller.getSnapshot().loading).toBe(false);
2267
- warnSpy.mockRestore();
2268
- });
2269
-
2270
- it('refuses a removal while a switch is in flight, and a switch while a removal is', async () => {
2271
- const { controller, sc } = directoryClient();
2272
- const switching = deferred<void>();
2273
- jest.spyOn(sc, 'activateContext').mockReturnValue(switching.promise);
2274
- const removeContext = jest.spyOn(sc, 'signOutContext').mockResolvedValue(undefined);
2275
- const removePrincipal = jest.spyOn(sc, 'signOutPrincipal').mockResolvedValue(undefined);
2276
- jest.spyOn(sc, 'refreshDirectory').mockResolvedValue(undefined);
2277
-
2278
- const activation = controller.activateContext('ctx-b');
2279
- expect(controller.isDeviceMutationInFlight()).toBe(true);
2280
- // Refused, not queued: nothing destructive may fire later, after the switch.
2281
- expect(await controller.signOutContext('ctx-a')).toBe(false);
2282
- expect(await controller.signOutPrincipal('p-a')).toBe(false);
2283
- expect(removeContext).not.toHaveBeenCalled();
2284
- expect(removePrincipal).not.toHaveBeenCalled();
2285
-
2286
- switching.resolve();
2287
- expect(await activation).toBe(true);
2288
- expect(controller.isDeviceMutationInFlight()).toBe(false);
2289
-
2290
- const removing = deferred<void>();
2291
- removeContext.mockReturnValue(removing.promise);
2292
- const removal = controller.signOutContext('ctx-a');
2293
- expect(await controller.activateContext('ctx-c')).toBe(false);
2294
- removing.resolve();
2295
- expect(await removal).toBe(true);
2296
- await flush();
2297
- expect(removeContext).toHaveBeenCalledTimes(1);
2298
- });
2299
-
2300
- it('runs a host operation under the same gate, and releases it when the operation rejects', async () => {
2301
- const { controller, sc } = directoryClient();
2302
- const signingOut = deferred<string>();
2303
- const activate = jest.spyOn(sc, 'activateContext').mockResolvedValue(undefined);
2304
- jest.spyOn(sc, 'refreshDirectory').mockResolvedValue(undefined);
2305
-
2306
- const exclusive = controller.runDeviceMutation(() => signingOut.promise);
2307
- expect(await controller.activateContext('ctx-b')).toBe(false);
2308
- expect(await controller.runDeviceMutation(async () => 'second')).toEqual({ ran: false });
2309
- expect(activate).not.toHaveBeenCalled();
2310
- signingOut.resolve('done');
2311
- expect(await exclusive).toEqual({ ran: true, value: 'done' });
2312
-
2313
- await expect(
2314
- controller.runDeviceMutation(async () => {
2315
- throw new Error('boom');
2316
- }),
2317
- ).rejects.toThrow('boom');
2318
- expect(controller.isDeviceMutationInFlight()).toBe(false);
2319
- });
2320
- });
2321
-
2322
- it('createAccountDialogController returns an AccountDialogController instance', () => {
2323
- const { controller } = makeHarness();
2324
- expect(controller).toBeInstanceOf(AccountDialogController);
2325
- });
2326
-
2327
- // Ensure the exported type surface is reachable at compile time for binders.
2328
- const _typecheck: MinimalUserData | null = null;
2329
- void _typecheck;
2330
-
2331
- describe('AccountDialogController — choosing a device account row (OxyHQ/oxy#1375 items 20 and 21)', () => {
2332
- /**
2333
- * The device's directory as Commons leaves it: `qa` on the device and active,
2334
- * plus (when `withOrg`) an organization `qa` also operates.
2335
- */
2336
- const deviceDirectory = (activeContextId: string | null, withOrg = false) => ({
2337
- deviceId: 'device-1',
2338
- revision: 4,
2339
- activeContextId,
2340
- updatedAt: 1_720_000_000_000,
2341
- principals: [
2342
- {
2343
- id: 'p-qa',
2344
- userId: 'qa',
2345
- authuser: 0,
2346
- user: { id: 'qa', username: 'qatest0925' },
2347
- contexts: [
2348
- {
2349
- id: 'ctx-qa',
2350
- accountId: 'qa',
2351
- kind: 'personal' as const,
2352
- relationship: 'self' as const,
2353
- account: { id: 'qa', username: 'qatest0925' },
2354
- onDevice: true,
2355
- available: true,
2356
- active: activeContextId === 'ctx-qa',
2357
- lastUsedAt: null,
2358
- },
2359
- ...(withOrg
2360
- ? [
2361
- {
2362
- id: 'ctx-qa-org',
2363
- accountId: 'org',
2364
- kind: 'organization' as const,
2365
- relationship: 'owner' as const,
2366
- account: { id: 'org', username: 'oxy' },
2367
- onDevice: true,
2368
- available: true,
2369
- active: activeContextId === 'ctx-qa-org',
2370
- lastUsedAt: null,
2371
- },
2372
- ]
2373
- : []),
2374
- ],
2375
- },
2376
- ],
2377
- });
2378
-
2379
- const sharedSession: SessionLoginResponse = {
2380
- sessionId: 'sess-shared',
2381
- deviceId: 'device-1',
2382
- expiresAt: '2030-01-01T00:00:00Z',
2383
- accessToken: 'access-shared',
2384
- user: { id: 'qa', username: 'qatest0925', name: { displayName: 'QA' } },
2385
- };
2386
-
2387
- /**
2388
- * A device that still lists `qa` (Commons holds the shared identity) while
2389
- * THIS app holds no bearer — Mention after "Sign out".
2390
- */
2391
- async function signedOutDevice(options: { withOrg?: boolean } = {}) {
2392
- const oxy = makeOxy();
2393
- const urls: string[] = [];
2394
- const sc = new TestSessionClient({
2395
- makeRequest: jest.fn(async (_method: string, url: string) => {
2396
- urls.push(url);
2397
- if (url === '/session/device/directory') return deviceDirectory('ctx-qa', options.withOrg);
2398
- if (url === '/session/device/activate') {
2399
- return { directory: { ...deviceDirectory('ctx-qa-org', true), revision: 5 }, activeToken: null };
2400
- }
2401
- return undefined;
2402
- }),
2403
- getBaseURL: () => 'http://test.invalid',
2404
- getAccessToken: () => oxy.getAccessToken(),
2405
- getDeviceCredential: () => null,
2406
- onTokensChanged: () => () => undefined,
2407
- setTokens: jest.fn(),
2408
- getCurrentAccountId: () => null,
2409
- });
2410
- // The directory this client read while it was still signed in.
2411
- await sc.refreshDirectory();
2412
- oxy.emitTokenChange(null);
2413
- const commitSession = jest.fn().mockResolvedValue(undefined);
2414
- const onSignedIn = jest.fn();
2415
- const controller = createAccountDialogController({
2416
- oxyServices: oxy as unknown as OxyServices,
2417
- sessionClient: sc,
2418
- clientId: 'oxy_dk_test',
2419
- commitSession,
2420
- onSignedIn,
2421
- });
2422
- controller.start();
2423
- await flush();
2424
- urls.length = 0;
2425
- return { controller, oxy, sc, urls, commitSession, onSignedIn };
2426
- }
2427
-
2428
- it('publishes that this client is signed out while the device still lists an active account', async () => {
2429
- const { controller } = await signedOutDevice();
2430
- const snap = controller.getSnapshot();
2431
- expect(snap.hasSession).toBe(false);
2432
- // The precondition of the bug: the row the sheet renders reads as "active".
2433
- expect(snap.activeContext?.contextId).toBe('ctx-qa');
2434
- controller.destroy();
2435
- });
2436
-
2437
- it('signed out, choosing the listed account signs in through the shared identity, like "Continue with Oxy"', async () => {
2438
- const { controller, oxy, urls, commitSession, onSignedIn } = await signedOutDevice();
2439
- oxy.signInWithSharedIdentity.mockResolvedValue(sharedSession);
2440
-
2441
- // It used to short-circuit on "already the active row" and report success,
2442
- // closing the sheet on an app that was still signed out.
2443
- expect(await controller.chooseContext('ctx-qa')).toBe('signing-in');
2444
-
2445
- expect(oxy.signInWithSharedIdentity).toHaveBeenCalledWith({ plantTokens: false });
2446
- expect(commitSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'sess-shared' }));
2447
- expect(onSignedIn).toHaveBeenCalledWith(expect.objectContaining({ id: 'qa' }));
2448
- expect(controller.getSnapshot().hasSession).toBe(true);
2449
- expect(controller.getSnapshot().signIn.phase).toBe('completed');
2450
- // The silent mint already landed on the chosen pair: no switch on top.
2451
- expect(urls).not.toContain('/session/device/activate');
2452
- controller.destroy();
2453
- });
2454
-
2455
- it('signed out with no shared identity, choosing the account starts the same request "Continue with Oxy" does', async () => {
2456
- const { controller, oxy, onSignedIn } = await signedOutDevice();
2457
- oxy.signInWithSharedIdentity.mockResolvedValue(null);
2458
- oxy.startCommonsSignIn.mockResolvedValue({
2459
- sessionToken: 'secret-tok',
2460
- authorizeCode: 'AUTH-CODE',
2461
- qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
2462
- expiresAt: Date.now() + 300_000,
2463
- status: 'pending',
2464
- });
2465
-
2466
- expect(await controller.chooseContext('ctx-qa')).toBe('signing-in');
2467
-
2468
- expect(oxy.startCommonsSignIn).toHaveBeenCalledWith({ clientId: 'oxy_dk_test' });
2469
- expect(controller.getSnapshot().view).toBe('qr');
2470
- expect(controller.getSnapshot().signIn.phase).toBe('waiting');
2471
- expect(onSignedIn).not.toHaveBeenCalled();
2472
- controller.destroy();
2473
- });
2474
-
2475
- it('signed out, choosing an organization row signs in and then activates that row', async () => {
2476
- const { controller, oxy, urls } = await signedOutDevice({ withOrg: true });
2477
- oxy.signInWithSharedIdentity.mockResolvedValue(sharedSession);
2478
-
2479
- expect(await controller.chooseContext('ctx-qa-org')).toBe('switched');
2480
-
2481
- expect(oxy.signInWithSharedIdentity).toHaveBeenCalledTimes(1);
2482
- expect(urls).toContain('/session/device/activate');
2483
- expect(controller.getSnapshot().activeContext?.contextId).toBe('ctx-qa-org');
2484
- controller.destroy();
2485
- });
2486
-
2487
- it('signed in, the active row is "current" and any other row is a switch', async () => {
2488
- const { controller, oxy, urls } = await signedOutDevice({ withOrg: true });
2489
- oxy.emitTokenChange('access-token');
2490
- await flush();
2491
- urls.length = 0;
2492
- expect(controller.getSnapshot().hasSession).toBe(true);
2493
-
2494
- expect(await controller.chooseContext('ctx-qa')).toBe('current');
2495
- expect(urls).not.toContain('/session/device/activate');
2496
-
2497
- expect(await controller.chooseContext('ctx-qa-org')).toBe('switched');
2498
- expect(urls).toContain('/session/device/activate');
2499
- expect(oxy.signInWithSharedIdentity).not.toHaveBeenCalled();
2500
- controller.destroy();
2501
- });
2502
- });