@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
@@ -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
+ });