@oxyhq/core 5.4.3 → 6.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 (186) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +6 -3
  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 +47 -43
  12. package/dist/cjs/mixins/OxyServices.accounts.js +6 -13
  13. package/dist/cjs/mixins/OxyServices.auth.js +66 -201
  14. package/dist/cjs/mixins/OxyServices.authorizedApps.js +38 -0
  15. package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
  16. package/dist/cjs/mixins/index.js +13 -17
  17. package/dist/cjs/session/SessionClient.js +44 -2
  18. package/dist/cjs/session/authStateStore.js +284 -0
  19. package/dist/cjs/session/createSessionClient.js +8 -2
  20. package/dist/cjs/session/refresh.js +264 -0
  21. package/dist/cjs/utils/accountUtils.js +1 -55
  22. package/dist/cjs/utils/authWebUrl.js +6 -15
  23. package/dist/cjs/utils/fapiAutoDetect.js +16 -64
  24. package/dist/cjs/utils/platform.js +19 -0
  25. package/dist/cjs/utils/ssoBounce.js +15 -340
  26. package/dist/cjs/utils/validationUtils.js +57 -0
  27. package/dist/esm/.tsbuildinfo +1 -1
  28. package/dist/esm/HttpService.js +6 -3
  29. package/dist/esm/OxyServices.base.js +7 -102
  30. package/dist/esm/boot/coldBootV2.js +344 -0
  31. package/dist/esm/boot/deviceBootReturn.js +146 -0
  32. package/dist/esm/crypto/keyManager.js +95 -0
  33. package/dist/esm/i18n/locales/en-US.json +13 -1
  34. package/dist/esm/i18n/locales/es-ES.json +13 -1
  35. package/dist/esm/i18n/locales/locales/en-US.json +13 -1
  36. package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
  37. package/dist/esm/index.js +28 -18
  38. package/dist/esm/mixins/OxyServices.accounts.js +6 -13
  39. package/dist/esm/mixins/OxyServices.auth.js +66 -201
  40. package/dist/esm/mixins/OxyServices.authorizedApps.js +35 -0
  41. package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
  42. package/dist/esm/mixins/index.js +13 -17
  43. package/dist/esm/session/SessionClient.js +44 -2
  44. package/dist/esm/session/authStateStore.js +278 -0
  45. package/dist/esm/session/createSessionClient.js +8 -2
  46. package/dist/esm/session/refresh.js +257 -0
  47. package/dist/esm/utils/accountUtils.js +0 -53
  48. package/dist/esm/utils/authWebUrl.js +6 -14
  49. package/dist/esm/utils/fapiAutoDetect.js +16 -63
  50. package/dist/esm/utils/platform.js +18 -0
  51. package/dist/esm/utils/ssoBounce.js +14 -324
  52. package/dist/esm/utils/validationUtils.js +56 -0
  53. package/dist/types/.tsbuildinfo +1 -1
  54. package/dist/types/HttpService.d.ts +13 -0
  55. package/dist/types/OxyServices.base.d.ts +0 -52
  56. package/dist/types/OxyServices.d.ts +0 -25
  57. package/dist/types/boot/coldBootV2.d.ts +76 -0
  58. package/dist/types/boot/deviceBootReturn.d.ts +83 -0
  59. package/dist/types/crypto/keyManager.d.ts +21 -0
  60. package/dist/types/index.d.ts +16 -21
  61. package/dist/types/mixins/OxyServices.accounts.d.ts +0 -2
  62. package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
  63. package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
  64. package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
  65. package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
  66. package/dist/types/mixins/{OxyServices.redirect.d.ts → OxyServices.authorizedApps.d.ts} +35 -33
  67. package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
  68. package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
  69. package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
  70. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
  71. package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
  72. package/dist/types/mixins/OxyServices.features.d.ts +0 -2
  73. package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
  74. package/dist/types/mixins/OxyServices.language.d.ts +0 -2
  75. package/dist/types/mixins/OxyServices.links.d.ts +0 -2
  76. package/dist/types/mixins/OxyServices.location.d.ts +0 -2
  77. package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
  78. package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
  79. package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
  80. package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
  81. package/dist/types/mixins/OxyServices.security.d.ts +0 -2
  82. package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
  83. package/dist/types/mixins/OxyServices.user.d.ts +0 -2
  84. package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
  85. package/dist/types/mixins/index.d.ts +6 -9
  86. package/dist/types/models/interfaces.d.ts +0 -67
  87. package/dist/types/session/SessionClient.d.ts +38 -1
  88. package/dist/types/session/authStateStore.d.ts +119 -0
  89. package/dist/types/session/createSessionClient.d.ts +8 -1
  90. package/dist/types/session/refresh.d.ts +93 -0
  91. package/dist/types/utils/accountUtils.d.ts +0 -14
  92. package/dist/types/utils/authWebUrl.d.ts +6 -12
  93. package/dist/types/utils/fapiAutoDetect.d.ts +15 -38
  94. package/dist/types/utils/platform.d.ts +14 -0
  95. package/dist/types/utils/ssoBounce.d.ts +14 -262
  96. package/dist/types/utils/validationUtils.d.ts +15 -0
  97. package/package.json +2 -2
  98. package/src/HttpService.ts +19 -3
  99. package/src/OxyServices.base.ts +7 -112
  100. package/src/OxyServices.ts +0 -38
  101. package/src/boot/__tests__/coldBootV2.test.ts +317 -0
  102. package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
  103. package/src/boot/coldBootV2.ts +426 -0
  104. package/src/boot/deviceBootReturn.ts +195 -0
  105. package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
  106. package/src/crypto/keyManager.ts +101 -0
  107. package/src/i18n/locales/en-US.json +13 -1
  108. package/src/i18n/locales/es-ES.json +13 -1
  109. package/src/index.ts +78 -64
  110. package/src/mixins/OxyServices.accounts.ts +6 -13
  111. package/src/mixins/OxyServices.auth.ts +78 -253
  112. package/src/mixins/OxyServices.authorizedApps.ts +75 -0
  113. package/src/mixins/OxyServices.deviceBoot.ts +146 -0
  114. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
  115. package/src/mixins/__tests__/accounts.test.ts +17 -44
  116. package/src/mixins/__tests__/authorizedApps.test.ts +63 -0
  117. package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
  118. package/src/mixins/index.ts +16 -22
  119. package/src/models/interfaces.ts +0 -79
  120. package/src/session/SessionClient.ts +69 -3
  121. package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
  122. package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
  123. package/src/session/__tests__/SessionClient.socketFactory.test.ts +79 -0
  124. package/src/session/__tests__/SessionClient.state.test.ts +18 -5
  125. package/src/session/__tests__/authStateStore.test.ts +209 -0
  126. package/src/session/__tests__/refresh.test.ts +256 -0
  127. package/src/session/authStateStore.ts +335 -0
  128. package/src/session/createSessionClient.ts +9 -1
  129. package/src/session/refresh.ts +334 -0
  130. package/src/utils/__tests__/authWebUrl.test.ts +5 -29
  131. package/src/utils/__tests__/fapiAutoDetect.test.ts +5 -126
  132. package/src/utils/__tests__/validationUtils.test.ts +30 -0
  133. package/src/utils/accountUtils.ts +0 -62
  134. package/src/utils/authWebUrl.ts +6 -15
  135. package/src/utils/fapiAutoDetect.ts +16 -60
  136. package/src/utils/platform.ts +21 -0
  137. package/src/utils/ssoBounce.ts +14 -371
  138. package/src/utils/validationUtils.ts +62 -0
  139. package/dist/cjs/AuthManager.js +0 -1110
  140. package/dist/cjs/AuthManagerTypes.js +0 -13
  141. package/dist/cjs/CrossDomainAuth.js +0 -206
  142. package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
  143. package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
  144. package/dist/cjs/mixins/OxyServices.silent.js +0 -204
  145. package/dist/cjs/mixins/OxyServices.sso.js +0 -208
  146. package/dist/cjs/utils/ssoEstablish.js +0 -110
  147. package/dist/cjs/utils/ssoReturn.js +0 -267
  148. package/dist/esm/AuthManager.js +0 -1105
  149. package/dist/esm/AuthManagerTypes.js +0 -12
  150. package/dist/esm/CrossDomainAuth.js +0 -201
  151. package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
  152. package/dist/esm/mixins/OxyServices.redirect.js +0 -92
  153. package/dist/esm/mixins/OxyServices.silent.js +0 -202
  154. package/dist/esm/mixins/OxyServices.sso.js +0 -204
  155. package/dist/esm/utils/ssoEstablish.js +0 -107
  156. package/dist/esm/utils/ssoReturn.js +0 -263
  157. package/dist/types/AuthManager.d.ts +0 -380
  158. package/dist/types/AuthManagerTypes.d.ts +0 -81
  159. package/dist/types/CrossDomainAuth.d.ts +0 -164
  160. package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
  161. package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
  162. package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
  163. package/dist/types/utils/ssoEstablish.d.ts +0 -85
  164. package/dist/types/utils/ssoReturn.d.ts +0 -146
  165. package/src/AuthManager.ts +0 -1269
  166. package/src/AuthManagerTypes.ts +0 -86
  167. package/src/CrossDomainAuth.ts +0 -243
  168. package/src/__tests__/authManager.cookiePath.test.ts +0 -390
  169. package/src/__tests__/authManager.security.test.ts +0 -377
  170. package/src/__tests__/crossDomainAuth.test.ts +0 -116
  171. package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
  172. package/src/mixins/OxyServices.fedcm.ts +0 -1026
  173. package/src/mixins/OxyServices.redirect.ts +0 -122
  174. package/src/mixins/OxyServices.silent.ts +0 -272
  175. package/src/mixins/OxyServices.sso.ts +0 -261
  176. package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
  177. package/src/mixins/__tests__/fedcm.test.ts +0 -667
  178. package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
  179. package/src/mixins/__tests__/silent.test.ts +0 -102
  180. package/src/mixins/__tests__/sso.test.ts +0 -228
  181. package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
  182. package/src/utils/__tests__/ssoBounce.test.ts +0 -219
  183. package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
  184. package/src/utils/__tests__/ssoReturn.test.ts +0 -251
  185. package/src/utils/ssoEstablish.ts +0 -174
  186. package/src/utils/ssoReturn.ts +0 -372
@@ -0,0 +1,317 @@
1
+ import type { OxyServices } from '../../OxyServices';
2
+ import type { AuthTokenBundle, TokenRefreshResponse, WebSessionResult } from '@oxyhq/contracts';
3
+ import type { SessionLoginResponse } from '../../models/session';
4
+ import {
5
+ runSessionColdBoot,
6
+ createBrowserColdBootDom,
7
+ isSameApex,
8
+ BOOT_ATTEMPTED_KEY,
9
+ type ColdBootDom,
10
+ } from '../coldBootV2';
11
+ import { BOOT_STATE_SESSION_KEY } from '../deviceBootReturn';
12
+ import { createMemoryAuthStateStore, type AuthStateStore } from '../../session/authStateStore';
13
+ import { KeyManager } from '../../crypto/keyManager';
14
+
15
+ const BUNDLE: AuthTokenBundle = {
16
+ sessionId: 'sess-boot',
17
+ accessToken: 'access-boot',
18
+ refreshToken: 'refresh-boot-abcdefghij',
19
+ expiresAt: '2030-01-01T00:00:00.000Z',
20
+ user: { id: 'user-boot', name: {} } as AuthTokenBundle['user'],
21
+ };
22
+
23
+ const ROTATED: TokenRefreshResponse = {
24
+ accessToken: 'access-rot',
25
+ refreshToken: 'refresh-rot-abcdefghij',
26
+ expiresAt: '2030-01-01T00:00:00.000Z',
27
+ sessionId: 'sess-rot',
28
+ };
29
+
30
+ interface OxyOverrides {
31
+ baseURL?: string;
32
+ exchangeBootCode?: OxyServices['exchangeBootCode'];
33
+ refreshWithToken?: OxyServices['refreshWithToken'];
34
+ signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
35
+ issueNativeDeviceToken?: OxyServices['issueNativeDeviceToken'];
36
+ requestWebSession?: OxyServices['requestWebSession'];
37
+ }
38
+
39
+ function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: jest.Mock } {
40
+ const setTokens = jest.fn();
41
+ const oxy = {
42
+ getBaseURL: () => overrides.baseURL ?? 'https://api.oxy.so',
43
+ setTokens,
44
+ exchangeBootCode: overrides.exchangeBootCode ?? (async () => BUNDLE),
45
+ refreshWithToken: overrides.refreshWithToken ?? (async () => ROTATED),
46
+ signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
47
+ issueNativeDeviceToken: overrides.issueNativeDeviceToken ?? (async () => 'native-device-token'),
48
+ requestWebSession:
49
+ overrides.requestWebSession
50
+ ?? (async () => ({ reason: 'no_session', deviceToken: 'dt-web' }) as WebSessionResult),
51
+ buildBootstrapUrl: (returnTo: string, state: string) =>
52
+ `https://api.oxy.so/auth/device/bootstrap?return_to=${encodeURIComponent(returnTo)}&state=${state}`,
53
+ } as unknown as OxyServices;
54
+ return { oxy, setTokens };
55
+ }
56
+
57
+ interface DomHandle {
58
+ dom: ColdBootDom;
59
+ navigate: jest.Mock;
60
+ strip: jest.Mock;
61
+ session: Map<string, string>;
62
+ local: Map<string, string>;
63
+ }
64
+
65
+ function makeDom(opts: { hash?: string; hostname?: string; sessionState?: string; attempted?: boolean } = {}): DomHandle {
66
+ const session = new Map<string, string>();
67
+ const local = new Map<string, string>();
68
+ if (opts.sessionState !== undefined) session.set(BOOT_STATE_SESSION_KEY, opts.sessionState);
69
+ if (opts.attempted) local.set(BOOT_ATTEMPTED_KEY, '1');
70
+ const navigate = jest.fn();
71
+ const strip = jest.fn();
72
+ const dom: ColdBootDom = {
73
+ getHash: () => opts.hash ?? '',
74
+ stripFragment: strip,
75
+ getSessionItem: (k) => session.get(k) ?? null,
76
+ setSessionItem: (k, v) => {
77
+ session.set(k, v);
78
+ },
79
+ removeSessionItem: (k) => {
80
+ session.delete(k);
81
+ },
82
+ getLocalItem: (k) => local.get(k) ?? null,
83
+ setLocalItem: (k, v) => {
84
+ local.set(k, v);
85
+ },
86
+ getLocationHostname: () => opts.hostname ?? 'accounts.oxy.so',
87
+ getReturnToHref: () => 'https://accounts.oxy.so/home',
88
+ navigate,
89
+ randomState: () => 'state-fixed-1234',
90
+ };
91
+ return { dom, navigate, strip, session, local };
92
+ }
93
+
94
+ const WEB = { isWeb: true, isNative: false };
95
+ const NATIVE = { isWeb: false, isNative: true };
96
+
97
+ describe('isSameApex', () => {
98
+ it('groups by registrable domain', () => {
99
+ expect(isSameApex('accounts.oxy.so', 'api.oxy.so')).toBe(true);
100
+ expect(isSameApex('oxy.so', 'api.oxy.so')).toBe(true);
101
+ expect(isSameApex('app.mention.earth', 'api.oxy.so')).toBe(false);
102
+ expect(isSameApex('homiio.com', 'api.oxy.so')).toBe(false);
103
+ });
104
+
105
+ it('does NOT collapse distinct IPv4 hosts that share trailing octets', () => {
106
+ // The last-2-labels heuristic would map both to "1.1" — must be exact.
107
+ expect(isSameApex('192.168.1.1', '10.0.1.1')).toBe(false);
108
+ expect(isSameApex('192.168.1.10', '192.168.1.5')).toBe(false);
109
+ expect(isSameApex('192.168.1.5', '192.168.1.5')).toBe(true);
110
+ });
111
+
112
+ it('requires exact equality for single-label / IPv6 / localhost hosts', () => {
113
+ expect(isSameApex('localhost', 'localhost')).toBe(true);
114
+ expect(isSameApex('localhost', 'api.oxy.so')).toBe(false);
115
+ expect(isSameApex('::1', '::1')).toBe(true);
116
+ expect(isSameApex('fe80::1', 'fe80::2')).toBe(false);
117
+ });
118
+ });
119
+
120
+ describe('runSessionColdBoot — step ordering', () => {
121
+ it('stored-tokens warm-plants a still-valid access token before any hop', async () => {
122
+ const store = createMemoryAuthStateStore();
123
+ await store.save({
124
+ sessionId: 'sess-warm',
125
+ refreshToken: 'r-abcdefghij',
126
+ userId: 'user-warm',
127
+ accessToken: 'warm-token',
128
+ expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
129
+ });
130
+ const { oxy, setTokens } = makeOxy();
131
+ const requestWebSession = jest.spyOn(oxy, 'requestWebSession');
132
+ const onSession = jest.fn();
133
+
134
+ const outcome = await runSessionColdBoot({
135
+ oxy, store, platform: WEB, dom: makeDom().dom, onSession,
136
+ });
137
+
138
+ expect(outcome).toEqual({ kind: 'session', via: 'stored-tokens', session: expect.any(Object) });
139
+ expect(setTokens).toHaveBeenCalledWith('warm-token');
140
+ expect(onSession).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-warm', via: 'stored-tokens' }));
141
+ expect(requestWebSession).not.toHaveBeenCalled();
142
+ });
143
+
144
+ it('stored-tokens rotates when the warm token is absent/expired', async () => {
145
+ const store = createMemoryAuthStateStore();
146
+ await store.save({ sessionId: 'sess-old', refreshToken: 'r-abcdefghij', userId: 'user-1' });
147
+ const { oxy, setTokens } = makeOxy();
148
+
149
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: makeDom().dom });
150
+
151
+ expect(outcome.kind).toBe('session');
152
+ expect(setTokens).toHaveBeenCalledWith('access-rot');
153
+ expect(await store.load()).toMatchObject({ sessionId: 'sess-rot', refreshToken: 'refresh-rot-abcdefghij' });
154
+ });
155
+
156
+ it('bootstrap-return wins over later steps when a valid fragment is present', async () => {
157
+ const store = createMemoryAuthStateStore();
158
+ // A base64url fragment carrying reason:session + matching state.
159
+ const frag = {
160
+ v: 1,
161
+ state: 'state-match',
162
+ reason: 'session',
163
+ code: 'c'.repeat(24),
164
+ deviceToken: 'd'.repeat(24),
165
+ };
166
+ const b64 = Buffer.from(JSON.stringify(frag), 'utf-8')
167
+ .toString('base64')
168
+ .replace(/\+/g, '-')
169
+ .replace(/\//g, '_')
170
+ .replace(/=+$/, '');
171
+ const domHandle = makeDom({ hash: `#oxy_boot=${b64}`, sessionState: 'state-match' });
172
+ const { oxy, setTokens } = makeOxy();
173
+ const onSession = jest.fn();
174
+
175
+ const outcome = await runSessionColdBoot({
176
+ oxy, store, platform: WEB, dom: domHandle.dom, onSession,
177
+ });
178
+
179
+ expect(outcome).toEqual({ kind: 'session', via: 'bootstrap-return', session: expect.any(Object) });
180
+ expect(domHandle.strip).toHaveBeenCalled();
181
+ expect(setTokens).toHaveBeenCalledWith('access-boot');
182
+ expect(onSession).toHaveBeenCalledWith(expect.objectContaining({ via: 'bootstrap-return' }));
183
+ });
184
+ });
185
+
186
+ describe('runSessionColdBoot — bootstrap-hop (web)', () => {
187
+ it('same-apex: resolves a session via the inline web-session fetch (no navigation)', async () => {
188
+ const store = createMemoryAuthStateStore();
189
+ // The EXACT post-unwrap server shape (PR #526): a reason-`session` envelope
190
+ // nesting the bundle under `session` plus the rotated deviceToken.
191
+ const sessionEnvelope: WebSessionResult = { reason: 'session', session: BUNDLE, deviceToken: 'dt-rotated' };
192
+ const requestWebSession = jest.fn(async () => sessionEnvelope);
193
+ const { oxy, setTokens } = makeOxy({ requestWebSession });
194
+ const domHandle = makeDom({ hostname: 'accounts.oxy.so' });
195
+
196
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom });
197
+
198
+ expect(outcome).toEqual({ kind: 'session', via: 'bootstrap-hop', session: expect.any(Object) });
199
+ expect(requestWebSession).toHaveBeenCalledTimes(1);
200
+ expect(setTokens).toHaveBeenCalledWith('access-boot');
201
+ expect(domHandle.navigate).not.toHaveBeenCalled();
202
+ expect(await store.load()).toMatchObject({ sessionId: 'sess-boot', refreshToken: 'refresh-boot-abcdefghij' });
203
+ // deviceToken from the SAME envelope is persisted on the session arm too.
204
+ expect(await store.loadDeviceToken()).toBe('dt-rotated');
205
+ });
206
+
207
+ it('same-apex: signed-out device persists the deviceToken and reports signed out', async () => {
208
+ const store = createMemoryAuthStateStore();
209
+ const requestWebSession = jest.fn(async () => ({ reason: 'no_session', deviceToken: 'dt-web' }) as WebSessionResult);
210
+ const { oxy } = makeOxy({ requestWebSession });
211
+ const domHandle = makeDom({ hostname: 'accounts.oxy.so' });
212
+ const onSignedOut = jest.fn();
213
+
214
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom, onSignedOut });
215
+
216
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
217
+ expect(onSignedOut).toHaveBeenCalledWith('no_session');
218
+ expect(await store.loadDeviceToken()).toBe('dt-web');
219
+ expect(domHandle.navigate).not.toHaveBeenCalled();
220
+ });
221
+
222
+ it('cross-apex: navigates ONCE, stashing state + the attempted flag', async () => {
223
+ const store = createMemoryAuthStateStore();
224
+ const { oxy } = makeOxy();
225
+ const domHandle = makeDom({ hostname: 'app.mention.earth' });
226
+ const onSignedOut = jest.fn();
227
+ const onSession = jest.fn();
228
+
229
+ await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom, onSignedOut, onSession });
230
+
231
+ expect(domHandle.navigate).toHaveBeenCalledTimes(1);
232
+ expect(domHandle.navigate).toHaveBeenCalledWith(expect.stringContaining('/auth/device/bootstrap?return_to='));
233
+ expect(domHandle.session.get(BOOT_STATE_SESSION_KEY)).toBe('state-fixed-1234');
234
+ expect(domHandle.local.get(BOOT_ATTEMPTED_KEY)).toBe('1');
235
+ // Navigating away — neither callback fires (no signed-out flash).
236
+ expect(onSignedOut).not.toHaveBeenCalled();
237
+ expect(onSession).not.toHaveBeenCalled();
238
+ });
239
+
240
+ it('cross-apex: NEVER navigates a second time once the attempted flag is set', async () => {
241
+ const store = createMemoryAuthStateStore();
242
+ const { oxy } = makeOxy();
243
+ const domHandle = makeDom({ hostname: 'app.mention.earth', attempted: true });
244
+ const onSignedOut = jest.fn();
245
+
246
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom, onSignedOut });
247
+
248
+ expect(domHandle.navigate).not.toHaveBeenCalled();
249
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
250
+ expect(onSignedOut).toHaveBeenCalledWith('no_session');
251
+ });
252
+ });
253
+
254
+ describe('runSessionColdBoot — native shared-key', () => {
255
+ afterEach(() => jest.restoreAllMocks());
256
+
257
+ it('re-mints via shared identity and issues + mirrors a deviceToken the first time', async () => {
258
+ const store = createMemoryAuthStateStore();
259
+ const sharedSession: SessionLoginResponse = {
260
+ sessionId: 'sess-shared',
261
+ deviceId: 'dev-1',
262
+ expiresAt: '2030-01-01T00:00:00.000Z',
263
+ user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
264
+ accessToken: 'access-shared',
265
+ };
266
+ const issueNativeDeviceToken = jest.fn(async () => 'fresh-device-token');
267
+ const { oxy } = makeOxy({
268
+ signInWithSharedIdentity: async () => sharedSession,
269
+ issueNativeDeviceToken,
270
+ });
271
+ const getShared = jest.spyOn(KeyManager, 'getSharedDeviceToken').mockResolvedValue(null);
272
+ const setShared = jest.spyOn(KeyManager, 'setSharedDeviceToken').mockResolvedValue(undefined);
273
+ const requestWebSession = jest.spyOn(oxy, 'requestWebSession');
274
+
275
+ const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE, dom: makeDom().dom });
276
+
277
+ expect(outcome).toEqual({ kind: 'session', via: 'shared-key-signin', session: expect.any(Object) });
278
+ expect(getShared).toHaveBeenCalled();
279
+ expect(issueNativeDeviceToken).toHaveBeenCalled();
280
+ expect(setShared).toHaveBeenCalledWith('fresh-device-token');
281
+ expect(await store.loadDeviceToken()).toBe('fresh-device-token');
282
+ // bootstrap-hop is web-only — never runs on native.
283
+ expect(requestWebSession).not.toHaveBeenCalled();
284
+ });
285
+
286
+ it('skips device-token issuance when the shared token already exists', async () => {
287
+ const store = createMemoryAuthStateStore();
288
+ const issueNativeDeviceToken = jest.fn(async () => 'unused');
289
+ const { oxy } = makeOxy({
290
+ signInWithSharedIdentity: async () => ({
291
+ sessionId: 'sess-shared',
292
+ deviceId: 'dev-1',
293
+ expiresAt: '2030-01-01T00:00:00.000Z',
294
+ user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
295
+ accessToken: 'access-shared',
296
+ }),
297
+ issueNativeDeviceToken,
298
+ });
299
+ jest.spyOn(KeyManager, 'getSharedDeviceToken').mockResolvedValue('already-there');
300
+
301
+ await runSessionColdBoot({ oxy, store, platform: NATIVE, dom: makeDom().dom });
302
+
303
+ expect(issueNativeDeviceToken).not.toHaveBeenCalled();
304
+ });
305
+ });
306
+
307
+ describe('createBrowserColdBootDom', () => {
308
+ it('returns neutral values under the jest node environment (no window)', () => {
309
+ const dom = createBrowserColdBootDom();
310
+ expect(dom.getHash()).toBe('');
311
+ expect(dom.getLocationHostname()).toBeNull();
312
+ expect(dom.getSessionItem('x')).toBeNull();
313
+ expect(() => dom.stripFragment()).not.toThrow();
314
+ expect(() => dom.navigate('https://x')).not.toThrow();
315
+ expect(dom.randomState()).toMatch(/^[0-9a-f]+$/);
316
+ });
317
+ });
@@ -0,0 +1,158 @@
1
+ import type { AuthTokenBundle } from '@oxyhq/contracts';
2
+ import {
3
+ parseDeviceBootFragment,
4
+ hashHasBootFragment,
5
+ consumeDeviceBootReturn,
6
+ type ConsumeDeviceBootReturnDeps,
7
+ } from '../deviceBootReturn';
8
+ import { createMemoryAuthStateStore } from '../../session/authStateStore';
9
+
10
+ const STATE = 'st-1234567890';
11
+ const CODE = 'c'.repeat(24);
12
+ const DEVICE_TOKEN = 'd'.repeat(24);
13
+
14
+ // A loose record (fed to `encodeHash(unknown)` and validated by the schema) so
15
+ // tests can build the session arm plus invalid variants (missing/extra `code`,
16
+ // `v: 2`) without fighting the discriminated-union type.
17
+ function fragmentObject(overrides: Record<string, unknown> = {}): Record<string, unknown> {
18
+ return { v: 1, state: STATE, reason: 'session', code: CODE, deviceToken: DEVICE_TOKEN, ...overrides };
19
+ }
20
+
21
+ function encodeHash(obj: unknown): string {
22
+ const b64 = Buffer.from(JSON.stringify(obj), 'utf-8')
23
+ .toString('base64')
24
+ .replace(/\+/g, '-')
25
+ .replace(/\//g, '_')
26
+ .replace(/=+$/, '');
27
+ return `#oxy_boot=${b64}`;
28
+ }
29
+
30
+ const BUNDLE: AuthTokenBundle = {
31
+ sessionId: 'sess-1',
32
+ accessToken: 'access-jwt',
33
+ refreshToken: 'refresh-abcdefghijklmnop',
34
+ expiresAt: '2030-01-01T00:00:00.000Z',
35
+ user: { id: 'user-1', name: {} } as AuthTokenBundle['user'],
36
+ };
37
+
38
+ /** Build the injectable deps around a memory store, recording call order. */
39
+ function makeDeps(
40
+ hash: string,
41
+ overrides: Partial<ConsumeDeviceBootReturnDeps> & { expectedState?: string | null } = {},
42
+ ): { deps: ConsumeDeviceBootReturnDeps; calls: string[]; store: ReturnType<typeof createMemoryAuthStateStore> } {
43
+ const calls: string[] = [];
44
+ const store = createMemoryAuthStateStore();
45
+ const deps: ConsumeDeviceBootReturnDeps = {
46
+ hash,
47
+ stripFragment: () => calls.push('strip'),
48
+ readExpectedState: () => (overrides.expectedState !== undefined ? overrides.expectedState : STATE),
49
+ clearExpectedState: () => calls.push('clearState'),
50
+ store,
51
+ exchangeBootCode: overrides.exchangeBootCode
52
+ ?? (async () => {
53
+ calls.push('exchange');
54
+ return BUNDLE;
55
+ }),
56
+ plantAccessToken: overrides.plantAccessToken ?? ((token) => calls.push(`plant:${token}`)),
57
+ };
58
+ return { deps, calls, store };
59
+ }
60
+
61
+ describe('parseDeviceBootFragment', () => {
62
+ it('parses a valid base64url fragment', () => {
63
+ expect(parseDeviceBootFragment(encodeHash(fragmentObject()))).toEqual(fragmentObject());
64
+ });
65
+
66
+ it('returns null when the parameter is absent', () => {
67
+ expect(parseDeviceBootFragment('#something=else')).toBeNull();
68
+ expect(parseDeviceBootFragment('')).toBeNull();
69
+ });
70
+
71
+ it('returns null for malformed base64 / non-JSON / wrong shape', () => {
72
+ expect(parseDeviceBootFragment('#oxy_boot=!!!not-base64!!!')).toBeNull();
73
+ expect(parseDeviceBootFragment(encodeHash({ nope: true }))).toBeNull();
74
+ // v must be the literal 1
75
+ expect(parseDeviceBootFragment(encodeHash(fragmentObject({ v: 2 })))).toBeNull();
76
+ });
77
+
78
+ it('returns null for a session fragment missing its code (discriminated union)', () => {
79
+ expect(parseDeviceBootFragment(encodeHash(fragmentObject({ code: undefined })))).toBeNull();
80
+ });
81
+ });
82
+
83
+ describe('hashHasBootFragment', () => {
84
+ it('detects the fragment regardless of position', () => {
85
+ expect(hashHasBootFragment('#oxy_boot=abc')).toBe(true);
86
+ expect(hashHasBootFragment('#x=1&oxy_boot=abc')).toBe(true);
87
+ expect(hashHasBootFragment('#other=1')).toBe(false);
88
+ expect(hashHasBootFragment('')).toBe(false);
89
+ });
90
+ });
91
+
92
+ describe('consumeDeviceBootReturn', () => {
93
+ it('returns none and does not touch the URL when no fragment is present', async () => {
94
+ const { deps, calls } = makeDeps('#unrelated=1');
95
+ expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'none' });
96
+ expect(calls).not.toContain('strip');
97
+ });
98
+
99
+ it('strips the fragment BEFORE exchanging the code', async () => {
100
+ const { deps, calls } = makeDeps(encodeHash(fragmentObject()));
101
+ const outcome = await consumeDeviceBootReturn(deps);
102
+ expect(outcome.kind).toBe('session');
103
+ expect(calls.indexOf('strip')).toBeLessThan(calls.indexOf('exchange'));
104
+ });
105
+
106
+ it('resolves a session, persists it, and plants the token', async () => {
107
+ const { deps, store } = makeDeps(encodeHash(fragmentObject()));
108
+ const outcome = await consumeDeviceBootReturn(deps);
109
+ expect(outcome).toEqual({
110
+ kind: 'session',
111
+ session: { sessionId: 'sess-1', userId: 'user-1', accessToken: 'access-jwt' },
112
+ });
113
+ expect(await store.load()).toMatchObject({
114
+ sessionId: 'sess-1',
115
+ refreshToken: 'refresh-abcdefghijklmnop',
116
+ userId: 'user-1',
117
+ deviceToken: DEVICE_TOKEN,
118
+ });
119
+ expect(await store.loadDeviceToken()).toBe(DEVICE_TOKEN);
120
+ });
121
+
122
+ it('rejects a state mismatch without persisting or exchanging', async () => {
123
+ const { deps, calls, store } = makeDeps(encodeHash(fragmentObject()), { expectedState: 'WRONG' });
124
+ expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'state-mismatch' });
125
+ expect(calls).toContain('strip');
126
+ expect(calls).not.toContain('exchange');
127
+ expect(await store.loadDeviceToken()).toBeNull();
128
+ });
129
+
130
+ it('rejects when there is no expected state stashed at all', async () => {
131
+ const { deps } = makeDeps(encodeHash(fragmentObject()), { expectedState: null });
132
+ expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'state-mismatch' });
133
+ });
134
+
135
+ it('persists the deviceToken but returns no-session for a signed-out device', async () => {
136
+ const { deps, store, calls } = makeDeps(
137
+ encodeHash(fragmentObject({ reason: 'new_device', code: undefined })),
138
+ );
139
+ expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'no-session', reason: 'new_device' });
140
+ expect(calls).not.toContain('exchange');
141
+ expect(await store.loadDeviceToken()).toBe(DEVICE_TOKEN);
142
+ });
143
+
144
+ it('returns no-session when the code exchange fails (burned/expired code)', async () => {
145
+ const { deps } = makeDeps(encodeHash(fragmentObject()), {
146
+ exchangeBootCode: async () => {
147
+ throw new Error('code already burned');
148
+ },
149
+ });
150
+ expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'no-session', reason: 'no_session' });
151
+ });
152
+
153
+ it('strips even a malformed fragment and returns none', async () => {
154
+ const { deps, calls } = makeDeps('#oxy_boot=!!!malformed!!!');
155
+ expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'none' });
156
+ expect(calls).toContain('strip');
157
+ });
158
+ });