@oxyhq/core 8.1.0 → 9.1.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 (71) hide show
  1. package/README.md +1 -3
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/boot/coldBootV2.js +22 -332
  4. package/dist/cjs/crypto/keyManager.js +0 -95
  5. package/dist/cjs/index.js +6 -19
  6. package/dist/cjs/mixins/OxyServices.auth.js +4 -7
  7. package/dist/cjs/mixins/OxyServices.deviceBoot.js +19 -115
  8. package/dist/cjs/mixins/index.js +2 -3
  9. package/dist/cjs/session/SessionClient.js +29 -100
  10. package/dist/cjs/session/accountDialogController.js +0 -13
  11. package/dist/cjs/session/authStateStore.js +9 -89
  12. package/dist/cjs/session/createSessionClient.js +2 -9
  13. package/dist/cjs/session/refresh.js +46 -71
  14. package/dist/cjs/utils/registrableApex.js +2 -6
  15. package/dist/esm/.tsbuildinfo +1 -1
  16. package/dist/esm/boot/coldBootV2.js +23 -330
  17. package/dist/esm/crypto/keyManager.js +0 -95
  18. package/dist/esm/index.js +7 -11
  19. package/dist/esm/mixins/OxyServices.auth.js +4 -7
  20. package/dist/esm/mixins/OxyServices.deviceBoot.js +20 -116
  21. package/dist/esm/mixins/index.js +2 -3
  22. package/dist/esm/session/SessionClient.js +29 -100
  23. package/dist/esm/session/accountDialogController.js +0 -13
  24. package/dist/esm/session/authStateStore.js +8 -88
  25. package/dist/esm/session/createSessionClient.js +2 -9
  26. package/dist/esm/session/refresh.js +46 -71
  27. package/dist/esm/utils/registrableApex.js +2 -6
  28. package/dist/types/.tsbuildinfo +1 -1
  29. package/dist/types/HttpService.d.ts +3 -3
  30. package/dist/types/boot/coldBootV2.d.ts +28 -53
  31. package/dist/types/crypto/keyManager.d.ts +0 -21
  32. package/dist/types/index.d.ts +3 -5
  33. package/dist/types/mixins/OxyServices.auth.d.ts +4 -7
  34. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +20 -60
  35. package/dist/types/models/interfaces.d.ts +15 -0
  36. package/dist/types/session/SessionClient.d.ts +6 -40
  37. package/dist/types/session/accountDialogController.d.ts +1 -3
  38. package/dist/types/session/authStateStore.d.ts +32 -57
  39. package/dist/types/session/createSessionClient.d.ts +2 -9
  40. package/dist/types/session/refresh.d.ts +32 -28
  41. package/dist/types/utils/registrableApex.d.ts +2 -6
  42. package/package.json +3 -3
  43. package/src/HttpService.ts +3 -3
  44. package/src/boot/__tests__/coldBootV2.test.ts +140 -353
  45. package/src/boot/coldBootV2.ts +35 -391
  46. package/src/crypto/keyManager.ts +0 -101
  47. package/src/index.ts +7 -30
  48. package/src/mixins/OxyServices.auth.ts +5 -9
  49. package/src/mixins/OxyServices.deviceBoot.ts +19 -142
  50. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +9 -96
  51. package/src/mixins/__tests__/onTokensChanged.test.ts +0 -1
  52. package/src/mixins/__tests__/passwordSignIn.test.ts +33 -9
  53. package/src/mixins/index.ts +2 -3
  54. package/src/models/interfaces.ts +15 -0
  55. package/src/session/SessionClient.ts +31 -122
  56. package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +113 -0
  57. package/src/session/__tests__/SessionClient.socket.test.ts +8 -8
  58. package/src/session/__tests__/authStateStore.test.ts +29 -42
  59. package/src/session/__tests__/refresh.test.ts +66 -52
  60. package/src/session/accountDialogController.ts +4 -18
  61. package/src/session/authStateStore.ts +32 -126
  62. package/src/session/createSessionClient.ts +2 -9
  63. package/src/session/refresh.ts +60 -92
  64. package/src/utils/registrableApex.ts +2 -6
  65. package/dist/cjs/boot/deviceBootReturn.js +0 -167
  66. package/dist/esm/boot/deviceBootReturn.js +0 -161
  67. package/dist/types/boot/deviceBootReturn.d.ts +0 -83
  68. package/src/boot/__tests__/deviceBootReturn.test.ts +0 -190
  69. package/src/boot/deviceBootReturn.ts +0 -210
  70. package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
  71. package/src/session/__tests__/SessionClient.signedOut.test.ts +0 -224
@@ -1,59 +1,28 @@
1
+ /**
2
+ * Cold boot v3 (`runSessionColdBoot`) — the zero-cookie, DOM-less device-first
3
+ * boot. Only two ordered steps remain:
4
+ * 1. `device-secret-mint` (web + native) — mint an access token from the
5
+ * persisted `deviceId` + `deviceSecret`.
6
+ * 2. `shared-key-signin` (native) — re-mint from the shared-keychain identity.
7
+ * Anything unresolved ends signed out (never a redirect).
8
+ */
1
9
  import type { OxyServices } from '../../OxyServices';
2
- import type {
3
- AuthTokenBundle,
4
- DeviceTokenMintResponse,
5
- TokenRefreshResponse,
6
- WebSessionResult,
7
- } from '@oxyhq/contracts';
10
+ import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
8
11
  import type { SessionLoginResponse } from '../../models/session';
9
- import {
10
- runSessionColdBoot,
11
- createBrowserColdBootDom,
12
- isSameApex,
13
- BOOT_ATTEMPTED_KEY,
14
- type ColdBootDom,
15
- } from '../coldBootV2';
16
- import { BOOT_STATE_SESSION_KEY } from '../deviceBootReturn';
17
- import { createMemoryAuthStateStore, type AuthStateStore } from '../../session/authStateStore';
18
- import { KeyManager } from '../../crypto/keyManager';
19
-
20
- const BUNDLE: AuthTokenBundle = {
21
- sessionId: 'sess-boot',
22
- accessToken: 'access-boot',
23
- refreshToken: 'refresh-boot-abcdefghij',
24
- expiresAt: '2030-01-01T00:00:00.000Z',
25
- user: { id: 'user-boot', name: {} } as AuthTokenBundle['user'],
26
- };
27
-
28
- const ROTATED: TokenRefreshResponse = {
29
- accessToken: 'access-rot',
30
- refreshToken: 'refresh-rot-abcdefghij',
31
- expiresAt: '2030-01-01T00:00:00.000Z',
32
- sessionId: 'sess-rot',
33
- };
12
+ import { runSessionColdBoot } from '../coldBootV2';
13
+ import { createMemoryAuthStateStore, type PersistedAuthState } from '../../session/authStateStore';
34
14
 
35
15
  interface OxyOverrides {
36
- baseURL?: string;
37
- exchangeBootCode?: OxyServices['exchangeBootCode'];
38
- refreshWithToken?: OxyServices['refreshWithToken'];
39
16
  signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
40
- issueNativeDeviceToken?: OxyServices['issueNativeDeviceToken'];
41
- requestWebSession?: OxyServices['requestWebSession'];
42
17
  mintFromDeviceSecret?: OxyServices['mintFromDeviceSecret'];
43
18
  }
44
19
 
45
20
  function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: jest.Mock } {
46
21
  const setTokens = jest.fn();
47
22
  const oxy = {
48
- getBaseURL: () => overrides.baseURL ?? 'https://api.oxy.so',
23
+ getBaseURL: () => 'https://api.oxy.so',
49
24
  setTokens,
50
- exchangeBootCode: overrides.exchangeBootCode ?? (async () => BUNDLE),
51
- refreshWithToken: overrides.refreshWithToken ?? (async () => ROTATED),
52
25
  signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
53
- issueNativeDeviceToken: overrides.issueNativeDeviceToken ?? (async () => 'native-device-token'),
54
- requestWebSession:
55
- overrides.requestWebSession
56
- ?? (async () => ({ reason: 'no_session', deviceToken: 'dt-web' }) as WebSessionResult),
57
26
  // Default: no persisted secret in these fixtures, so the mint step skips
58
27
  // before ever calling this. Tests that exercise the mint pass an override.
59
28
  mintFromDeviceSecret:
@@ -61,8 +30,6 @@ function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: j
61
30
  ?? (async () => {
62
31
  throw new Error('mintFromDeviceSecret not stubbed');
63
32
  }),
64
- buildBootstrapUrl: (returnTo: string, state: string) =>
65
- `https://api.oxy.so/auth/device/bootstrap?return_to=${encodeURIComponent(returnTo)}&state=${state}`,
66
33
  } as unknown as OxyServices;
67
34
  return { oxy, setTokens };
68
35
  }
@@ -85,159 +52,36 @@ function mint401(body: string): Error & { status: number } {
85
52
  return Object.assign(new Error(body), { status: 401 });
86
53
  }
87
54
 
88
- interface DomHandle {
89
- dom: ColdBootDom;
90
- navigate: jest.Mock;
91
- strip: jest.Mock;
92
- session: Map<string, string>;
93
- local: Map<string, string>;
94
- }
95
-
96
- function makeDom(opts: { hash?: string; hostname?: string; sessionState?: string; attempted?: boolean } = {}): DomHandle {
97
- const session = new Map<string, string>();
98
- const local = new Map<string, string>();
99
- if (opts.sessionState !== undefined) session.set(BOOT_STATE_SESSION_KEY, opts.sessionState);
100
- if (opts.attempted) local.set(BOOT_ATTEMPTED_KEY, '1');
101
- const navigate = jest.fn();
102
- const strip = jest.fn();
103
- const dom: ColdBootDom = {
104
- getHash: () => opts.hash ?? '',
105
- stripFragment: strip,
106
- getSessionItem: (k) => session.get(k) ?? null,
107
- setSessionItem: (k, v) => {
108
- session.set(k, v);
109
- },
110
- removeSessionItem: (k) => {
111
- session.delete(k);
112
- },
113
- getLocalItem: (k) => local.get(k) ?? null,
114
- setLocalItem: (k, v) => {
115
- local.set(k, v);
116
- },
117
- getLocationHostname: () => opts.hostname ?? 'accounts.oxy.so',
118
- getReturnToHref: () => 'https://accounts.oxy.so/home',
119
- navigate,
120
- randomState: () => 'state-fixed-1234',
121
- };
122
- return { dom, navigate, strip, session, local };
123
- }
124
-
125
55
  const WEB = { isWeb: true, isNative: false };
126
56
  const NATIVE = { isWeb: false, isNative: true };
127
57
 
128
- describe('isSameApex', () => {
129
- it('groups by registrable domain', () => {
130
- expect(isSameApex('accounts.oxy.so', 'api.oxy.so')).toBe(true);
131
- expect(isSameApex('oxy.so', 'api.oxy.so')).toBe(true);
132
- expect(isSameApex('app.mention.earth', 'api.oxy.so')).toBe(false);
133
- expect(isSameApex('homiio.com', 'api.oxy.so')).toBe(false);
134
- });
135
-
136
- it('does NOT collapse distinct IPv4 hosts that share trailing octets', () => {
137
- // The last-2-labels heuristic would map both to "1.1" — must be exact.
138
- expect(isSameApex('192.168.1.1', '10.0.1.1')).toBe(false);
139
- expect(isSameApex('192.168.1.10', '192.168.1.5')).toBe(false);
140
- expect(isSameApex('192.168.1.5', '192.168.1.5')).toBe(true);
141
- });
142
-
143
- it('requires exact equality for single-label / IPv6 / localhost hosts', () => {
144
- expect(isSameApex('localhost', 'localhost')).toBe(true);
145
- expect(isSameApex('localhost', 'api.oxy.so')).toBe(false);
146
- expect(isSameApex('::1', '::1')).toBe(true);
147
- expect(isSameApex('fe80::1', 'fe80::2')).toBe(false);
148
- });
149
- });
150
-
151
- describe('runSessionColdBoot — step ordering', () => {
152
- it('stored-tokens warm-plants a still-valid access token before any hop', async () => {
153
- const store = createMemoryAuthStateStore();
154
- await store.save({
155
- sessionId: 'sess-warm',
156
- refreshToken: 'r-abcdefghij',
157
- userId: 'user-warm',
158
- accessToken: 'warm-token',
159
- expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
160
- });
161
- const { oxy, setTokens } = makeOxy();
162
- const requestWebSession = jest.spyOn(oxy, 'requestWebSession');
163
- const onSession = jest.fn();
164
-
165
- const outcome = await runSessionColdBoot({
166
- oxy, store, platform: WEB, dom: makeDom().dom, onSession,
167
- });
168
-
169
- expect(outcome).toEqual({ kind: 'session', via: 'stored-tokens', session: expect.any(Object) });
170
- expect(setTokens).toHaveBeenCalledWith('warm-token');
171
- expect(onSession).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-warm', via: 'stored-tokens' }));
172
- expect(requestWebSession).not.toHaveBeenCalled();
173
- });
174
-
175
- it('stored-tokens rotates when the warm token is absent/expired', async () => {
176
- const store = createMemoryAuthStateStore();
177
- await store.save({ sessionId: 'sess-old', refreshToken: 'r-abcdefghij', userId: 'user-1' });
178
- const { oxy, setTokens } = makeOxy();
179
-
180
- const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: makeDom().dom });
181
-
182
- expect(outcome.kind).toBe('session');
183
- expect(setTokens).toHaveBeenCalledWith('access-rot');
184
- expect(await store.load()).toMatchObject({ sessionId: 'sess-rot', refreshToken: 'refresh-rot-abcdefghij' });
185
- });
186
-
187
- it('bootstrap-return wins over later steps when a valid fragment is present', async () => {
188
- const store = createMemoryAuthStateStore();
189
- // A base64url fragment carrying reason:session + matching state.
190
- const frag = {
191
- v: 1,
192
- state: 'state-match',
193
- reason: 'session',
194
- code: 'c'.repeat(24),
195
- deviceToken: 'd'.repeat(24),
196
- };
197
- const b64 = Buffer.from(JSON.stringify(frag), 'utf-8')
198
- .toString('base64')
199
- .replace(/\+/g, '-')
200
- .replace(/\//g, '_')
201
- .replace(/=+$/, '');
202
- const domHandle = makeDom({ hash: `#oxy_boot=${b64}`, sessionState: 'state-match' });
203
- const { oxy, setTokens } = makeOxy();
204
- const onSession = jest.fn();
205
-
206
- const outcome = await runSessionColdBoot({
207
- oxy, store, platform: WEB, dom: domHandle.dom, onSession,
208
- });
209
-
210
- expect(outcome).toEqual({ kind: 'session', via: 'bootstrap-return', session: expect.any(Object) });
211
- expect(domHandle.strip).toHaveBeenCalled();
212
- expect(setTokens).toHaveBeenCalledWith('access-boot');
213
- expect(onSession).toHaveBeenCalledWith(expect.objectContaining({ via: 'bootstrap-return' }));
214
- });
215
- });
216
-
217
- describe('runSessionColdBoot — device-secret-mint (phase 2c)', () => {
218
- function makeCredStore(extra: Partial<import('../../session/authStateStore').PersistedAuthState> = {}) {
219
- const store = createMemoryAuthStateStore();
220
- return { store, seed: async () => {
58
+ /** Seed a store with the zero-cookie mint credential. */
59
+ function seedCredStore(extra: Partial<PersistedAuthState> = {}) {
60
+ const store = createMemoryAuthStateStore();
61
+ return {
62
+ store,
63
+ seed: async () => {
221
64
  await store.save({
222
65
  sessionId: 'sess-old',
223
- refreshToken: 'r-abcdefghij',
224
66
  userId: 'user-old',
225
67
  deviceId: 'dev-mint',
226
68
  deviceSecret: 'ds-secret-orig',
227
69
  ...extra,
228
70
  });
229
- } };
230
- }
71
+ },
72
+ };
73
+ }
231
74
 
75
+ describe('runSessionColdBoot — device-secret-mint', () => {
232
76
  it('wins FIRST via the mint, persisting nextDeviceSecret BEFORE planting the token', async () => {
233
- const { store, seed } = makeCredStore();
77
+ const { store, seed } = seedCredStore();
234
78
  await seed();
235
79
  const mintFromDeviceSecret = jest.fn(async () => MINT);
236
80
  const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
237
81
  const saveSpy = jest.spyOn(store, 'save');
238
82
  const onSession = jest.fn();
239
83
 
240
- const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: makeDom().dom, onSession });
84
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSession });
241
85
 
242
86
  expect(outcome).toEqual({ kind: 'session', via: 'device-secret-mint', session: expect.any(Object) });
243
87
  expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-mint', 'ds-secret-orig');
@@ -251,6 +95,7 @@ describe('runSessionColdBoot — device-secret-mint (phase 2c)', () => {
251
95
  const firstPlantOrder = Math.min(...setTokens.mock.invocationCallOrder);
252
96
  expect(lastSaveOrder).toBeLessThan(firstPlantOrder);
253
97
  expect(await store.load()).toMatchObject({
98
+ deviceId: 'dev-mint',
254
99
  deviceSecret: 'ds-next-secret',
255
100
  sessionId: 'sess-mint',
256
101
  userId: 'user-mint',
@@ -258,66 +103,77 @@ describe('runSessionColdBoot — device-secret-mint (phase 2c)', () => {
258
103
  });
259
104
  });
260
105
 
106
+ it('mints on native too (the step runs on both platforms)', async () => {
107
+ const { store, seed } = seedCredStore();
108
+ await seed();
109
+ const mintFromDeviceSecret = jest.fn(async () => MINT);
110
+ const signInWithSharedIdentity = jest.fn(async () => null);
111
+ const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret, signInWithSharedIdentity });
112
+
113
+ const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE });
114
+
115
+ expect(outcome).toMatchObject({ kind: 'session', via: 'device-secret-mint' });
116
+ expect(setTokens).toHaveBeenCalledWith('access-minted');
117
+ // The mint won first — the shared-key fallback was never reached.
118
+ expect(signInWithSharedIdentity).not.toHaveBeenCalled();
119
+ });
120
+
261
121
  it('skips (no mint) when only one of deviceId / deviceSecret is persisted', async () => {
262
122
  const store = createMemoryAuthStateStore();
263
- await store.save({ sessionId: 's', refreshToken: 'r-abcdefghij', userId: 'u', deviceSecret: 'ds-only' });
123
+ await store.save({ sessionId: 's', userId: 'u', deviceSecret: 'ds-only' });
264
124
  const mintFromDeviceSecret = jest.fn(async () => MINT);
265
125
  const { oxy } = makeOxy({ mintFromDeviceSecret });
126
+ const onSignedOut = jest.fn();
266
127
 
267
- const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: makeDom().dom });
128
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
268
129
 
269
130
  expect(mintFromDeviceSecret).not.toHaveBeenCalled();
270
- // Falls through to the migratory refresh family.
271
- expect(outcome).toEqual({ kind: 'session', via: 'stored-tokens', session: expect.any(Object) });
131
+ // Nothing else can resolve on web → signed out.
132
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
133
+ expect(onSignedOut).toHaveBeenCalledWith('no_session');
272
134
  });
273
135
 
274
- it('401 invalid_device_secret → drops the secret (keeps deviceId) and falls through', async () => {
275
- const { store, seed } = makeCredStore();
136
+ it('401 invalid_device_secret → drops the secret (keeps deviceId) and ends signed out on web', async () => {
137
+ const { store, seed } = seedCredStore();
276
138
  await seed();
277
139
  const mintFromDeviceSecret = jest.fn(async () => {
278
140
  throw mint401('invalid_device_secret');
279
141
  });
280
142
  const { oxy } = makeOxy({ mintFromDeviceSecret });
143
+ const onSignedOut = jest.fn();
281
144
 
282
- const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: makeDom().dom });
145
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
283
146
 
284
147
  expect(mintFromDeviceSecret).toHaveBeenCalled();
285
- // The migratory refresh lane recovers the session.
286
- expect(outcome.kind).toBe('session');
287
- expect(outcome).toMatchObject({ via: 'stored-tokens' });
148
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
149
+ expect(onSignedOut).toHaveBeenCalledWith('no_session');
288
150
  const persisted = await store.load();
289
151
  expect(persisted?.deviceSecret).toBeUndefined();
290
152
  expect(persisted?.deviceId).toBe('dev-mint');
291
153
  });
292
154
 
293
- it('401 no_active_session → keeps the secret, signed out, NEVER falls to the hop', async () => {
294
- const { store, seed } = makeCredStore();
155
+ it('401 no_active_session → keeps the secret and reports signed out', async () => {
156
+ const { store, seed } = seedCredStore();
295
157
  await seed();
296
158
  const mintFromDeviceSecret = jest.fn(async () => {
297
159
  throw mint401('no_active_session');
298
160
  });
299
- const { oxy } = makeOxy({ mintFromDeviceSecret });
300
- const refreshWithToken = jest.spyOn(oxy, 'refreshWithToken');
301
- const requestWebSession = jest.spyOn(oxy, 'requestWebSession');
302
- const domHandle = makeDom();
161
+ const signInWithSharedIdentity = jest.fn(async () => null);
162
+ const { oxy } = makeOxy({ mintFromDeviceSecret, signInWithSharedIdentity });
303
163
  const onSignedOut = jest.fn();
304
164
 
305
- const outcome = await runSessionColdBoot({
306
- oxy, store, platform: WEB, dom: domHandle.dom, onSignedOut,
307
- });
165
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
308
166
 
309
167
  expect(outcome).toEqual({ kind: 'unauthenticated' });
310
168
  expect(onSignedOut).toHaveBeenCalledWith('no_session');
311
- // The known-signed-out device is not bounced and no fallback lane runs.
312
- expect(refreshWithToken).not.toHaveBeenCalled();
313
- expect(requestWebSession).not.toHaveBeenCalled();
314
- expect(domHandle.navigate).not.toHaveBeenCalled();
315
- // The secret is retained — the device may sign in again.
169
+ // The known-signed-out device retains its secret (it may sign in again).
316
170
  expect((await store.load())?.deviceSecret).toBe('ds-secret-orig');
171
+ // Web has no shared-key lane.
172
+ expect(signInWithSharedIdentity).not.toHaveBeenCalled();
317
173
  });
318
174
 
319
175
  it('classifies a PLAIN-OBJECT 401 (no Error prototype) — no_active_session still keeps the secret', async () => {
320
- const { store, seed } = makeCredStore();
176
+ const { store, seed } = seedCredStore();
321
177
  await seed();
322
178
  // Cross-realm / ApiError-shaped throw: not `instanceof Error`. Must still be
323
179
  // read as no_active_session — misreading it as a stale secret would drop it.
@@ -327,205 +183,136 @@ describe('runSessionColdBoot — device-secret-mint (phase 2c)', () => {
327
183
  const { oxy } = makeOxy({ mintFromDeviceSecret });
328
184
  const onSignedOut = jest.fn();
329
185
 
330
- const outcome = await runSessionColdBoot({
331
- oxy, store, platform: WEB, dom: makeDom().dom, onSignedOut,
332
- });
186
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
333
187
 
334
188
  expect(outcome).toEqual({ kind: 'unauthenticated' });
189
+ expect(onSignedOut).toHaveBeenCalledWith('no_session');
335
190
  expect((await store.load())?.deviceSecret).toBe('ds-secret-orig');
336
191
  });
337
192
 
338
- it('transient (non-401) mint error → keeps the secret and falls through', async () => {
339
- const { store, seed } = makeCredStore();
193
+ it('transient (non-401) mint error → keeps the secret and reports signed out on web', async () => {
194
+ const { store, seed } = seedCredStore();
340
195
  await seed();
341
196
  const mintFromDeviceSecret = jest.fn(async () => {
342
197
  throw new Error('network down');
343
198
  });
344
199
  const { oxy } = makeOxy({ mintFromDeviceSecret });
200
+ const onSignedOut = jest.fn();
345
201
 
346
- const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: makeDom().dom });
202
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
347
203
 
348
- expect(outcome).toMatchObject({ kind: 'session', via: 'stored-tokens' });
349
- // The secret survives a transient failure (rotation preserved through refresh).
204
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
205
+ expect(onSignedOut).toHaveBeenCalledWith('no_session');
206
+ // The secret survives a transient failure so a later attempt can succeed.
350
207
  expect((await store.load())?.deviceSecret).toBe('ds-secret-orig');
351
208
  });
352
209
 
353
- it('transient mint + cookie-lane hop whose bundle omits a secret prior secret preserved', async () => {
354
- const { store, seed } = makeCredStore();
210
+ it('a dropped secret (401) on native falls through to the shared-key lane', async () => {
211
+ const { store, seed } = seedCredStore();
355
212
  await seed();
356
- // Mint down, refresh family down → the chain lands on bootstrap-hop, whose
357
- // web-session BUNDLE carries no deviceSecret. The still-valid prior secret
358
- // must survive the store overwrite (else the mint lane is orphaned forever).
213
+ const sharedSession: SessionLoginResponse = {
214
+ sessionId: 'sess-shared',
215
+ deviceId: 'dev-1',
216
+ expiresAt: '2030-01-01T00:00:00.000Z',
217
+ user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
218
+ accessToken: 'access-shared',
219
+ };
359
220
  const mintFromDeviceSecret = jest.fn(async () => {
360
- throw new Error('network down');
361
- });
362
- const refreshWithToken = jest.fn(async () => {
363
- throw new Error('refresh down');
221
+ throw mint401('invalid_device_secret');
364
222
  });
365
- const requestWebSession = jest.fn(
366
- async () => ({ reason: 'session', session: BUNDLE, deviceToken: 'dt-rotated' }) as WebSessionResult,
367
- );
368
- const { oxy } = makeOxy({ mintFromDeviceSecret, refreshWithToken, requestWebSession });
369
- const domHandle = makeDom({ hostname: 'accounts.oxy.so' });
223
+ const signInWithSharedIdentity = jest.fn(async () => sharedSession);
224
+ const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret, signInWithSharedIdentity });
370
225
 
371
- const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom });
226
+ const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE });
372
227
 
373
- expect(outcome).toMatchObject({ kind: 'session', via: 'bootstrap-hop' });
374
- const persisted = await store.load();
375
- expect(persisted?.deviceSecret).toBe('ds-secret-orig');
376
- expect(persisted?.deviceId).toBe('dev-mint');
228
+ expect(outcome).toEqual({ kind: 'session', via: 'shared-key-signin', session: expect.any(Object) });
229
+ expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
230
+ // The mint lane still dropped the stale secret before falling through.
231
+ expect((await store.load())?.deviceSecret).toBeUndefined();
232
+ // Shared-key plants tokens itself (via verifyChallenge); the cold boot does not.
233
+ expect(setTokens).not.toHaveBeenCalled();
377
234
  });
235
+ });
378
236
 
379
- it('is gated OFF while a #oxy_boot return fragment is present (bootstrap-return wins)', async () => {
380
- const { store, seed } = makeCredStore();
381
- await seed();
382
- const frag = { v: 1, state: 'state-match', reason: 'session', code: 'c'.repeat(24), deviceToken: 'd'.repeat(24) };
383
- const b64 = Buffer.from(JSON.stringify(frag), 'utf-8')
384
- .toString('base64')
385
- .replace(/\+/g, '-')
386
- .replace(/\//g, '_')
387
- .replace(/=+$/, '');
388
- const domHandle = makeDom({ hash: `#oxy_boot=${b64}`, sessionState: 'state-match' });
389
- const mintFromDeviceSecret = jest.fn(async () => MINT);
390
- const { oxy } = makeOxy({ mintFromDeviceSecret });
237
+ describe('runSessionColdBoot shared-key-signin (native)', () => {
238
+ const sharedSession: SessionLoginResponse = {
239
+ sessionId: 'sess-shared',
240
+ deviceId: 'dev-1',
241
+ expiresAt: '2030-01-01T00:00:00.000Z',
242
+ user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
243
+ accessToken: 'access-shared',
244
+ };
391
245
 
392
- const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom });
246
+ it('re-mints via the shared identity when there is no persisted mint credential', async () => {
247
+ const store = createMemoryAuthStateStore(); // no deviceId/deviceSecret
248
+ const signInWithSharedIdentity = jest.fn(async () => sharedSession);
249
+ const { oxy } = makeOxy({ signInWithSharedIdentity });
250
+ const onSession = jest.fn();
393
251
 
394
- expect(outcome).toMatchObject({ via: 'bootstrap-return' });
395
- expect(mintFromDeviceSecret).not.toHaveBeenCalled();
396
- expect(domHandle.strip).toHaveBeenCalled();
397
- });
398
- });
252
+ const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE, onSession });
399
253
 
400
- describe('runSessionColdBoot bootstrap-hop (web)', () => {
401
- it('same-apex: resolves a session via the inline web-session fetch (no navigation)', async () => {
402
- const store = createMemoryAuthStateStore();
403
- // The EXACT post-unwrap server shape (PR #526): a reason-`session` envelope
404
- // nesting the bundle under `session` plus the rotated deviceToken.
405
- const sessionEnvelope: WebSessionResult = { reason: 'session', session: BUNDLE, deviceToken: 'dt-rotated' };
406
- const requestWebSession = jest.fn(async () => sessionEnvelope);
407
- const { oxy, setTokens } = makeOxy({ requestWebSession });
408
- const domHandle = makeDom({ hostname: 'accounts.oxy.so' });
409
-
410
- const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom });
411
-
412
- expect(outcome).toEqual({ kind: 'session', via: 'bootstrap-hop', session: expect.any(Object) });
413
- expect(requestWebSession).toHaveBeenCalledTimes(1);
414
- expect(setTokens).toHaveBeenCalledWith('access-boot');
415
- expect(domHandle.navigate).not.toHaveBeenCalled();
416
- expect(await store.load()).toMatchObject({ sessionId: 'sess-boot', refreshToken: 'refresh-boot-abcdefghij' });
417
- // deviceToken from the SAME envelope is persisted on the session arm too.
418
- expect(await store.loadDeviceToken()).toBe('dt-rotated');
254
+ expect(outcome).toEqual({ kind: 'session', via: 'shared-key-signin', session: expect.any(Object) });
255
+ expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
256
+ expect(onSession).toHaveBeenCalledWith(
257
+ expect.objectContaining({ sessionId: 'sess-shared', userId: 'user-shared', via: 'shared-key-signin' }),
258
+ );
419
259
  });
420
260
 
421
- it('same-apex: signed-out device persists the deviceToken and reports signed out', async () => {
261
+ it('does NOT run the shared-key lane on web', async () => {
422
262
  const store = createMemoryAuthStateStore();
423
- const requestWebSession = jest.fn(async () => ({ reason: 'no_session', deviceToken: 'dt-web' }) as WebSessionResult);
424
- const { oxy } = makeOxy({ requestWebSession });
425
- const domHandle = makeDom({ hostname: 'accounts.oxy.so' });
263
+ const signInWithSharedIdentity = jest.fn(async () => sharedSession);
264
+ const { oxy } = makeOxy({ signInWithSharedIdentity });
426
265
  const onSignedOut = jest.fn();
427
266
 
428
- const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom, onSignedOut });
267
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
429
268
 
269
+ expect(signInWithSharedIdentity).not.toHaveBeenCalled();
430
270
  expect(outcome).toEqual({ kind: 'unauthenticated' });
431
271
  expect(onSignedOut).toHaveBeenCalledWith('no_session');
432
- expect(await store.loadDeviceToken()).toBe('dt-web');
433
- expect(domHandle.navigate).not.toHaveBeenCalled();
434
272
  });
435
273
 
436
- it('cross-apex: navigates ONCE, stashing state + the attempted flag', async () => {
274
+ it('reports signed out when the shared identity yields no session', async () => {
437
275
  const store = createMemoryAuthStateStore();
438
- const { oxy } = makeOxy();
439
- const domHandle = makeDom({ hostname: 'app.mention.earth' });
276
+ const signInWithSharedIdentity = jest.fn(async () => null);
277
+ const { oxy } = makeOxy({ signInWithSharedIdentity });
440
278
  const onSignedOut = jest.fn();
441
- const onSession = jest.fn();
442
279
 
443
- await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom, onSignedOut, onSession });
280
+ const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE, onSignedOut });
444
281
 
445
- expect(domHandle.navigate).toHaveBeenCalledTimes(1);
446
- expect(domHandle.navigate).toHaveBeenCalledWith(expect.stringContaining('/auth/device/bootstrap?return_to='));
447
- expect(domHandle.session.get(BOOT_STATE_SESSION_KEY)).toBe('state-fixed-1234');
448
- expect(domHandle.local.get(BOOT_ATTEMPTED_KEY)).toBe('1');
449
- // Navigating away — neither callback fires (no signed-out flash).
450
- expect(onSignedOut).not.toHaveBeenCalled();
451
- expect(onSession).not.toHaveBeenCalled();
452
- });
453
-
454
- it('cross-apex: NEVER navigates a second time once the attempted flag is set', async () => {
455
- const store = createMemoryAuthStateStore();
456
- const { oxy } = makeOxy();
457
- const domHandle = makeDom({ hostname: 'app.mention.earth', attempted: true });
458
- const onSignedOut = jest.fn();
459
-
460
- const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom, onSignedOut });
461
-
462
- expect(domHandle.navigate).not.toHaveBeenCalled();
282
+ expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
463
283
  expect(outcome).toEqual({ kind: 'unauthenticated' });
464
284
  expect(onSignedOut).toHaveBeenCalledWith('no_session');
465
285
  });
466
- });
467
286
 
468
- describe('runSessionColdBoot native shared-key', () => {
469
- afterEach(() => jest.restoreAllMocks());
470
-
471
- it('re-mints via shared identity and issues + mirrors a deviceToken the first time', async () => {
287
+ it('reports a step error (onStepError + signed-out reason `error`) when the shared identity throws', async () => {
472
288
  const store = createMemoryAuthStateStore();
473
- const sharedSession: SessionLoginResponse = {
474
- sessionId: 'sess-shared',
475
- deviceId: 'dev-1',
476
- expiresAt: '2030-01-01T00:00:00.000Z',
477
- user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
478
- accessToken: 'access-shared',
479
- };
480
- const issueNativeDeviceToken = jest.fn(async () => 'fresh-device-token');
481
- const { oxy } = makeOxy({
482
- signInWithSharedIdentity: async () => sharedSession,
483
- issueNativeDeviceToken,
289
+ const signInWithSharedIdentity = jest.fn(async () => {
290
+ throw new Error('keychain locked');
484
291
  });
485
- const getShared = jest.spyOn(KeyManager, 'getSharedDeviceToken').mockResolvedValue(null);
486
- const setShared = jest.spyOn(KeyManager, 'setSharedDeviceToken').mockResolvedValue(undefined);
487
- const requestWebSession = jest.spyOn(oxy, 'requestWebSession');
292
+ const { oxy } = makeOxy({ signInWithSharedIdentity });
293
+ const onSignedOut = jest.fn();
294
+ const onStepError = jest.fn();
488
295
 
489
- const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE, dom: makeDom().dom });
296
+ const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE, onSignedOut, onStepError });
490
297
 
491
- expect(outcome).toEqual({ kind: 'session', via: 'shared-key-signin', session: expect.any(Object) });
492
- expect(getShared).toHaveBeenCalled();
493
- expect(issueNativeDeviceToken).toHaveBeenCalled();
494
- expect(setShared).toHaveBeenCalledWith('fresh-device-token');
495
- expect(await store.loadDeviceToken()).toBe('fresh-device-token');
496
- // bootstrap-hop is web-only — never runs on native.
497
- expect(requestWebSession).not.toHaveBeenCalled();
298
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
299
+ expect(onStepError).toHaveBeenCalledWith('shared-key-signin', expect.any(Error));
300
+ expect(onSignedOut).toHaveBeenCalledWith('error');
498
301
  });
302
+ });
499
303
 
500
- it('skips device-token issuance when the shared token already exists', async () => {
304
+ describe('runSessionColdBoot signed out', () => {
305
+ it('reports signed out when the store is empty and no lane resolves (web)', async () => {
501
306
  const store = createMemoryAuthStateStore();
502
- const issueNativeDeviceToken = jest.fn(async () => 'unused');
503
- const { oxy } = makeOxy({
504
- signInWithSharedIdentity: async () => ({
505
- sessionId: 'sess-shared',
506
- deviceId: 'dev-1',
507
- expiresAt: '2030-01-01T00:00:00.000Z',
508
- user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
509
- accessToken: 'access-shared',
510
- }),
511
- issueNativeDeviceToken,
512
- });
513
- jest.spyOn(KeyManager, 'getSharedDeviceToken').mockResolvedValue('already-there');
514
-
515
- await runSessionColdBoot({ oxy, store, platform: NATIVE, dom: makeDom().dom });
307
+ const { oxy, setTokens } = makeOxy();
308
+ const onSession = jest.fn();
309
+ const onSignedOut = jest.fn();
516
310
 
517
- expect(issueNativeDeviceToken).not.toHaveBeenCalled();
518
- });
519
- });
311
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSession, onSignedOut });
520
312
 
521
- describe('createBrowserColdBootDom', () => {
522
- it('returns neutral values under the jest node environment (no window)', () => {
523
- const dom = createBrowserColdBootDom();
524
- expect(dom.getHash()).toBe('');
525
- expect(dom.getLocationHostname()).toBeNull();
526
- expect(dom.getSessionItem('x')).toBeNull();
527
- expect(() => dom.stripFragment()).not.toThrow();
528
- expect(() => dom.navigate('https://x')).not.toThrow();
529
- expect(dom.randomState()).toMatch(/^[0-9a-f]+$/);
313
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
314
+ expect(onSignedOut).toHaveBeenCalledWith('no_session');
315
+ expect(onSession).not.toHaveBeenCalled();
316
+ expect(setTokens).not.toHaveBeenCalled();
530
317
  });
531
318
  });