@oxyhq/core 5.5.0 → 7.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 (196) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +7 -4
  3. package/dist/cjs/OxyServices.base.js +7 -102
  4. package/dist/cjs/boot/coldBootV2.js +350 -0
  5. package/dist/cjs/boot/deviceBootReturn.js +152 -0
  6. package/dist/cjs/crypto/keyManager.js +95 -0
  7. package/dist/cjs/i18n/locales/en-US.json +13 -1
  8. package/dist/cjs/i18n/locales/es-ES.json +13 -1
  9. package/dist/cjs/i18n/locales/locales/en-US.json +13 -1
  10. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -1
  11. package/dist/cjs/index.js +54 -45
  12. package/dist/cjs/mixins/OxyServices.accounts.js +13 -26
  13. package/dist/cjs/mixins/OxyServices.auth.js +66 -201
  14. package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
  15. package/dist/cjs/mixins/index.js +8 -17
  16. package/dist/cjs/server/index.js +2 -2
  17. package/dist/cjs/session/SessionClient.js +40 -1
  18. package/dist/cjs/session/authStateStore.js +284 -0
  19. package/dist/cjs/session/refresh.js +264 -0
  20. package/dist/cjs/shared/utils/debugUtils.js +3 -3
  21. package/dist/cjs/utils/accountUtils.js +1 -55
  22. package/dist/cjs/utils/authWebUrl.js +10 -39
  23. package/dist/cjs/utils/coldBoot.js +10 -8
  24. package/dist/cjs/utils/platform.js +19 -0
  25. package/dist/cjs/utils/registrableApex.js +49 -0
  26. package/dist/cjs/utils/ssoBounce.js +15 -362
  27. package/dist/cjs/utils/validationUtils.js +57 -0
  28. package/dist/esm/.tsbuildinfo +1 -1
  29. package/dist/esm/HttpService.js +7 -4
  30. package/dist/esm/OxyServices.base.js +7 -102
  31. package/dist/esm/boot/coldBootV2.js +344 -0
  32. package/dist/esm/boot/deviceBootReturn.js +146 -0
  33. package/dist/esm/crypto/keyManager.js +95 -0
  34. package/dist/esm/i18n/locales/en-US.json +13 -1
  35. package/dist/esm/i18n/locales/es-ES.json +13 -1
  36. package/dist/esm/i18n/locales/locales/en-US.json +13 -1
  37. package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
  38. package/dist/esm/index.js +34 -17
  39. package/dist/esm/mixins/OxyServices.accounts.js +13 -26
  40. package/dist/esm/mixins/OxyServices.auth.js +66 -201
  41. package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
  42. package/dist/esm/mixins/index.js +8 -17
  43. package/dist/esm/server/index.js +1 -1
  44. package/dist/esm/session/SessionClient.js +40 -1
  45. package/dist/esm/session/authStateStore.js +278 -0
  46. package/dist/esm/session/refresh.js +257 -0
  47. package/dist/esm/shared/utils/debugUtils.js +3 -3
  48. package/dist/esm/utils/accountUtils.js +0 -53
  49. package/dist/esm/utils/authWebUrl.js +9 -37
  50. package/dist/esm/utils/coldBoot.js +10 -8
  51. package/dist/esm/utils/platform.js +18 -0
  52. package/dist/esm/utils/registrableApex.js +46 -0
  53. package/dist/esm/utils/ssoBounce.js +14 -345
  54. package/dist/esm/utils/validationUtils.js +56 -0
  55. package/dist/types/.tsbuildinfo +1 -1
  56. package/dist/types/HttpService.d.ts +14 -1
  57. package/dist/types/OxyServices.base.d.ts +0 -52
  58. package/dist/types/OxyServices.d.ts +0 -25
  59. package/dist/types/boot/coldBootV2.d.ts +76 -0
  60. package/dist/types/boot/deviceBootReturn.d.ts +83 -0
  61. package/dist/types/crypto/keyManager.d.ts +21 -0
  62. package/dist/types/index.d.ts +14 -21
  63. package/dist/types/mixins/OxyServices.accounts.d.ts +24 -29
  64. package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
  65. package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
  66. package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
  67. package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
  68. package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
  69. package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
  70. package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
  71. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
  72. package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
  73. package/dist/types/mixins/OxyServices.features.d.ts +0 -2
  74. package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
  75. package/dist/types/mixins/OxyServices.language.d.ts +0 -2
  76. package/dist/types/mixins/OxyServices.links.d.ts +0 -2
  77. package/dist/types/mixins/OxyServices.location.d.ts +0 -2
  78. package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
  79. package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
  80. package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
  81. package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
  82. package/dist/types/mixins/OxyServices.security.d.ts +0 -2
  83. package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
  84. package/dist/types/mixins/OxyServices.user.d.ts +0 -2
  85. package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
  86. package/dist/types/mixins/index.d.ts +5 -9
  87. package/dist/types/models/interfaces.d.ts +0 -67
  88. package/dist/types/models/session.d.ts +4 -5
  89. package/dist/types/server/index.d.ts +1 -1
  90. package/dist/types/session/SessionClient.d.ts +26 -1
  91. package/dist/types/session/authStateStore.d.ts +119 -0
  92. package/dist/types/session/refresh.d.ts +93 -0
  93. package/dist/types/shared/utils/debugUtils.d.ts +3 -3
  94. package/dist/types/utils/accountUtils.d.ts +2 -17
  95. package/dist/types/utils/authWebUrl.d.ts +9 -35
  96. package/dist/types/utils/coldBoot.d.ts +17 -14
  97. package/dist/types/utils/platform.d.ts +14 -0
  98. package/dist/types/utils/registrableApex.d.ts +31 -0
  99. package/dist/types/utils/ssoBounce.d.ts +14 -280
  100. package/dist/types/utils/validationUtils.d.ts +15 -0
  101. package/package.json +2 -2
  102. package/src/HttpService.ts +20 -4
  103. package/src/OxyServices.base.ts +7 -112
  104. package/src/OxyServices.ts +0 -38
  105. package/src/boot/__tests__/coldBootV2.test.ts +317 -0
  106. package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
  107. package/src/boot/coldBootV2.ts +426 -0
  108. package/src/boot/deviceBootReturn.ts +195 -0
  109. package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
  110. package/src/crypto/keyManager.ts +101 -0
  111. package/src/i18n/locales/en-US.json +13 -1
  112. package/src/i18n/locales/es-ES.json +13 -1
  113. package/src/index.ts +75 -65
  114. package/src/mixins/OxyServices.accounts.ts +27 -39
  115. package/src/mixins/OxyServices.auth.ts +78 -253
  116. package/src/mixins/OxyServices.deviceBoot.ts +146 -0
  117. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
  118. package/src/mixins/__tests__/accounts.test.ts +17 -44
  119. package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
  120. package/src/mixins/index.ts +10 -23
  121. package/src/models/interfaces.ts +0 -79
  122. package/src/models/session.ts +4 -5
  123. package/src/server/index.ts +1 -1
  124. package/src/session/SessionClient.ts +53 -2
  125. package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
  126. package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
  127. package/src/session/__tests__/SessionClient.state.test.ts +18 -5
  128. package/src/session/__tests__/authStateStore.test.ts +209 -0
  129. package/src/session/__tests__/refresh.test.ts +256 -0
  130. package/src/session/authStateStore.ts +335 -0
  131. package/src/session/refresh.ts +334 -0
  132. package/src/shared/utils/debugUtils.ts +3 -3
  133. package/src/utils/__tests__/authWebUrl.test.ts +5 -40
  134. package/src/utils/__tests__/registrableApex.test.ts +62 -0
  135. package/src/utils/__tests__/validationUtils.test.ts +30 -0
  136. package/src/utils/accountUtils.ts +2 -65
  137. package/src/utils/authWebUrl.ts +9 -39
  138. package/src/utils/coldBoot.ts +17 -14
  139. package/src/utils/platform.ts +21 -0
  140. package/src/utils/registrableApex.ts +45 -0
  141. package/src/utils/ssoBounce.ts +14 -393
  142. package/src/utils/validationUtils.ts +62 -0
  143. package/dist/cjs/AuthManager.js +0 -1110
  144. package/dist/cjs/AuthManagerTypes.js +0 -13
  145. package/dist/cjs/CrossDomainAuth.js +0 -206
  146. package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
  147. package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
  148. package/dist/cjs/mixins/OxyServices.silent.js +0 -204
  149. package/dist/cjs/mixins/OxyServices.sso.js +0 -208
  150. package/dist/cjs/utils/fapiAutoDetect.js +0 -99
  151. package/dist/cjs/utils/ssoEstablish.js +0 -110
  152. package/dist/cjs/utils/ssoReturn.js +0 -275
  153. package/dist/esm/AuthManager.js +0 -1105
  154. package/dist/esm/AuthManagerTypes.js +0 -12
  155. package/dist/esm/CrossDomainAuth.js +0 -201
  156. package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
  157. package/dist/esm/mixins/OxyServices.redirect.js +0 -92
  158. package/dist/esm/mixins/OxyServices.silent.js +0 -202
  159. package/dist/esm/mixins/OxyServices.sso.js +0 -204
  160. package/dist/esm/utils/fapiAutoDetect.js +0 -95
  161. package/dist/esm/utils/ssoEstablish.js +0 -107
  162. package/dist/esm/utils/ssoReturn.js +0 -271
  163. package/dist/types/AuthManager.d.ts +0 -380
  164. package/dist/types/AuthManagerTypes.d.ts +0 -81
  165. package/dist/types/CrossDomainAuth.d.ts +0 -164
  166. package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
  167. package/dist/types/mixins/OxyServices.redirect.d.ts +0 -92
  168. package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
  169. package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
  170. package/dist/types/utils/fapiAutoDetect.d.ts +0 -56
  171. package/dist/types/utils/ssoEstablish.d.ts +0 -85
  172. package/dist/types/utils/ssoReturn.d.ts +0 -156
  173. package/src/AuthManager.ts +0 -1269
  174. package/src/AuthManagerTypes.ts +0 -86
  175. package/src/CrossDomainAuth.ts +0 -243
  176. package/src/__tests__/authManager.cookiePath.test.ts +0 -390
  177. package/src/__tests__/authManager.security.test.ts +0 -377
  178. package/src/__tests__/crossDomainAuth.test.ts +0 -116
  179. package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
  180. package/src/mixins/OxyServices.fedcm.ts +0 -1026
  181. package/src/mixins/OxyServices.redirect.ts +0 -122
  182. package/src/mixins/OxyServices.silent.ts +0 -272
  183. package/src/mixins/OxyServices.sso.ts +0 -261
  184. package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
  185. package/src/mixins/__tests__/fedcm.test.ts +0 -667
  186. package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
  187. package/src/mixins/__tests__/silent.test.ts +0 -102
  188. package/src/mixins/__tests__/sso.test.ts +0 -228
  189. package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
  190. package/src/utils/__tests__/fapiAutoDetect.test.ts +0 -183
  191. package/src/utils/__tests__/ssoBounce.test.ts +0 -219
  192. package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
  193. package/src/utils/__tests__/ssoReturn.test.ts +0 -276
  194. package/src/utils/fapiAutoDetect.ts +0 -91
  195. package/src/utils/ssoEstablish.ts +0 -174
  196. package/src/utils/ssoReturn.ts +0 -389
@@ -176,27 +176,22 @@ describe('OxyServices.accounts', () => {
176
176
  });
177
177
 
178
178
  describe('switchToAccount', () => {
179
- // The switch route NO LONGER returns `authuser` or sets the device cookie —
180
- // it can't (it's at /accounts/*, outside the Path=/auth cookie scope). The SDK
181
- // establishes the cookie + resolves the slot via a follow-up POST /auth/session.
179
+ // Device-first: the switch route registers the switched session in the
180
+ // server-side DeviceSession set directly and returns the fresh access token
181
+ // + the resolved `authuser`. There is NO follow-up POST /auth/session.
182
182
  const switchResponse: SwitchAccountResult = {
183
183
  sessionId: 'sess_switch',
184
184
  deviceId: 'dev_switch',
185
185
  expiresAt: '2026-06-30T01:00:00.000Z',
186
186
  accessToken: 'access_switch',
187
+ authuser: 1,
187
188
  user: { id: 'acc1', username: 'oxy-org', name: { displayName: 'Oxy Org' } },
188
189
  };
189
- // POST /auth/session establishes the cookie in a correctly-allocated NEW slot
190
- // and returns that slot + a fresh access token off the same session.
191
- const sessionResponse = { accessToken: 'access_session', authuser: 1 };
192
190
 
193
- // Route makeRequest by path: the switch call vs the /auth/session establishment.
194
191
  const routeByPath = (response = switchResponse) =>
195
- makeRequestSpy.mockImplementation((_method: string, path: string) =>
196
- Promise.resolve(path === '/auth/session' ? sessionResponse : response),
197
- );
192
+ makeRequestSpy.mockResolvedValue(response);
198
193
 
199
- it('posts to /:id/switch then establishes the cookie via POST /auth/session, planting both tokens and sweeping the cache', async () => {
194
+ it('posts to /:id/switch, plants the token, carries the response authuser, and sweeps the cache', async () => {
200
195
  const setTokensSpy = jest.spyOn(oxy, 'setTokens');
201
196
  const clearCacheSpy = jest.spyOn(oxy, 'clearCache');
202
197
  routeByPath();
@@ -210,29 +205,20 @@ describe('OxyServices.accounts', () => {
210
205
  undefined,
211
206
  expect.objectContaining({ cache: false }),
212
207
  );
213
- // Then the canonical refresh-cookie establishment under /auth (where the
214
- // device's oxy_rt_* slots ARE visible, so a NEW slot is allocated).
215
- expect(makeRequestSpy).toHaveBeenCalledWith(
216
- 'POST',
217
- '/auth/session',
218
- undefined,
219
- expect.objectContaining({ cache: false }),
220
- );
208
+ // No secondary /auth/session establishment the switch registers the
209
+ // session server-side on its own.
210
+ expect(makeRequestSpy).toHaveBeenCalledTimes(1);
221
211
 
222
- // The switch token is planted first; /auth/session's fresh token re-planted.
223
- expect(setTokensSpy).toHaveBeenNthCalledWith(1, 'access_switch');
224
- expect(setTokensSpy).toHaveBeenNthCalledWith(2, 'access_session');
225
- expect(oxy.getAccessToken()).toBe('access_session');
212
+ // The switch token is planted; the cache is swept AFTER planting.
213
+ expect(setTokensSpy).toHaveBeenCalledWith('access_switch');
214
+ expect(oxy.getAccessToken()).toBe('access_switch');
226
215
  expect(oxy.hasValidToken()).toBe(true);
227
-
228
- // Cache swept once, AFTER both tokens are planted.
229
216
  expect(clearCacheSpy).toHaveBeenCalledTimes(1);
230
- expect(setTokensSpy.mock.invocationCallOrder[1]).toBeLessThan(
217
+ expect(setTokensSpy.mock.invocationCallOrder[0]).toBeLessThan(
231
218
  clearCacheSpy.mock.invocationCallOrder[0],
232
219
  );
233
220
 
234
- // The returned session carries the target account (id-normalised) and the
235
- // slot resolved by /auth/session — NEVER the clobbering slot 0.
221
+ // The returned session carries the target account (id-normalised) + slot.
236
222
  expect(result.sessionId).toBe('sess_switch');
237
223
  expect(result.user).toEqual({ id: 'acc1', username: 'oxy-org', name: { displayName: 'Oxy Org' } });
238
224
  expect(result.authuser).toBe(1);
@@ -247,27 +233,14 @@ describe('OxyServices.accounts', () => {
247
233
  expect(makeRequestSpy.mock.calls[0][1]).toBe('/accounts/a%20b%2Fc/switch');
248
234
  });
249
235
 
250
- it('keeps the switch active in-session when /auth/session fails (best-effort cookie)', async () => {
251
- const setTokensSpy = jest.spyOn(oxy, 'setTokens');
252
- const clearCacheSpy = jest.spyOn(oxy, 'clearCache');
253
- makeRequestSpy.mockImplementation((_method: string, path: string) =>
254
- path === '/auth/session'
255
- ? Promise.reject(Object.assign(new Error('origin'), { response: { status: 403 } }))
256
- : Promise.resolve(switchResponse),
257
- );
236
+ it('omits authuser from the result when the switch response has none', async () => {
237
+ const { authuser, ...noAuthuser } = switchResponse;
238
+ routeByPath(noAuthuser as SwitchAccountResult);
258
239
 
259
240
  const result = await oxy.switchToAccount('acc1');
260
241
 
261
- // The in-session switch survives: the switch token stays planted and the
262
- // cache is still swept. The switched account just won't survive a reload
263
- // until the cookie is next established (no authuser resolved).
264
- expect(setTokensSpy).toHaveBeenCalledWith('access_switch');
265
242
  expect(oxy.getAccessToken()).toBe('access_switch');
266
- expect(clearCacheSpy).toHaveBeenCalledTimes(1);
267
243
  expect(result.authuser).toBeUndefined();
268
-
269
- setTokensSpy.mockRestore();
270
- clearCacheSpy.mockRestore();
271
244
  });
272
245
 
273
246
  it('does NOT plant or sweep when the operator is not authorized (403 surfaces via handleError)', async () => {
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Device-first password sign-in (`passwordSignIn` + `completeTwoFactorSignIn`).
3
+ * Stubs `makeRequest` and asserts the login-result contract handling: the 2FA
4
+ * arm passes through un-planted, the session arm plants its access token, the
5
+ * deviceToken is threaded into the request, and a 2FA arm from verify-login is
6
+ * a protocol error.
7
+ */
8
+ import type { LoginResult } from '@oxyhq/contracts';
9
+ import { OxyServices } from '../../OxyServices';
10
+
11
+ const SESSION_ARM: LoginResult = {
12
+ sessionId: 'sess-1',
13
+ deviceId: 'dev-1',
14
+ expiresAt: '2030-01-01T00:00:00.000Z',
15
+ accessToken: 'access-1',
16
+ refreshToken: 'refresh-1',
17
+ user: { id: 'user-1', username: 'u' },
18
+ };
19
+
20
+ const TWO_FACTOR_ARM: LoginResult = { twoFactorRequired: true, loginToken: 'login-token-1' };
21
+
22
+ describe('passwordSignIn', () => {
23
+ let oxy: OxyServices;
24
+ let makeRequest: jest.SpyInstance;
25
+ let setTokens: jest.SpyInstance;
26
+
27
+ beforeEach(() => {
28
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
29
+ makeRequest = jest.spyOn(oxy, 'makeRequest');
30
+ setTokens = jest.spyOn(oxy, 'setTokens').mockImplementation(() => undefined);
31
+ });
32
+
33
+ afterEach(() => jest.restoreAllMocks());
34
+
35
+ it('returns the 2FA arm without planting a token', async () => {
36
+ makeRequest.mockResolvedValueOnce(TWO_FACTOR_ARM);
37
+ const result = await oxy.passwordSignIn('alice', 'pw');
38
+ expect(result).toEqual(TWO_FACTOR_ARM);
39
+ expect(setTokens).not.toHaveBeenCalled();
40
+ });
41
+
42
+ it('plants the access token on the session arm and threads the deviceToken', async () => {
43
+ makeRequest.mockResolvedValueOnce(SESSION_ARM);
44
+ const result = await oxy.passwordSignIn('alice', 'pw', { deviceToken: 'dt-1', deviceName: 'Phone' });
45
+ expect(result).toEqual(SESSION_ARM);
46
+ expect(setTokens).toHaveBeenCalledWith('access-1');
47
+ expect(makeRequest).toHaveBeenCalledWith(
48
+ 'POST',
49
+ '/auth/login',
50
+ { identifier: 'alice', password: 'pw', deviceName: 'Phone', deviceFingerprint: undefined, deviceToken: 'dt-1' },
51
+ { cache: false },
52
+ );
53
+ });
54
+
55
+ it('throws on an unexpected response shape', async () => {
56
+ makeRequest.mockResolvedValueOnce({ nope: true });
57
+ await expect(oxy.passwordSignIn('alice', 'pw')).rejects.toThrow();
58
+ });
59
+ });
60
+
61
+ describe('completeTwoFactorSignIn', () => {
62
+ let oxy: OxyServices;
63
+ let makeRequest: jest.SpyInstance;
64
+ let setTokens: jest.SpyInstance;
65
+
66
+ beforeEach(() => {
67
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
68
+ makeRequest = jest.spyOn(oxy, 'makeRequest');
69
+ setTokens = jest.spyOn(oxy, 'setTokens').mockImplementation(() => undefined);
70
+ });
71
+
72
+ afterEach(() => jest.restoreAllMocks());
73
+
74
+ it('verifies the login token, plants the session, and POSTs to /security/2fa/verify-login', async () => {
75
+ makeRequest.mockResolvedValueOnce(SESSION_ARM);
76
+ const result = await oxy.completeTwoFactorSignIn({ loginToken: 'lt', token: '123456', deviceToken: 'dt-1' });
77
+ expect(result).toEqual(SESSION_ARM);
78
+ expect(setTokens).toHaveBeenCalledWith('access-1');
79
+ expect(makeRequest).toHaveBeenCalledWith(
80
+ 'POST',
81
+ '/security/2fa/verify-login',
82
+ { loginToken: 'lt', token: '123456', backupCode: undefined, deviceToken: 'dt-1', deviceName: undefined },
83
+ { cache: false },
84
+ );
85
+ });
86
+
87
+ it('throws when verify-login unexpectedly returns another 2FA challenge', async () => {
88
+ makeRequest.mockResolvedValueOnce(TWO_FACTOR_ARM);
89
+ await expect(oxy.completeTwoFactorSignIn({ loginToken: 'lt', token: '123456' })).rejects.toThrow();
90
+ });
91
+ });
@@ -7,10 +7,6 @@
7
7
 
8
8
  import { OxyServicesBase } from '../OxyServices.base';
9
9
  import { OxyServicesAuthMixin } from './OxyServices.auth';
10
- import { OxyServicesFedCMMixin } from './OxyServices.fedcm';
11
- import { OxyServicesSilentAuthMixin } from './OxyServices.silent';
12
- import { OxyServicesRedirectAuthMixin } from './OxyServices.redirect';
13
- import { OxyServicesSsoMixin } from './OxyServices.sso';
14
10
  import { OxyServicesUserMixin } from './OxyServices.user';
15
11
  import { OxyServicesIdentityMixin } from './OxyServices.identity';
16
12
  import { OxyServicesPrivacyMixin } from './OxyServices.privacy';
@@ -32,6 +28,7 @@ import { OxyServicesAppDataMixin } from './OxyServices.appData';
32
28
  import { OxyServicesCivicMixin } from './OxyServices.civic';
33
29
  import { OxyServicesNodesMixin } from './OxyServices.nodes';
34
30
  import { OxyServicesLinksMixin } from './OxyServices.links';
31
+ import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
35
32
 
36
33
  /**
37
34
  * Instance shape of every mixin in the pipeline, intersected. The runtime
@@ -44,10 +41,6 @@ import { OxyServicesLinksMixin } from './OxyServices.links';
44
41
  */
45
42
  type AllMixinInstances =
46
43
  & InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>>
47
- & InstanceType<ReturnType<typeof OxyServicesFedCMMixin<typeof OxyServicesBase>>>
48
- & InstanceType<ReturnType<typeof OxyServicesSilentAuthMixin<typeof OxyServicesBase>>>
49
- & InstanceType<ReturnType<typeof OxyServicesRedirectAuthMixin<typeof OxyServicesBase>>>
50
- & InstanceType<ReturnType<typeof OxyServicesSsoMixin<typeof OxyServicesBase>>>
51
44
  & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>>
52
45
  & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>>
53
46
  & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>>
@@ -68,6 +61,7 @@ type AllMixinInstances =
68
61
  & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>>
69
62
  & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>>
70
63
  & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>>
64
+ & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>>
71
65
  & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
72
66
 
73
67
  /**
@@ -90,10 +84,9 @@ type MixinFunction = (Base: new (...args: unknown[]) => OxyServicesBase) => new
90
84
  *
91
85
  * Order matters for dependencies:
92
86
  * 1. Base auth mixin first (required by all others)
93
- * 2. Cross-domain auth mixins (FedCM, silent iframe, Redirect)
94
- * 3. User mixin (requires auth)
95
- * 4. Feature mixins (can depend on user)
96
- * 5. Utility mixin last (augments all)
87
+ * 2. User mixin (requires auth)
88
+ * 3. Feature mixins (can depend on user)
89
+ * 4. Utility mixin last (augments all)
97
90
  *
98
91
  * To add a new mixin: insert it at the appropriate position in this array.
99
92
  */
@@ -101,17 +94,6 @@ const MIXIN_PIPELINE: MixinFunction[] = [
101
94
  // Base authentication
102
95
  OxyServicesAuthMixin,
103
96
 
104
- // Cross-domain authentication (web-only)
105
- // - FedCM: Modern browser-native identity federation (Google-style)
106
- // - Silent: iframe-based restore for first-party IdP hosts
107
- // - Redirect: Traditional redirect-based authentication
108
- OxyServicesFedCMMixin,
109
- OxyServicesSilentAuthMixin,
110
- OxyServicesRedirectAuthMixin,
111
-
112
- // Central cross-domain SSO (opaque-code exchange).
113
- OxyServicesSsoMixin,
114
-
115
97
  // User management (requires auth)
116
98
  OxyServicesUserMixin,
117
99
  // Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping)
@@ -147,6 +129,11 @@ const MIXIN_PIPELINE: MixinFunction[] = [
147
129
  // so apps stop scraping link metadata locally.
148
130
  OxyServicesLinksMixin,
149
131
 
132
+ // Device-first session bootstrap: the client half of the new
133
+ // /auth/device/* + /auth/refresh-token surface (exchange, web-session,
134
+ // refresh, native device-token, bootstrap-url builder).
135
+ OxyServicesDeviceBootMixin,
136
+
150
137
  // Utility (last, can use all above)
151
138
  OxyServicesUtilityMixin,
152
139
  ];
@@ -3,20 +3,6 @@ import type { UserNameResponse } from '@oxyhq/contracts';
3
3
  export interface OxyConfig {
4
4
  baseURL: string;
5
5
  cloudURL?: string;
6
- /**
7
- * Base URL the SDK's first-party session/refresh calls target.
8
- *
9
- * Per the 2026 session architecture (docs/SESSION-ARCHITECTURE.md), every app
10
- * keeps its OWN first-party session on its OWN domain. For non-`oxy.so` apps
11
- * this is the app's own same-site backend (e.g. `https://api.mention.earth`),
12
- * whose session bridge forwards the user's refresh credential to
13
- * `api.oxy.so`. For `*.oxy.so` apps this is omitted and falls back to
14
- * `baseURL` (`https://api.oxy.so`), so their behavior is unchanged.
15
- *
16
- * Resolve via {@link OxyServices.getSessionBaseUrl}; when unset it returns
17
- * `baseURL`. This is purely additive — no refresh/auth logic reads it yet.
18
- */
19
- sessionBaseUrl?: string;
20
6
  authWebUrl?: string;
21
7
  authRedirectUri?: string;
22
8
  /**
@@ -120,7 +106,6 @@ export interface User {
120
106
  phone?: string;
121
107
  address?: string;
122
108
  birthday?: string;
123
- location?: string;
124
109
  website?: string;
125
110
  createdAt?: string;
126
111
  updatedAt?: string;
@@ -689,67 +674,3 @@ export interface UpdateDeviceNameResponse {
689
674
  deviceName: string;
690
675
  }
691
676
 
692
- // ---------------------------------------------------------------------------
693
- // Multi-account "refresh-all" (Google-style)
694
- // ---------------------------------------------------------------------------
695
- // Wire shape of `POST /auth/refresh-all`. The server rotates every device-local
696
- // `oxy_rt_${authuser}` cookie in parallel and returns one entry per VALID
697
- // account, sorted by `authuser` ascending. Slot-level errors are silently
698
- // omitted; the response is `{ accounts: [] }` in the worst case (no signed-in
699
- // accounts, all cookies expired, or origin not allowlisted).
700
-
701
- /**
702
- * Minimal user shape included in a `RefreshAllAccount` entry. The server
703
- * projects a small whitelist (`username name avatar email color`) so the
704
- * client can render the account chooser without an extra `/users/me` round
705
- * trip per account.
706
- *
707
- * `avatar` and `color` are `string | null` because they are stored as nullable
708
- * fields in the user document.
709
- */
710
- export interface RefreshAllAccountUser {
711
- id: string;
712
- username: string;
713
- /**
714
- * Structured human name as emitted by `formatUserResponse` (the canonical
715
- * {@link UserNameResponse} `{ first?, last?, full? }` subdocument), NOT a bare
716
- * string. The server projects `name` verbatim from the user document. The
717
- * single source of truth is `@oxyhq/contracts`.
718
- */
719
- name: UserNameResponse;
720
- avatar?: string | null;
721
- email?: string;
722
- color?: string | null;
723
- }
724
-
725
- /**
726
- * One rotated account entry returned by `POST /auth/refresh-all`. `authuser` is
727
- * the device-local slot index (0..N-1) the cookie was bound to.
728
- */
729
- export interface RefreshAllAccount {
730
- authuser: number;
731
- accessToken: string;
732
- expiresAt: string;
733
- sessionId: string;
734
- user: RefreshAllAccountUser | null;
735
- }
736
-
737
- /**
738
- * Wire shape of `POST /auth/refresh-all`. Always 200 with a (possibly empty)
739
- * accounts array — 401 means "no accounts signed in on this device" and is
740
- * normalised to `{ accounts: [] }` at the SDK layer.
741
- */
742
- export interface RefreshAllResponse {
743
- accounts: RefreshAllAccount[];
744
- }
745
-
746
- /**
747
- * Wire shape of `POST /auth/refresh` (single-slot refresh, optionally targeting
748
- * a specific `?authuser=N` slot). The server always includes the numeric slot in
749
- * the response.
750
- */
751
- export interface RefreshCookieResponse {
752
- accessToken: string;
753
- expiresAt: string;
754
- authuser: number;
755
- }
@@ -8,11 +8,10 @@ export interface ClientSession {
8
8
  userId?: string;
9
9
  isCurrent?: boolean;
10
10
  /**
11
- * Web-only: the device-local refresh-cookie slot index (0..N) that backs
12
- * this session. Populated from `POST /auth/refresh-all` and from login /
13
- * signup / fedcm-exchange responses. Required for per-session web token
14
- * refresh via `refreshTokenViaCookie({ authuser })` without a bearer token.
15
- * Absent on native (RN uses the bearer-protected session id directly).
11
+ * The account's ordinal slot (0..N) within the device's account set
12
+ * (`SessionAccount.authuser` in `@oxyhq/contracts`), projected from the
13
+ * device-first `DeviceSessionState` used purely for stable Google-style
14
+ * account-chooser ordering, not for any token-refresh mechanism.
16
15
  */
17
16
  authuser?: number;
18
17
  }
@@ -68,7 +68,7 @@ export { verifySecret } from './verifySecret';
68
68
  // SOURCE OF TRUTH shared with the IdP worker and the client FAPI auto-detect.
69
69
  // Pure host handling (no browser deps), so it is safe on the server subpath and
70
70
  // lets `@oxyhq/api` derive `auth.<apex>` without duplicating PSL logic.
71
- export { registrableApex } from '../utils/fapiAutoDetect';
71
+ export { registrableApex } from '../utils/registrableApex';
72
72
 
73
73
  // The single RP callback path the IdP redirects back to. A pure wire-contract
74
74
  // constant (no browser deps at module top level), re-used server-side so the
@@ -9,7 +9,7 @@ import { getSocketIO } from './socketLoader';
9
9
  import type { MinimalSocket, SocketIOFactory } from './socketLoader';
10
10
 
11
11
  export interface TokenTransport {
12
- /** Ensure this app holds a per-domain access token for state.activeAccountId (mint via FedCM/silent/sso/keychain). Best-effort. */
12
+ /** Ensure this app holds a per-domain access token for state.activeAccountId (mint via the persisted refresh family / shared keychain). Best-effort. */
13
13
  ensureActiveToken(state: DeviceSessionState): Promise<void>;
14
14
  }
15
15
 
@@ -24,6 +24,16 @@ export interface SessionClientHost {
24
24
 
25
25
  export interface SessionClientOptions {
26
26
  transport?: TokenTransport;
27
+ /**
28
+ * Invoked when an APPLIED state has zero accounts — i.e. a device
29
+ * signout-all removed the last account from this device set. Providers use
30
+ * this to clear the persisted {@link AuthStateStore} so a reload does not
31
+ * try to restore a session that no longer exists on the device.
32
+ *
33
+ * Only fires when a state is actually applied (revision advanced), never on
34
+ * a stale/rejected push. Exceptions thrown by the callback are isolated.
35
+ */
36
+ onUnauthenticated?: () => void;
27
37
  /**
28
38
  * Statically-injected `socket.io-client` factory (its `io` export).
29
39
  * `@oxyhq/services` and `@oxyhq/auth` list `socket.io-client` as a real
@@ -80,7 +90,17 @@ export class SessionClient {
80
90
  logger.warn('[SessionClient] discarded invalid session state');
81
91
  return false;
82
92
  }
83
- if (this.state && next.revision <= this.state.revision) {
93
+ // The revision is monotone ONLY within a single deviceId. When the incoming
94
+ // state belongs to a DIFFERENT device than the currently-applied one, reset
95
+ // the baseline and accept it regardless of revision: a freshly-converged
96
+ // device (low revision) must not lose last-writer-wins to a stale, higher-
97
+ // revision state from a retired device. Only when the deviceIds MATCH is the
98
+ // `revision <= current` guard a valid staleness check.
99
+ if (
100
+ this.state &&
101
+ next.deviceId === this.state.deviceId &&
102
+ next.revision <= this.state.revision
103
+ ) {
84
104
  return false;
85
105
  }
86
106
  this.state = next;
@@ -90,6 +110,15 @@ export class SessionClient {
90
110
  logger.warn('[SessionClient] ensureActiveToken failed', { component: 'SessionClient' }, error);
91
111
  });
92
112
  }
113
+ // A device signout-all leaves zero accounts — tell the provider to clear
114
+ // the persisted store so a reload does not try to restore a dead session.
115
+ if (next.accounts.length === 0 && this.options.onUnauthenticated) {
116
+ try {
117
+ this.options.onUnauthenticated();
118
+ } catch (error) {
119
+ logger.error('[SessionClient] onUnauthenticated threw', error);
120
+ }
121
+ }
93
122
  return true;
94
123
  }
95
124
 
@@ -144,6 +173,28 @@ export class SessionClient {
144
173
  this.applySync(res);
145
174
  }
146
175
 
176
+ /**
177
+ * Register the just-signed-in account into the device set AND make it the
178
+ * ACTIVE account — the explicit user-intent activation a sign-in UI performs.
179
+ *
180
+ * `addCurrentAccount` alone honors the server's `activate:'if-empty'` policy
181
+ * (a new account does NOT steal focus from an already-active one), which is
182
+ * correct for a background/silent add but wrong right after a deliberate
183
+ * sign-in. This adds, then switches to the target so the UI lands on the
184
+ * account the user just authenticated.
185
+ *
186
+ * @param accountId - The signed-in account id (e.g. `session.user.id`). When
187
+ * omitted, falls back to the host's current-account ref. If neither
188
+ * resolves, the add still applies and no switch is performed.
189
+ */
190
+ async registerAndActivate(accountId?: string): Promise<void> {
191
+ await this.addCurrentAccount();
192
+ const target = accountId ?? this.host.getCurrentAccountId();
193
+ if (target && this.state?.activeAccountId !== target) {
194
+ await this.switchAccount(target);
195
+ }
196
+ }
197
+
147
198
  async start(): Promise<void> {
148
199
  if (this.started) return;
149
200
  this.started = true;
@@ -0,0 +1,92 @@
1
+ import type { DeviceSessionState } from '@oxyhq/contracts';
2
+ import { SessionClient, type SessionClientHost } from '../SessionClient';
3
+
4
+ const stateWith = (rev: number, active: string | null, accountIds: string[]): DeviceSessionState => ({
5
+ deviceId: 'd1',
6
+ accounts: accountIds.map((id, i) => ({ accountId: id, sessionId: `s-${id}`, authuser: i })),
7
+ activeAccountId: active,
8
+ revision: rev,
9
+ updatedAt: 1720000000000,
10
+ });
11
+
12
+ const sync = (state: DeviceSessionState) => ({ state, activeToken: { accessToken: `jwt-${state.revision}`, expiresAt: 'x' } });
13
+
14
+ function makeHost(makeRequest: jest.Mock, currentAccountId: string | null = null): SessionClientHost {
15
+ return {
16
+ makeRequest,
17
+ getBaseURL: () => 'http://test.invalid',
18
+ getAccessToken: () => 't',
19
+ onTokensChanged: () => () => undefined,
20
+ setTokens: jest.fn(),
21
+ getCurrentAccountId: () => currentAccountId,
22
+ };
23
+ }
24
+
25
+ class TestClient extends SessionClient {
26
+ public apply(raw: unknown): boolean {
27
+ return this.applyState(raw);
28
+ }
29
+ }
30
+
31
+ describe('SessionClient.registerAndActivate', () => {
32
+ it('adds then switches to the explicit target when it is not already active', async () => {
33
+ const makeRequest = jest
34
+ .fn()
35
+ // add → device set has a1 active, a2 present but not active
36
+ .mockResolvedValueOnce(sync(stateWith(1, 'a1', ['a1', 'a2'])))
37
+ // switch → a2 becomes active
38
+ .mockResolvedValueOnce(sync(stateWith(2, 'a2', ['a1', 'a2'])));
39
+ const c = new SessionClient(makeHost(makeRequest));
40
+
41
+ await c.registerAndActivate('a2');
42
+
43
+ expect(makeRequest).toHaveBeenNthCalledWith(1, 'POST', '/session/device/add', undefined, { cache: false });
44
+ expect(makeRequest).toHaveBeenNthCalledWith(2, 'POST', '/session/device/switch', { accountId: 'a2' }, { cache: false });
45
+ expect(c.getState()?.activeAccountId).toBe('a2');
46
+ });
47
+
48
+ it('falls back to the host current-account ref when no target is passed', async () => {
49
+ const makeRequest = jest
50
+ .fn()
51
+ .mockResolvedValueOnce(sync(stateWith(1, 'a1', ['a1', 'a2'])))
52
+ .mockResolvedValueOnce(sync(stateWith(2, 'a2', ['a1', 'a2'])));
53
+ const c = new SessionClient(makeHost(makeRequest, 'a2'));
54
+
55
+ await c.registerAndActivate();
56
+
57
+ expect(makeRequest).toHaveBeenNthCalledWith(2, 'POST', '/session/device/switch', { accountId: 'a2' }, { cache: false });
58
+ });
59
+
60
+ it('does NOT switch when the added account is already active', async () => {
61
+ const makeRequest = jest.fn().mockResolvedValueOnce(sync(stateWith(1, 'a1', ['a1'])));
62
+ const c = new SessionClient(makeHost(makeRequest));
63
+
64
+ await c.registerAndActivate('a1');
65
+
66
+ expect(makeRequest).toHaveBeenCalledTimes(1);
67
+ expect(makeRequest).toHaveBeenCalledWith('POST', '/session/device/add', undefined, { cache: false });
68
+ });
69
+ });
70
+
71
+ describe('SessionClient onUnauthenticated', () => {
72
+ it('fires when an applied state has zero accounts (device signout-all)', () => {
73
+ const onUnauthenticated = jest.fn();
74
+ const c = new TestClient(makeHost(jest.fn()), { onUnauthenticated });
75
+
76
+ c.apply(stateWith(1, 'a1', ['a1']));
77
+ expect(onUnauthenticated).not.toHaveBeenCalled();
78
+
79
+ c.apply(stateWith(2, null, []));
80
+ expect(onUnauthenticated).toHaveBeenCalledTimes(1);
81
+ });
82
+
83
+ it('does not fire for a stale (non-applied) empty state', () => {
84
+ const onUnauthenticated = jest.fn();
85
+ const c = new TestClient(makeHost(jest.fn()), { onUnauthenticated });
86
+
87
+ c.apply(stateWith(5, 'a1', ['a1']));
88
+ // revision 4 <= 5 → rejected, so onUnauthenticated must NOT fire.
89
+ c.apply(stateWith(4, null, []));
90
+ expect(onUnauthenticated).not.toHaveBeenCalled();
91
+ });
92
+ });
@@ -77,4 +77,29 @@ describe('SessionClient REST', () => {
77
77
  expect(c.getState()?.revision).toBe(7);
78
78
  expect(host.setTokens).not.toHaveBeenCalled();
79
79
  });
80
+
81
+ it('accepts a NEW device\'s lower-revision state and plants its token (cross-device revision reset)', async () => {
82
+ const deviceA: DeviceSessionState = {
83
+ deviceId: 'A', accounts: [{ accountId: 'a1', sessionId: 's1', authuser: 0 }], activeAccountId: 'a1', revision: 10, updatedAt: 1720000000000,
84
+ };
85
+ const deviceB: DeviceSessionState = {
86
+ deviceId: 'B', accounts: [{ accountId: 'a2', sessionId: 's2', authuser: 0 }], activeAccountId: 'a2', revision: 1, updatedAt: 1720000001000,
87
+ };
88
+ const makeRequest = jest.fn()
89
+ .mockResolvedValueOnce({ state: deviceA, activeToken: { accessToken: 'jwt-A', expiresAt: 'x' } })
90
+ .mockResolvedValueOnce({ state: deviceB, activeToken: { accessToken: 'jwt-B', expiresAt: 'x' } });
91
+ const host = makeHost(makeRequest);
92
+ const c = new SessionClient(host);
93
+
94
+ await c.bootstrap();
95
+ expect(c.getState()?.deviceId).toBe('A');
96
+ expect(host.setTokens).toHaveBeenLastCalledWith('jwt-A');
97
+
98
+ // Device B is freshly converged (revision 1) — it must win over device A's
99
+ // stale-but-higher revision, and its token must be planted.
100
+ await c.bootstrap();
101
+ expect(c.getState()?.deviceId).toBe('B');
102
+ expect(c.getState()?.revision).toBe(1);
103
+ expect(host.setTokens).toHaveBeenLastCalledWith('jwt-B');
104
+ });
80
105
  });
@@ -32,12 +32,25 @@ describe('SessionClient state', () => {
32
32
  expect(seen.at(-1)?.revision).toBe(1);
33
33
  });
34
34
 
35
- it('ignores a stale or equal revision (last-writer-wins)', () => {
35
+ it('ignores a stale or equal revision (last-writer-wins) WITHIN the same device', () => {
36
36
  const c = new TestClient(makeHost());
37
- c.apply(STATE(5));
38
- expect(c.apply(STATE(5))).toBe(false);
39
- expect(c.apply(STATE(4))).toBe(false);
40
- expect(c.getState()?.revision).toBe(5);
37
+ c.apply({ ...STATE(5), deviceId: 'A' });
38
+ expect(c.apply({ ...STATE(5), deviceId: 'A' })).toBe(false);
39
+ expect(c.apply({ ...STATE(4), deviceId: 'A' })).toBe(false);
40
+ expect(c.apply({ ...STATE(6), deviceId: 'A' })).toBe(true);
41
+ expect(c.getState()?.revision).toBe(6);
42
+ });
43
+
44
+ it('accepts a LOWER-revision state from a DIFFERENT device (revision baseline resets cross-device)', () => {
45
+ const c = new TestClient(makeHost());
46
+ // Device A at a high revision.
47
+ expect(c.apply({ ...STATE(10), deviceId: 'A' })).toBe(true);
48
+ expect(c.getState()?.deviceId).toBe('A');
49
+ // Device B, freshly converged (revision 1), must win over device A's
50
+ // stale-but-higher revision — the cross-device comparison is not monotone.
51
+ expect(c.apply({ ...STATE(1), deviceId: 'B' })).toBe(true);
52
+ expect(c.getState()?.deviceId).toBe('B');
53
+ expect(c.getState()?.revision).toBe(1);
41
54
  });
42
55
 
43
56
  it('rejects an invalid (unvalidated) state without applying', () => {