@oxyhq/core 5.5.0 → 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 (181) 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 -44
  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 +40 -1
  18. package/dist/cjs/session/authStateStore.js +284 -0
  19. package/dist/cjs/session/refresh.js +264 -0
  20. package/dist/cjs/utils/accountUtils.js +1 -55
  21. package/dist/cjs/utils/authWebUrl.js +6 -15
  22. package/dist/cjs/utils/fapiAutoDetect.js +16 -64
  23. package/dist/cjs/utils/platform.js +19 -0
  24. package/dist/cjs/utils/ssoBounce.js +15 -362
  25. package/dist/cjs/utils/validationUtils.js +57 -0
  26. package/dist/esm/.tsbuildinfo +1 -1
  27. package/dist/esm/HttpService.js +6 -3
  28. package/dist/esm/OxyServices.base.js +7 -102
  29. package/dist/esm/boot/coldBootV2.js +344 -0
  30. package/dist/esm/boot/deviceBootReturn.js +146 -0
  31. package/dist/esm/crypto/keyManager.js +95 -0
  32. package/dist/esm/i18n/locales/en-US.json +13 -1
  33. package/dist/esm/i18n/locales/es-ES.json +13 -1
  34. package/dist/esm/i18n/locales/locales/en-US.json +13 -1
  35. package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
  36. package/dist/esm/index.js +28 -18
  37. package/dist/esm/mixins/OxyServices.accounts.js +6 -13
  38. package/dist/esm/mixins/OxyServices.auth.js +66 -201
  39. package/dist/esm/mixins/OxyServices.authorizedApps.js +35 -0
  40. package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
  41. package/dist/esm/mixins/index.js +13 -17
  42. package/dist/esm/session/SessionClient.js +40 -1
  43. package/dist/esm/session/authStateStore.js +278 -0
  44. package/dist/esm/session/refresh.js +257 -0
  45. package/dist/esm/utils/accountUtils.js +0 -53
  46. package/dist/esm/utils/authWebUrl.js +6 -14
  47. package/dist/esm/utils/fapiAutoDetect.js +16 -63
  48. package/dist/esm/utils/platform.js +18 -0
  49. package/dist/esm/utils/ssoBounce.js +14 -345
  50. package/dist/esm/utils/validationUtils.js +56 -0
  51. package/dist/types/.tsbuildinfo +1 -1
  52. package/dist/types/HttpService.d.ts +13 -0
  53. package/dist/types/OxyServices.base.d.ts +0 -52
  54. package/dist/types/OxyServices.d.ts +0 -25
  55. package/dist/types/boot/coldBootV2.d.ts +76 -0
  56. package/dist/types/boot/deviceBootReturn.d.ts +83 -0
  57. package/dist/types/crypto/keyManager.d.ts +21 -0
  58. package/dist/types/index.d.ts +15 -21
  59. package/dist/types/mixins/OxyServices.accounts.d.ts +0 -2
  60. package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
  61. package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
  62. package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
  63. package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
  64. package/dist/types/mixins/{OxyServices.redirect.d.ts → OxyServices.authorizedApps.d.ts} +35 -33
  65. package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
  66. package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
  67. package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
  68. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
  69. package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
  70. package/dist/types/mixins/OxyServices.features.d.ts +0 -2
  71. package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
  72. package/dist/types/mixins/OxyServices.language.d.ts +0 -2
  73. package/dist/types/mixins/OxyServices.links.d.ts +0 -2
  74. package/dist/types/mixins/OxyServices.location.d.ts +0 -2
  75. package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
  76. package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
  77. package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
  78. package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
  79. package/dist/types/mixins/OxyServices.security.d.ts +0 -2
  80. package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
  81. package/dist/types/mixins/OxyServices.user.d.ts +0 -2
  82. package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
  83. package/dist/types/mixins/index.d.ts +6 -9
  84. package/dist/types/models/interfaces.d.ts +0 -67
  85. package/dist/types/session/SessionClient.d.ts +25 -0
  86. package/dist/types/session/authStateStore.d.ts +119 -0
  87. package/dist/types/session/refresh.d.ts +93 -0
  88. package/dist/types/utils/accountUtils.d.ts +0 -14
  89. package/dist/types/utils/authWebUrl.d.ts +6 -12
  90. package/dist/types/utils/fapiAutoDetect.d.ts +15 -38
  91. package/dist/types/utils/platform.d.ts +14 -0
  92. package/dist/types/utils/ssoBounce.d.ts +14 -280
  93. package/dist/types/utils/validationUtils.d.ts +15 -0
  94. package/package.json +2 -2
  95. package/src/HttpService.ts +19 -3
  96. package/src/OxyServices.base.ts +7 -112
  97. package/src/OxyServices.ts +0 -38
  98. package/src/boot/__tests__/coldBootV2.test.ts +317 -0
  99. package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
  100. package/src/boot/coldBootV2.ts +426 -0
  101. package/src/boot/deviceBootReturn.ts +195 -0
  102. package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
  103. package/src/crypto/keyManager.ts +101 -0
  104. package/src/i18n/locales/en-US.json +13 -1
  105. package/src/i18n/locales/es-ES.json +13 -1
  106. package/src/index.ts +74 -65
  107. package/src/mixins/OxyServices.accounts.ts +6 -13
  108. package/src/mixins/OxyServices.auth.ts +78 -253
  109. package/src/mixins/OxyServices.authorizedApps.ts +75 -0
  110. package/src/mixins/OxyServices.deviceBoot.ts +146 -0
  111. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
  112. package/src/mixins/__tests__/accounts.test.ts +17 -44
  113. package/src/mixins/__tests__/authorizedApps.test.ts +63 -0
  114. package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
  115. package/src/mixins/index.ts +16 -22
  116. package/src/models/interfaces.ts +0 -79
  117. package/src/session/SessionClient.ts +52 -1
  118. package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
  119. package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
  120. package/src/session/__tests__/SessionClient.state.test.ts +18 -5
  121. package/src/session/__tests__/authStateStore.test.ts +209 -0
  122. package/src/session/__tests__/refresh.test.ts +256 -0
  123. package/src/session/authStateStore.ts +335 -0
  124. package/src/session/refresh.ts +334 -0
  125. package/src/utils/__tests__/authWebUrl.test.ts +5 -29
  126. package/src/utils/__tests__/fapiAutoDetect.test.ts +5 -126
  127. package/src/utils/__tests__/validationUtils.test.ts +30 -0
  128. package/src/utils/accountUtils.ts +0 -62
  129. package/src/utils/authWebUrl.ts +6 -15
  130. package/src/utils/fapiAutoDetect.ts +16 -60
  131. package/src/utils/platform.ts +21 -0
  132. package/src/utils/ssoBounce.ts +14 -393
  133. package/src/utils/validationUtils.ts +62 -0
  134. package/dist/cjs/AuthManager.js +0 -1110
  135. package/dist/cjs/AuthManagerTypes.js +0 -13
  136. package/dist/cjs/CrossDomainAuth.js +0 -206
  137. package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
  138. package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
  139. package/dist/cjs/mixins/OxyServices.silent.js +0 -204
  140. package/dist/cjs/mixins/OxyServices.sso.js +0 -208
  141. package/dist/cjs/utils/ssoEstablish.js +0 -110
  142. package/dist/cjs/utils/ssoReturn.js +0 -275
  143. package/dist/esm/AuthManager.js +0 -1105
  144. package/dist/esm/AuthManagerTypes.js +0 -12
  145. package/dist/esm/CrossDomainAuth.js +0 -201
  146. package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
  147. package/dist/esm/mixins/OxyServices.redirect.js +0 -92
  148. package/dist/esm/mixins/OxyServices.silent.js +0 -202
  149. package/dist/esm/mixins/OxyServices.sso.js +0 -204
  150. package/dist/esm/utils/ssoEstablish.js +0 -107
  151. package/dist/esm/utils/ssoReturn.js +0 -271
  152. package/dist/types/AuthManager.d.ts +0 -380
  153. package/dist/types/AuthManagerTypes.d.ts +0 -81
  154. package/dist/types/CrossDomainAuth.d.ts +0 -164
  155. package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
  156. package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
  157. package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
  158. package/dist/types/utils/ssoEstablish.d.ts +0 -85
  159. package/dist/types/utils/ssoReturn.d.ts +0 -156
  160. package/src/AuthManager.ts +0 -1269
  161. package/src/AuthManagerTypes.ts +0 -86
  162. package/src/CrossDomainAuth.ts +0 -243
  163. package/src/__tests__/authManager.cookiePath.test.ts +0 -390
  164. package/src/__tests__/authManager.security.test.ts +0 -377
  165. package/src/__tests__/crossDomainAuth.test.ts +0 -116
  166. package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
  167. package/src/mixins/OxyServices.fedcm.ts +0 -1026
  168. package/src/mixins/OxyServices.redirect.ts +0 -122
  169. package/src/mixins/OxyServices.silent.ts +0 -272
  170. package/src/mixins/OxyServices.sso.ts +0 -261
  171. package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
  172. package/src/mixins/__tests__/fedcm.test.ts +0 -667
  173. package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
  174. package/src/mixins/__tests__/silent.test.ts +0 -102
  175. package/src/mixins/__tests__/sso.test.ts +0 -228
  176. package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
  177. package/src/utils/__tests__/ssoBounce.test.ts +0 -219
  178. package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
  179. package/src/utils/__tests__/ssoReturn.test.ts +0 -276
  180. package/src/utils/ssoEstablish.ts +0 -174
  181. package/src/utils/ssoReturn.ts +0 -389
@@ -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
+ });