@oxyhq/core 9.2.1 → 9.2.3

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 (45) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +27 -0
  3. package/dist/cjs/boot/sessionColdBoot.js +83 -53
  4. package/dist/cjs/crypto/keyManager.js +45 -12
  5. package/dist/cjs/index.js +2 -1
  6. package/dist/cjs/mixins/OxyServices.utility.js +9 -5
  7. package/dist/cjs/session/SessionClient.js +54 -4
  8. package/dist/cjs/session/accountDialogController.js +30 -0
  9. package/dist/cjs/session/authStateStore.js +204 -16
  10. package/dist/cjs/session/refresh.js +110 -37
  11. package/dist/esm/.tsbuildinfo +1 -1
  12. package/dist/esm/HttpService.js +27 -0
  13. package/dist/esm/boot/sessionColdBoot.js +83 -53
  14. package/dist/esm/crypto/keyManager.js +46 -13
  15. package/dist/esm/index.js +1 -1
  16. package/dist/esm/mixins/OxyServices.utility.js +9 -5
  17. package/dist/esm/session/SessionClient.js +54 -4
  18. package/dist/esm/session/accountDialogController.js +30 -0
  19. package/dist/esm/session/authStateStore.js +203 -15
  20. package/dist/esm/session/refresh.js +109 -37
  21. package/dist/types/.tsbuildinfo +1 -1
  22. package/dist/types/HttpService.d.ts +21 -0
  23. package/dist/types/boot/sessionColdBoot.d.ts +7 -3
  24. package/dist/types/index.d.ts +3 -3
  25. package/dist/types/session/SessionClient.d.ts +37 -4
  26. package/dist/types/session/accountDialogController.d.ts +17 -0
  27. package/dist/types/session/authStateStore.d.ts +48 -9
  28. package/dist/types/session/refresh.d.ts +67 -31
  29. package/package.json +2 -2
  30. package/src/HttpService.ts +31 -0
  31. package/src/boot/__tests__/sessionColdBoot.test.ts +119 -0
  32. package/src/boot/sessionColdBoot.ts +93 -74
  33. package/src/crypto/keyManager.ts +42 -15
  34. package/src/index.ts +3 -2
  35. package/src/mixins/OxyServices.utility.ts +10 -9
  36. package/src/session/SessionClient.ts +79 -7
  37. package/src/session/__tests__/SessionClient.additive.test.ts +58 -1
  38. package/src/session/__tests__/SessionClient.serverEvents.test.ts +71 -0
  39. package/src/session/__tests__/SessionClient.socket.test.ts +32 -0
  40. package/src/session/__tests__/accountDialogController.test.ts +85 -0
  41. package/src/session/__tests__/authStateStore.test.ts +232 -4
  42. package/src/session/__tests__/refresh.test.ts +141 -2
  43. package/src/session/accountDialogController.ts +45 -0
  44. package/src/session/authStateStore.ts +242 -16
  45. package/src/session/refresh.ts +146 -40
@@ -0,0 +1,71 @@
1
+ import type { DeviceSessionState } from '@oxyhq/contracts';
2
+ import type { MinimalSocket, SocketIOFactory } from '../socketLoader';
3
+ import { SessionClient, type SessionClientHost } from '../SessionClient';
4
+
5
+ type Handler = (...args: unknown[]) => void;
6
+ class FakeSocket implements MinimalSocket {
7
+ connected = false;
8
+ handlers = new Map<string, Handler[]>();
9
+ on(event: string, cb: Handler) { const l = this.handlers.get(event) ?? []; l.push(cb); this.handlers.set(event, l); }
10
+ off(event: string, cb?: Handler) { if (!cb) { this.handlers.delete(event); return; } this.handlers.set(event, (this.handlers.get(event) ?? []).filter((h) => h !== cb)); }
11
+ connect() { this.connected = true; }
12
+ disconnect() { this.connected = false; }
13
+ emitServer(event: string, payload: unknown) { for (const h of this.handlers.get(event) ?? []) h(payload); }
14
+ }
15
+
16
+ const STATE = (rev: number): DeviceSessionState => ({ deviceId: 'd1', accounts: [{ accountId: 'a1', sessionId: 's1', authuser: 0 }], activeAccountId: 'a1', revision: rev, updatedAt: 1720000000000 });
17
+ const SYNC = (rev: number) => ({ state: STATE(rev), activeToken: { accessToken: `jwt-${rev}`, expiresAt: 'x' } });
18
+
19
+ function makeHost(over: Partial<SessionClientHost> = {}): SessionClientHost {
20
+ return {
21
+ makeRequest: jest.fn().mockResolvedValue(SYNC(1)),
22
+ getBaseURL: () => 'http://test.invalid',
23
+ getAccessToken: () => 'tok',
24
+ getDeviceCredential: () => null,
25
+ onTokensChanged: () => () => undefined,
26
+ setTokens: jest.fn(),
27
+ getCurrentAccountId: () => 'a1',
28
+ ...over,
29
+ };
30
+ }
31
+
32
+ describe('SessionClient.onServerEvent', () => {
33
+ it('delivers a server event to a listener registered BEFORE the socket exists', async () => {
34
+ let created: FakeSocket | null = null;
35
+ const factory: SocketIOFactory = jest.fn(() => { created = new FakeSocket(); created.connected = true; return created; });
36
+ const client = new SessionClient(makeHost(), { socketFactory: factory });
37
+ const seen: unknown[] = [];
38
+ client.onServerEvent('civic:attested', (p) => seen.push(p));
39
+ await client.start();
40
+ created?.emitServer('civic:attested', { byUserId: 'u2' });
41
+ expect(seen).toEqual([{ byUserId: 'u2' }]);
42
+ client.stop();
43
+ });
44
+
45
+ it('delivers to a listener registered AFTER the socket exists, and unsubscribe stops delivery', async () => {
46
+ let created: FakeSocket | null = null;
47
+ const factory: SocketIOFactory = jest.fn(() => { created = new FakeSocket(); created.connected = true; return created; });
48
+ const client = new SessionClient(makeHost(), { socketFactory: factory });
49
+ await client.start();
50
+ const seen: unknown[] = [];
51
+ const unsub = client.onServerEvent('civic:attested', (p) => seen.push(p));
52
+ created?.emitServer('civic:attested', 1);
53
+ unsub();
54
+ created?.emitServer('civic:attested', 2);
55
+ expect(seen).toEqual([1]);
56
+ client.stop();
57
+ });
58
+
59
+ it('one listener throwing does not break the others', async () => {
60
+ let created: FakeSocket | null = null;
61
+ const factory: SocketIOFactory = jest.fn(() => { created = new FakeSocket(); created.connected = true; return created; });
62
+ const client = new SessionClient(makeHost(), { socketFactory: factory });
63
+ await client.start();
64
+ const seen: unknown[] = [];
65
+ client.onServerEvent('civic:attested', () => { throw new Error('boom'); });
66
+ client.onServerEvent('civic:attested', (p) => seen.push(p));
67
+ created?.emitServer('civic:attested', 'ok');
68
+ expect(seen).toEqual(['ok']);
69
+ client.stop();
70
+ });
71
+ });
@@ -131,4 +131,36 @@ describe('SessionClient socket', () => {
131
131
  c.stop();
132
132
  expect(fakeSocket.connected).toBe(false);
133
133
  });
134
+
135
+ it('a socket-pushed empty state fires onUnauthenticated with the PUSH origin (bug #4)', async () => {
136
+ const onUnauthenticated = jest.fn();
137
+ const c = new SessionClient(makeHost(), { onUnauthenticated });
138
+ await c.start();
139
+
140
+ const EMPTY: DeviceSessionState = { deviceId: 'd1', accounts: [], activeAccountId: null, revision: 9, updatedAt: 1720000000001 };
141
+ fakeSocket.trigger('session_state', EMPTY);
142
+
143
+ expect(onUnauthenticated).toHaveBeenCalledWith('push');
144
+ c.stop();
145
+ });
146
+
147
+ it('a REST signOut-all empty response fires onUnauthenticated with the REQUEST origin', async () => {
148
+ const onUnauthenticated = jest.fn();
149
+ const EMPTY_SYNC = {
150
+ state: { deviceId: 'd1', accounts: [], activeAccountId: null, revision: 9, updatedAt: 1720000000001 },
151
+ activeToken: null,
152
+ };
153
+ // bootstrap during start() returns a populated state; the signout (and any
154
+ // stray reconcile) returns empty.
155
+ const makeRequest = jest.fn().mockResolvedValue(EMPTY_SYNC).mockResolvedValueOnce(SYNC(1));
156
+ const c = new SessionClient(makeHost({ makeRequest }), { onUnauthenticated });
157
+ await c.start();
158
+
159
+ await c.signOut({ all: true });
160
+ // Flush any fire-and-forget post-commit reconcile before asserting/teardown.
161
+ await Promise.resolve();
162
+
163
+ expect(onUnauthenticated).toHaveBeenCalledWith('request');
164
+ c.stop();
165
+ });
134
166
  });
@@ -540,6 +540,91 @@ describe('AccountDialogController — sign in with Oxy', () => {
540
540
  });
541
541
  });
542
542
 
543
+ describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
544
+ const START_HANDLE = {
545
+ sessionToken: 'secret-tok',
546
+ authorizeCode: 'AUTH-CODE',
547
+ qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
548
+ expiresAt: Date.now() + 600_000,
549
+ status: 'pending' as const,
550
+ };
551
+
552
+ function makeController(opts: {
553
+ openUrl?: jest.Mock;
554
+ canOpenApp?: jest.Mock;
555
+ }): { controller: AccountDialogController; oxy: OxyMock } {
556
+ const oxy = makeOxy();
557
+ oxy.startCommonsSignIn.mockResolvedValue(START_HANDLE);
558
+ oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
559
+ const controller = new AccountDialogController({
560
+ oxyServices: oxy as unknown as OxyServices,
561
+ sessionClient: new TestSessionClient(host()),
562
+ clientId: 'oxy_dk_test',
563
+ pollIntervalMs: 1000,
564
+ openUrl: opts.openUrl,
565
+ canOpenApp: opts.canOpenApp,
566
+ });
567
+ return { controller, oxy };
568
+ }
569
+
570
+ it('deep-links into Commons via openUrl when canOpenApp reports it installed, keeping the QR/polling fallback', async () => {
571
+ const openUrl = jest.fn();
572
+ const canOpenApp = jest.fn().mockResolvedValue(true);
573
+ const { controller } = makeController({ openUrl, canOpenApp });
574
+
575
+ await controller.showQr();
576
+ await flush(); // let the (non-awaited) canOpenApp probe resolve
577
+
578
+ expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
579
+ expect(openUrl).toHaveBeenCalledWith('oxycommons://approve?v=1&code=AUTH-CODE');
580
+ // The QR + polling remain the fallback path — the flow is still waiting.
581
+ const snap = controller.getSnapshot();
582
+ expect(snap.view).toBe('qr');
583
+ expect(snap.signIn.phase).toBe('waiting');
584
+ expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
585
+ controller.cancelSignIn();
586
+ });
587
+
588
+ it('does NOT open Commons when canOpenApp reports it absent (renders QR only)', async () => {
589
+ const openUrl = jest.fn();
590
+ const canOpenApp = jest.fn().mockResolvedValue(false);
591
+ const { controller } = makeController({ openUrl, canOpenApp });
592
+
593
+ await controller.showQr();
594
+ await flush();
595
+
596
+ expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
597
+ expect(openUrl).not.toHaveBeenCalled();
598
+ expect(controller.getSnapshot().signIn.phase).toBe('waiting');
599
+ controller.cancelSignIn();
600
+ });
601
+
602
+ it('never probes or opens when canOpenApp is absent (web — unchanged behavior)', async () => {
603
+ const openUrl = jest.fn();
604
+ const { controller } = makeController({ openUrl });
605
+
606
+ await controller.showQr();
607
+ await flush();
608
+
609
+ expect(openUrl).not.toHaveBeenCalled();
610
+ expect(controller.getSnapshot().signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
611
+ controller.cancelSignIn();
612
+ });
613
+
614
+ it('swallows a canOpenApp probe rejection and keeps the QR fallback', async () => {
615
+ const openUrl = jest.fn();
616
+ const canOpenApp = jest.fn().mockRejectedValue(new Error('probe boom'));
617
+ const { controller } = makeController({ openUrl, canOpenApp });
618
+
619
+ await controller.showQr();
620
+ await flush();
621
+
622
+ expect(openUrl).not.toHaveBeenCalled();
623
+ expect(controller.getSnapshot().signIn.phase).toBe('waiting');
624
+ controller.cancelSignIn();
625
+ });
626
+ });
627
+
543
628
  describe('AccountDialogController — openPasswordAtOxyAuth', () => {
544
629
  beforeEach(() => {
545
630
  const store = new Map<string, string>();
@@ -3,6 +3,7 @@ import {
3
3
  createNativeAuthStateStore,
4
4
  createMemoryAuthStateStore,
5
5
  AUTH_STATE_STORAGE_KEY,
6
+ AUTH_STATE_TOKEN_STORAGE_KEY,
6
7
  type PersistedAuthState,
7
8
  type NativeKeyValueStorage,
8
9
  } from '../authStateStore';
@@ -133,7 +134,8 @@ describe('createWebAuthStateStore', () => {
133
134
 
134
135
  // Construction must not throw, and the store must still function (in-memory).
135
136
  const store = createWebAuthStateStore();
136
- await expect(store.save(SAMPLE)).resolves.toBeUndefined();
137
+ // A degraded in-memory store IS its own durability backing → reports `true`.
138
+ await expect(store.save(SAMPLE)).resolves.toBe(true);
137
139
  expect(await store.load()).toEqual(SAMPLE);
138
140
  });
139
141
 
@@ -142,13 +144,46 @@ describe('createWebAuthStateStore', () => {
142
144
  const store = createWebAuthStateStore();
143
145
  const withCreds: PersistedAuthState = { ...SAMPLE, deviceId: 'dev-abc', deviceSecret: 'ds-secret-xyz' };
144
146
 
145
- await expect(store.save(withCreds)).resolves.toBeUndefined();
147
+ // The durable write threw → the store reports the persist did NOT land.
148
+ await expect(store.save(withCreds)).resolves.toBe(false);
146
149
  // The write never reached storage...
147
150
  expect(localStorage.getItem(AUTH_STATE_STORAGE_KEY)).toBeNull();
148
151
  // ...but the in-memory mirror keeps the session (incl. the mint credential) live.
149
152
  expect(await store.load()).toEqual(withCreds);
150
153
  });
151
154
 
155
+ it('reports false when the durable write SILENTLY no-ops (read-back mismatch, no throw)', async () => {
156
+ // A backing store whose setItem neither throws nor persists — the exact
157
+ // failure the read-back guards. The mirror keeps the session live, but the
158
+ // credential did not land, so save() must report false.
159
+ const map = new Map<string, string>();
160
+ const storage = {
161
+ getItem: (k: string) => map.get(k) ?? null,
162
+ setItem: (k: string, v: string) => {
163
+ // Only the WARM key writes; the durable credential silently vanishes.
164
+ if (k === AUTH_STATE_TOKEN_STORAGE_KEY) map.set(k, v);
165
+ },
166
+ removeItem: (k: string) => {
167
+ map.delete(k);
168
+ },
169
+ clear: () => map.clear(),
170
+ key: (i: number) => Array.from(map.keys())[i] ?? null,
171
+ get length() {
172
+ return map.size;
173
+ },
174
+ } as Storage;
175
+ installLocalStorage(storage);
176
+ const store = createWebAuthStateStore();
177
+
178
+ await expect(
179
+ store.save({ ...SAMPLE, deviceId: 'dev-x', deviceSecret: 'ds-x' }),
180
+ ).resolves.toBe(false);
181
+ // The durable blob never landed…
182
+ expect(storage.getItem(AUTH_STATE_STORAGE_KEY)).toBeNull();
183
+ // …but the mirror still serves the session for this page's lifetime.
184
+ expect(await store.load()).toMatchObject({ deviceId: 'dev-x', deviceSecret: 'ds-x' });
185
+ });
186
+
152
187
  it('a cleared session reads null even if storage later holds a stale blob (mirror wins)', async () => {
153
188
  const storage = makeFakeStorage();
154
189
  installLocalStorage(storage);
@@ -161,6 +196,103 @@ describe('createWebAuthStateStore', () => {
161
196
  // The authoritative in-memory mirror still reports the cleared state.
162
197
  expect(await store.load()).toBeNull();
163
198
  });
199
+
200
+ it('splits the token into the warm key and keeps the durable blob token-free', async () => {
201
+ const storage = makeFakeStorage();
202
+ installLocalStorage(storage);
203
+ const store = createWebAuthStateStore();
204
+
205
+ await store.save({ ...SAMPLE, deviceId: 'dev-1', deviceSecret: 'ds-1' });
206
+
207
+ // Durable key holds ONLY the small mint-critical fields — never the JWT.
208
+ const durableRaw = storage.getItem(AUTH_STATE_STORAGE_KEY);
209
+ expect(durableRaw).toBeTruthy();
210
+ expect(JSON.parse(durableRaw ?? '{}')).toEqual({
211
+ sessionId: 's-1',
212
+ userId: 'u-1',
213
+ deviceId: 'dev-1',
214
+ deviceSecret: 'ds-1',
215
+ });
216
+
217
+ // Warm key holds ONLY the short-lived token pair.
218
+ const warmRaw = storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY);
219
+ expect(warmRaw).toBeTruthy();
220
+ expect(JSON.parse(warmRaw ?? '{}')).toEqual({
221
+ accessToken: 'a-jwt',
222
+ expiresAt: '2030-01-01T00:00:00.000Z',
223
+ });
224
+
225
+ // A FRESH store (empty mirror) composes both keys back into the same shape.
226
+ expect(await createWebAuthStateStore().load()).toEqual({
227
+ ...SAMPLE,
228
+ deviceId: 'dev-1',
229
+ deviceSecret: 'ds-1',
230
+ });
231
+ });
232
+
233
+ it('persists the durable credential even when the warm-token write fails', async () => {
234
+ const map = new Map<string, string>();
235
+ const storage = {
236
+ getItem: (k: string) => map.get(k) ?? null,
237
+ setItem: (k: string, v: string) => {
238
+ // Simulate the warm token exceeding the store's capacity while the small
239
+ // durable blob writes fine.
240
+ if (k === AUTH_STATE_TOKEN_STORAGE_KEY) {
241
+ throw new DOMException('QuotaExceededError', 'QuotaExceededError');
242
+ }
243
+ map.set(k, v);
244
+ },
245
+ removeItem: (k: string) => {
246
+ map.delete(k);
247
+ },
248
+ clear: () => map.clear(),
249
+ key: (i: number) => Array.from(map.keys())[i] ?? null,
250
+ get length() {
251
+ return map.size;
252
+ },
253
+ } as Storage;
254
+ installLocalStorage(storage);
255
+ const store = createWebAuthStateStore();
256
+
257
+ await expect(
258
+ store.save({ ...SAMPLE, deviceId: 'dev-abc', deviceSecret: 'ds-secret-xyz' }),
259
+ ).resolves.toBe(true);
260
+
261
+ // The durable mint credential landed despite the warm-token write throwing.
262
+ const durableRaw = storage.getItem(AUTH_STATE_STORAGE_KEY);
263
+ expect(durableRaw).toBeTruthy();
264
+ const durable = JSON.parse(durableRaw ?? '{}');
265
+ expect(durable.deviceId).toBe('dev-abc');
266
+ expect(durable.deviceSecret).toBe('ds-secret-xyz');
267
+ expect(durable.accessToken).toBeUndefined();
268
+ // The warm-token key never persisted.
269
+ expect(storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY)).toBeNull();
270
+
271
+ // A fresh store restores the mint credential from disk; no warm token survives.
272
+ const loaded = await createWebAuthStateStore().load();
273
+ expect(loaded?.deviceId).toBe('dev-abc');
274
+ expect(loaded?.deviceSecret).toBe('ds-secret-xyz');
275
+ expect(loaded?.accessToken).toBeUndefined();
276
+ });
277
+
278
+ it('load() reads an old combined oxy.auth.v1 blob (pre-split back-compat)', async () => {
279
+ const storage = makeFakeStorage();
280
+ installLocalStorage(storage);
281
+ // A user upgraded from the pre-split build: the WHOLE state (incl. the token)
282
+ // lives in the single durable key; the warm key does not exist yet.
283
+ storage.setItem(
284
+ AUTH_STATE_STORAGE_KEY,
285
+ JSON.stringify({ ...SAMPLE, deviceId: 'dev-old', deviceSecret: 'ds-old' }),
286
+ );
287
+ expect(storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY)).toBeNull();
288
+
289
+ const store = createWebAuthStateStore();
290
+ const loaded = await store.load();
291
+ // The token is read back from the combined blob (no one is logged out).
292
+ expect(loaded).toEqual({ ...SAMPLE, deviceId: 'dev-old', deviceSecret: 'ds-old' });
293
+ expect(loaded?.accessToken).toBe('a-jwt');
294
+ expect(loaded?.expiresAt).toBe('2030-01-01T00:00:00.000Z');
295
+ });
164
296
  });
165
297
 
166
298
  describe('createNativeAuthStateStore', () => {
@@ -206,10 +338,106 @@ describe('createNativeAuthStateStore', () => {
206
338
  throw new Error('secure store locked');
207
339
  },
208
340
  });
209
- await expect(store.save(SAMPLE)).resolves.toBeUndefined();
210
- // The write threw, but the in-memory mirror preserves the session.
341
+ // The durable write threw → reports the persist did NOT land…
342
+ await expect(store.save(SAMPLE)).resolves.toBe(false);
343
+ // …but the in-memory mirror preserves the session for this app run.
211
344
  expect(await store.load()).toEqual(SAMPLE);
212
345
  });
346
+
347
+ it('reports false when the durable SecureStore write SILENTLY no-ops (oversize value, no throw)', async () => {
348
+ // Android SecureStore can resolve a write WITHOUT throwing yet not persist an
349
+ // oversize value — the read-back is the only reliable proof. save() must
350
+ // report false so a rotating-secret lane refuses to plant on it.
351
+ const map = new Map<string, string>();
352
+ const storage: NativeKeyValueStorage = {
353
+ getItem: async (k) => map.get(k) ?? null,
354
+ setItem: async (k, v) => {
355
+ if (k === AUTH_STATE_TOKEN_STORAGE_KEY) map.set(k, v); // durable silently dropped
356
+ },
357
+ removeItem: async (k) => {
358
+ map.delete(k);
359
+ },
360
+ };
361
+ const store = createNativeAuthStateStore(storage);
362
+
363
+ await expect(
364
+ store.save({ ...SAMPLE, deviceId: 'dev-x', deviceSecret: 'ds-x' }),
365
+ ).resolves.toBe(false);
366
+ expect(map.get(AUTH_STATE_STORAGE_KEY)).toBeUndefined();
367
+ expect(await store.load()).toMatchObject({ deviceId: 'dev-x', deviceSecret: 'ds-x' });
368
+ });
369
+
370
+ it('persists the durable credential even when the warm-token write fails (oversize SecureStore value)', async () => {
371
+ const map = new Map<string, string>();
372
+ const storage: NativeKeyValueStorage = {
373
+ getItem: async (k) => map.get(k) ?? null,
374
+ // The large JWT exceeds the SecureStore value limit; the small durable blob
375
+ // writes fine.
376
+ setItem: async (k, v) => {
377
+ if (k === AUTH_STATE_TOKEN_STORAGE_KEY) {
378
+ throw new Error('Value too large for SecureStore');
379
+ }
380
+ map.set(k, v);
381
+ },
382
+ removeItem: async (k) => {
383
+ map.delete(k);
384
+ },
385
+ };
386
+ const store = createNativeAuthStateStore(storage);
387
+
388
+ await expect(
389
+ store.save({ ...SAMPLE, deviceId: 'dev-n', deviceSecret: 'ds-n' }),
390
+ ).resolves.toBe(true);
391
+
392
+ // The durable mint credential landed to disk.
393
+ expect(map.get(AUTH_STATE_STORAGE_KEY)).toBeTruthy();
394
+ const durable = JSON.parse(map.get(AUTH_STATE_STORAGE_KEY) ?? '{}');
395
+ expect(durable.deviceId).toBe('dev-n');
396
+ expect(durable.deviceSecret).toBe('ds-n');
397
+ expect(durable.accessToken).toBeUndefined();
398
+ expect(map.get(AUTH_STATE_TOKEN_STORAGE_KEY)).toBeUndefined();
399
+
400
+ // A FRESH store (empty mirror) restores the mint credential from disk.
401
+ const loaded = await createNativeAuthStateStore(storage).load();
402
+ expect(loaded?.deviceId).toBe('dev-n');
403
+ expect(loaded?.deviceSecret).toBe('ds-n');
404
+ expect(loaded?.accessToken).toBeUndefined();
405
+ });
406
+
407
+ it('load() reads an old combined blob (pre-split back-compat)', async () => {
408
+ const map = new Map<string, string>();
409
+ const storage: NativeKeyValueStorage = {
410
+ getItem: async (k) => map.get(k) ?? null,
411
+ setItem: async (k, v) => {
412
+ map.set(k, v);
413
+ },
414
+ removeItem: async (k) => {
415
+ map.delete(k);
416
+ },
417
+ };
418
+ // Pre-split combined blob in the single durable key; no warm key.
419
+ map.set(
420
+ AUTH_STATE_STORAGE_KEY,
421
+ JSON.stringify({ ...SAMPLE, deviceId: 'dev-old', deviceSecret: 'ds-old' }),
422
+ );
423
+
424
+ const store = createNativeAuthStateStore(storage);
425
+ expect(await store.load()).toEqual({ ...SAMPLE, deviceId: 'dev-old', deviceSecret: 'ds-old' });
426
+ });
427
+
428
+ it('clear() wipes BOTH the durable and warm keys', async () => {
429
+ const storage = makeNativeStorage();
430
+ const store = createNativeAuthStateStore(storage);
431
+ await store.save({ ...SAMPLE, deviceId: 'dev-1', deviceSecret: 'ds-1' });
432
+ // Both keys were written by the split save.
433
+ expect(storage.map.get(AUTH_STATE_STORAGE_KEY)).toBeTruthy();
434
+ expect(storage.map.get(AUTH_STATE_TOKEN_STORAGE_KEY)).toBeTruthy();
435
+
436
+ await store.clear();
437
+ expect(storage.map.get(AUTH_STATE_STORAGE_KEY)).toBeUndefined();
438
+ expect(storage.map.get(AUTH_STATE_TOKEN_STORAGE_KEY)).toBeUndefined();
439
+ expect(await store.load()).toBeNull();
440
+ });
213
441
  });
214
442
 
215
443
  describe('createMemoryAuthStateStore', () => {
@@ -1,8 +1,34 @@
1
1
  import type { OxyServices } from '../../OxyServices';
2
2
  import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
3
3
  import type { SessionLoginResponse } from '../../models/session';
4
- import { refreshPersistedSession, startTokenRefreshScheduler } from '../refresh';
5
- import { createMemoryAuthStateStore, type PersistedAuthState } from '../authStateStore';
4
+ import {
5
+ refreshPersistedSession,
6
+ startTokenRefreshScheduler,
7
+ type DeviceSecretMintOutcome,
8
+ } from '../refresh';
9
+ import {
10
+ createMemoryAuthStateStore,
11
+ type AuthStateStore,
12
+ type PersistedAuthState,
13
+ } from '../authStateStore';
14
+
15
+ /**
16
+ * A real, per-client device-secret mint single-flight matching
17
+ * `HttpService.runSingleFlightDeviceSecretMint`: concurrent callers await the
18
+ * SAME in-flight mint and all receive its result; a fresh call after it settles
19
+ * starts a new one.
20
+ */
21
+ function makeMintSingleFlight(): (mint: () => Promise<DeviceSecretMintOutcome>) => Promise<DeviceSecretMintOutcome> {
22
+ let inFlight: Promise<DeviceSecretMintOutcome> | null = null;
23
+ return (mint) => {
24
+ if (!inFlight) {
25
+ inFlight = mint().finally(() => {
26
+ inFlight = null;
27
+ });
28
+ }
29
+ return inFlight;
30
+ };
31
+ }
6
32
 
7
33
  /** The persisted zero-cookie mint credential the refresh reads. */
8
34
  const STORED: PersistedAuthState = {
@@ -37,6 +63,9 @@ function makeOxy(overrides: RefreshMockOverrides = {}): { oxy: OxyServices; setT
37
63
  setTokens,
38
64
  mintFromDeviceSecret: overrides.mintFromDeviceSecret ?? (async () => MINT),
39
65
  signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
66
+ // The rotating mint runs under the client's process-wide single-flight; the
67
+ // arm reaches for it via `oxy.httpService.runSingleFlightDeviceSecretMint`.
68
+ httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
40
69
  } as unknown as OxyServices;
41
70
  return { oxy, setTokens };
42
71
  }
@@ -169,6 +198,116 @@ describe('refreshPersistedSession — arm 2 (native shared-key fallback)', () =>
169
198
  expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true })).toBeNull();
170
199
  expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
171
200
  });
201
+
202
+ it('persists the recovered credential from the shared-key re-mint (repopulates the fast lane)', async () => {
203
+ // Bug #3: an in-session shared-key recovery must repopulate the durable
204
+ // device credential, not leave the fast device-secret lane empty.
205
+ const store = createMemoryAuthStateStore(); // no persisted secret → arm 1 skips
206
+ const RECOVERED: SessionLoginResponse = {
207
+ sessionId: 'sess-shared',
208
+ deviceId: 'dev-shared',
209
+ deviceSecret: 'ds-shared-secret',
210
+ expiresAt: '2030-01-01T00:00:00.000Z',
211
+ user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
212
+ accessToken: 'access-shared',
213
+ };
214
+ const { oxy } = makeOxy({ signInWithSharedIdentity: jest.fn(async () => RECOVERED) });
215
+
216
+ const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
217
+
218
+ expect(token).toBe('access-shared');
219
+ expect(await store.load()).toEqual({
220
+ sessionId: 'sess-shared',
221
+ userId: 'user-shared',
222
+ deviceId: 'dev-shared',
223
+ deviceSecret: 'ds-shared-secret',
224
+ accessToken: 'access-shared',
225
+ expiresAt: '2030-01-01T00:00:00.000Z',
226
+ });
227
+ });
228
+ });
229
+
230
+ describe('refreshPersistedSession — single-flight (no double-rotation)', () => {
231
+ it('coalesces two concurrent mints into ONE server rotation; the store holds the final current secret', async () => {
232
+ // The server rotates the secret on every mint. Two concurrent lanes must
233
+ // therefore share ONE in-flight mint (one rotation) or the store could
234
+ // converge on a superseded secret.
235
+ const store = createMemoryAuthStateStore();
236
+ await store.save(STORED);
237
+
238
+ let release: (() => void) | null = null;
239
+ const gate = new Promise<void>((resolve) => {
240
+ release = resolve;
241
+ });
242
+ const mintFromDeviceSecret = jest.fn(async (deviceId: string, deviceSecret: string) => {
243
+ // Block until BOTH callers have entered so the single-flight is exercised.
244
+ await gate;
245
+ expect(deviceId).toBe('dev-mint');
246
+ expect(deviceSecret).toBe('ds-secret-orig');
247
+ return MINT;
248
+ });
249
+ const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
250
+
251
+ const first = refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
252
+ const second = refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
253
+ // Both callers entered while the mint is in flight.
254
+ release?.();
255
+ const [t1, t2] = await Promise.all([first, second]);
256
+
257
+ // Exactly one server rotation despite two concurrent callers…
258
+ expect(mintFromDeviceSecret).toHaveBeenCalledTimes(1);
259
+ // …both callers received the same minted token…
260
+ expect(t1).toBe('access-new');
261
+ expect(t2).toBe('access-new');
262
+ // …and the durable store converged on the rotated (current) secret.
263
+ expect((await store.load())?.deviceSecret).toBe('ds-next-secret');
264
+ // The token was planted exactly once (inside the single-flighted mint).
265
+ expect(setTokens).toHaveBeenCalledTimes(1);
266
+ });
267
+ });
268
+
269
+ describe('refreshPersistedSession — durable persist failure is fatal to the mint', () => {
270
+ it('does NOT plant a token when the rotated secret cannot be durably persisted', async () => {
271
+ // Bug #2: a mint that rotated the server secret but could not persist it must
272
+ // not leave the process advertising a session on an unsaved, soon-dead secret.
273
+ const failingStore: AuthStateStore = {
274
+ load: async () => STORED,
275
+ save: async () => false, // durable write did not land
276
+ clear: async () => undefined,
277
+ };
278
+ const mintFromDeviceSecret = jest.fn(async () => MINT);
279
+ const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
280
+
281
+ const token = await refreshPersistedSession({
282
+ oxy,
283
+ store: failingStore,
284
+ allowSharedKeyFallback: false,
285
+ });
286
+
287
+ // The mint ran (the server rotated) but the token was NOT planted…
288
+ expect(mintFromDeviceSecret).toHaveBeenCalledTimes(1);
289
+ expect(setTokens).not.toHaveBeenCalled();
290
+ // …and the lane reports failure rather than a healthy session.
291
+ expect(token).toBeNull();
292
+ });
293
+
294
+ it('does NOT fall through to the shared-key arm on a persist failure', async () => {
295
+ const failingStore: AuthStateStore = {
296
+ load: async () => STORED,
297
+ save: async () => false,
298
+ clear: async () => undefined,
299
+ };
300
+ const signInWithSharedIdentity = jest.fn(async () => null);
301
+ const { oxy } = makeOxy({
302
+ mintFromDeviceSecret: async () => MINT,
303
+ signInWithSharedIdentity,
304
+ });
305
+
306
+ await refreshPersistedSession({ oxy, store: failingStore, allowSharedKeyFallback: true });
307
+
308
+ // A storage failure is not a bad-secret signal — the shared-key arm must not run.
309
+ expect(signInWithSharedIdentity).not.toHaveBeenCalled();
310
+ });
172
311
  });
173
312
 
174
313
  describe('startTokenRefreshScheduler', () => {