@oxyhq/core 8.1.0 → 9.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 (68) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/coldBootV2.js +22 -332
  3. package/dist/cjs/crypto/keyManager.js +0 -95
  4. package/dist/cjs/index.js +6 -19
  5. package/dist/cjs/mixins/OxyServices.auth.js +4 -7
  6. package/dist/cjs/mixins/OxyServices.deviceBoot.js +19 -115
  7. package/dist/cjs/mixins/index.js +2 -3
  8. package/dist/cjs/session/SessionClient.js +29 -100
  9. package/dist/cjs/session/accountDialogController.js +0 -13
  10. package/dist/cjs/session/authStateStore.js +9 -89
  11. package/dist/cjs/session/createSessionClient.js +2 -9
  12. package/dist/cjs/session/refresh.js +46 -71
  13. package/dist/cjs/utils/registrableApex.js +2 -6
  14. package/dist/esm/.tsbuildinfo +1 -1
  15. package/dist/esm/boot/coldBootV2.js +23 -330
  16. package/dist/esm/crypto/keyManager.js +0 -95
  17. package/dist/esm/index.js +7 -11
  18. package/dist/esm/mixins/OxyServices.auth.js +4 -7
  19. package/dist/esm/mixins/OxyServices.deviceBoot.js +20 -116
  20. package/dist/esm/mixins/index.js +2 -3
  21. package/dist/esm/session/SessionClient.js +29 -100
  22. package/dist/esm/session/accountDialogController.js +0 -13
  23. package/dist/esm/session/authStateStore.js +8 -88
  24. package/dist/esm/session/createSessionClient.js +2 -9
  25. package/dist/esm/session/refresh.js +46 -71
  26. package/dist/esm/utils/registrableApex.js +2 -6
  27. package/dist/types/.tsbuildinfo +1 -1
  28. package/dist/types/HttpService.d.ts +3 -3
  29. package/dist/types/boot/coldBootV2.d.ts +28 -53
  30. package/dist/types/crypto/keyManager.d.ts +0 -21
  31. package/dist/types/index.d.ts +3 -5
  32. package/dist/types/mixins/OxyServices.auth.d.ts +4 -7
  33. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +20 -60
  34. package/dist/types/session/SessionClient.d.ts +4 -38
  35. package/dist/types/session/accountDialogController.d.ts +1 -3
  36. package/dist/types/session/authStateStore.d.ts +32 -57
  37. package/dist/types/session/createSessionClient.d.ts +2 -9
  38. package/dist/types/session/refresh.d.ts +32 -28
  39. package/dist/types/utils/registrableApex.d.ts +2 -6
  40. package/package.json +2 -2
  41. package/src/HttpService.ts +3 -3
  42. package/src/boot/__tests__/coldBootV2.test.ts +140 -353
  43. package/src/boot/coldBootV2.ts +35 -391
  44. package/src/crypto/keyManager.ts +0 -101
  45. package/src/index.ts +7 -30
  46. package/src/mixins/OxyServices.auth.ts +5 -9
  47. package/src/mixins/OxyServices.deviceBoot.ts +19 -142
  48. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +9 -96
  49. package/src/mixins/__tests__/onTokensChanged.test.ts +0 -1
  50. package/src/mixins/__tests__/passwordSignIn.test.ts +33 -9
  51. package/src/mixins/index.ts +2 -3
  52. package/src/session/SessionClient.ts +29 -120
  53. package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +113 -0
  54. package/src/session/__tests__/SessionClient.socket.test.ts +8 -8
  55. package/src/session/__tests__/authStateStore.test.ts +29 -42
  56. package/src/session/__tests__/refresh.test.ts +66 -52
  57. package/src/session/accountDialogController.ts +4 -18
  58. package/src/session/authStateStore.ts +32 -126
  59. package/src/session/createSessionClient.ts +2 -9
  60. package/src/session/refresh.ts +60 -92
  61. package/src/utils/registrableApex.ts +2 -6
  62. package/dist/cjs/boot/deviceBootReturn.js +0 -167
  63. package/dist/esm/boot/deviceBootReturn.js +0 -161
  64. package/dist/types/boot/deviceBootReturn.d.ts +0 -83
  65. package/src/boot/__tests__/deviceBootReturn.test.ts +0 -190
  66. package/src/boot/deviceBootReturn.ts +0 -210
  67. package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
  68. package/src/session/__tests__/SessionClient.signedOut.test.ts +0 -224
@@ -46,40 +46,17 @@ export interface SessionClientOptions {
46
46
  * absent it falls back to `getSocketIO()`.
47
47
  */
48
48
  socketFactory?: SocketIOFactory;
49
- /**
50
- * Gate + credential for opening the realtime socket while SIGNED OUT (no
51
- * access token), so an idle tab still joins its `device:<id>` room and can
52
- * self-acquire the moment a sibling app/tab signs in on the same device.
53
- * Returns:
54
- * - `true` → connect and rely on the first-party `oxy_device` cookie
55
- * riding the same-site handshake (web `*.oxy.so`; the cookie is HttpOnly
56
- * so JS cannot read it, but the browser sends it automatically).
57
- * - a string → connect and present it as `deviceToken` in the handshake
58
- * auth (native shared-keychain device token; RN has no cookie jar).
59
- * - `false`/`null` → do NOT open a signed-out socket (the default — e.g. a
60
- * native app with no known device yet).
61
- * Called at connect time (and each reconnect); may be async (keychain read).
62
- */
63
- signedOutSocketAuth?: () => boolean | string | null | Promise<boolean | string | null>;
64
- /**
65
- * Invoked when a `session_state` push (or a same-origin BroadcastChannel wake)
66
- * arrives while this tab is SIGNED OUT and the pushed device state has at
67
- * least one account — i.e. a sibling just signed in on this device. The
68
- * consumer runs its session acquisition (cold boot / `requestWebSession`),
69
- * which plants a token and flips the tab to signed-in. Guarded + idempotent:
70
- * only one acquisition runs at a time, and a returned promise gates the next.
71
- */
72
- onSessionAppeared?: () => void | Promise<void>;
73
49
  }
74
50
 
75
51
  type StateListener = (state: DeviceSessionState | null) => void;
76
52
 
77
53
  /**
78
- * Same-origin `BroadcastChannel` name for instant, network-free session wake
79
- * across tabs of the SAME origin (e.g. two `accounts.oxy.so` tabs). Complements
80
- * the cookie-authed device socket, which covers same-apex cross-origin
81
- * (`accounts` `console` `inbox`, all `*.oxy.so`). Cross-APEX (mention.earth)
82
- * is covered by neither and relies on a reload — the documented limitation.
54
+ * Same-origin `BroadcastChannel` name for instant, network-free session-state
55
+ * propagation across tabs of the SAME origin (e.g. two `accounts.oxy.so` tabs):
56
+ * when one authenticated tab commits an account switch / sign-out, siblings
57
+ * re-sync their device state without waiting on the socket. Cross-origin
58
+ * same-apex propagation rides the authenticated device socket; cross-APEX
59
+ * (mention.earth) relies on a reload — the documented limitation.
83
60
  */
84
61
  const SESSION_BROADCAST_CHANNEL = 'oxy.session';
85
62
 
@@ -96,11 +73,7 @@ export class SessionClient {
96
73
  protected socket: MinimalSocket | null = null;
97
74
  private tokenUnsub: (() => void) | null = null;
98
75
  private started = false;
99
- /** In-flight guard so a burst of pushes triggers at most ONE acquisition. */
100
- private acquiring = false;
101
- /** True while the live socket is an anonymous (signed-out) device connection. */
102
- private socketAnonymous = false;
103
- /** Same-origin cross-tab wake channel; null on platforms without BroadcastChannel. */
76
+ /** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
104
77
  private channel: SessionBroadcastChannel | null = null;
105
78
 
106
79
  constructor(
@@ -248,27 +221,19 @@ export class SessionClient {
248
221
  if (this.started) return;
249
222
  this.started = true;
250
223
  this.tokenUnsub = this.host.onTokensChanged((token) => {
251
- if (!token) return;
252
- // A bearer just landed: an in-flight acquisition (if any) succeeded — clear
253
- // the guard so a later sign-out can re-acquire.
254
- this.acquiring = false;
255
- if (!this.socket) return;
256
- if (this.socketAnonymous) {
257
- // The live socket was an anonymous (device-room-only) connection. Force a
258
- // reconnect so the handshake re-runs authenticated and also joins the
259
- // `user:<id>` notification room.
260
- this.socketAnonymous = false;
261
- this.socket.disconnect();
262
- this.socket.connect();
263
- } else if (!this.socket.connected) {
224
+ // A rotated/fresh bearer landed — reconnect a dropped socket so its
225
+ // handshake re-runs with the current token. Sign-out (null token) is
226
+ // handled by the consumer calling `stop()`.
227
+ if (!token || !this.socket) return;
228
+ if (!this.socket.connected) {
264
229
  this.socket.connect();
265
230
  }
266
231
  });
267
232
  this.openBroadcastChannel();
268
- // `bootstrap` (`GET /session/device/state`) is bearer-authenticated: skip it
269
- // when signed out (the signed-out socket below still joins the device room to
270
- // receive pushes). A bootstrap failure is non-fatal — the socket must still
271
- // connect so realtime sync survives a transient state-fetch error.
233
+ // `bootstrap` (`GET /session/device/state`) is bearer-authenticated. A
234
+ // signed-out client opens no socket and runs no bootstrap; a bootstrap
235
+ // failure is non-fatal — the socket still connects so realtime sync
236
+ // survives a transient state-fetch error.
272
237
  if (this.host.getAccessToken()) {
273
238
  try {
274
239
  await this.bootstrap();
@@ -281,8 +246,6 @@ export class SessionClient {
281
246
 
282
247
  stop(): void {
283
248
  this.started = false;
284
- this.acquiring = false;
285
- this.socketAnonymous = false;
286
249
  if (this.tokenUnsub) {
287
250
  this.tokenUnsub();
288
251
  this.tokenUnsub = null;
@@ -312,51 +275,22 @@ export class SessionClient {
312
275
  return;
313
276
  }
314
277
  if (!this.started) return; // stopped while the dynamic import was in flight
315
- const hasToken = Boolean(this.host.getAccessToken());
316
-
317
- // Signed-out connect gate: when there is no bearer, only open the socket if
318
- // the consumer supplies a device anchor (web cookie → `true`; native token →
319
- // string). This lets an idle tab receive its device's `session_state` pushes
320
- // and self-acquire when a sibling signs in.
321
- let signedOutAuth: boolean | string | null = false;
322
- if (!hasToken && this.options.signedOutSocketAuth) {
323
- try {
324
- signedOutAuth = await this.options.signedOutSocketAuth();
325
- } catch (error) {
326
- logger.warn('[SessionClient] signedOutSocketAuth failed', { component: 'SessionClient' }, error);
327
- signedOutAuth = false;
328
- }
329
- if (!this.started) return; // stopped while the async gate was in flight
330
- }
331
- const signedOutConnect = signedOutAuth !== false && signedOutAuth != null;
332
- const signedOutDeviceToken = typeof signedOutAuth === 'string' ? signedOutAuth : undefined;
333
- this.socketAnonymous = !hasToken && signedOutConnect;
278
+ // Sockets are BEARER-ONLY: the server rejects any handshake without a valid
279
+ // bearer, so a signed-out client never opens a socket.
280
+ if (!this.host.getAccessToken()) return;
334
281
 
335
282
  const socket = io(this.host.getBaseURL(), {
336
283
  transports: ['websocket'],
337
- // Send the first-party `oxy_device` cookie on the same-site handshake so a
338
- // signed-out browser tab can be resolved to its device room server-side.
339
- withCredentials: true,
340
- autoConnect: hasToken || signedOutConnect,
341
- auth: (cb: (data: { token: string; deviceToken?: string }) => void) => {
342
- const token = this.host.getAccessToken() ?? '';
343
- // Present the native device token only while signed out (no bearer). Once
344
- // a token is held the bearer path wins and the deviceToken is redundant.
345
- cb(token || !signedOutDeviceToken ? { token } : { token, deviceToken: signedOutDeviceToken });
284
+ autoConnect: true,
285
+ auth: (cb: (data: { token: string }) => void) => {
286
+ cb({ token: this.host.getAccessToken() ?? '' });
346
287
  },
347
288
  });
348
289
  socket.on('session_state', (payload: unknown) => {
349
290
  const applied = this.applyState(payload);
350
291
  if (!applied) return;
351
- // Signed-out tab: a session appeared on this device (a sibling signed in).
352
- // Acquire it so this tab flips to signed-in; the planted token then
353
- // reconnects the socket on the authenticated path.
354
- if (!this.host.getAccessToken()) {
355
- if ((this.state?.accounts.length ?? 0) > 0) {
356
- this.requestAcquisition();
357
- }
358
- return;
359
- }
292
+ // A push changed the active account on another device/tab re-fetch state
293
+ // to plant the access token for the newly-active account.
360
294
  const active = this.state?.activeAccountId ?? null;
361
295
  if (active && active !== this.host.getCurrentAccountId()) {
362
296
  void this.bootstrap().catch((error) => {
@@ -367,34 +301,11 @@ export class SessionClient {
367
301
  this.socket = socket;
368
302
  }
369
303
 
370
- /**
371
- * Run the consumer's session acquisition at most once at a time. A returned
372
- * promise gates the next attempt (reset on settle), so a failed acquisition
373
- * can retry on the NEXT push while a burst of identical pushes cannot pile up.
374
- */
375
- private requestAcquisition(): void {
376
- if (this.acquiring || !this.options.onSessionAppeared) return;
377
- this.acquiring = true;
378
- let result: void | Promise<void>;
379
- try {
380
- result = this.options.onSessionAppeared();
381
- } catch (error) {
382
- this.acquiring = false;
383
- logger.error('[SessionClient] onSessionAppeared threw', error);
384
- return;
385
- }
386
- void Promise.resolve(result).catch((error) => {
387
- logger.warn('[SessionClient] onSessionAppeared rejected', { component: 'SessionClient' }, error);
388
- }).finally(() => {
389
- this.acquiring = false;
390
- });
391
- }
392
-
393
304
  /**
394
305
  * Open the same-origin `BroadcastChannel` (web only). A sibling tab that
395
- * commits a session posts a wake ping; on receipt a signed-in tab re-syncs its
396
- * device state and a signed-out tab self-acquires — instant + network-free for
397
- * the common "two tabs of the same origin" case, with no state (and no tokens)
306
+ * commits an account switch / sign-out posts a wake ping; on receipt an
307
+ * authenticated tab re-syncs its device state — instant + network-free for the
308
+ * common "two tabs of the same origin" case, with no state (and no tokens)
398
309
  * ever crossing the channel. No-op on native (no BroadcastChannel).
399
310
  */
400
311
  private openBroadcastChannel(): void {
@@ -412,14 +323,12 @@ export class SessionClient {
412
323
  if (!event || typeof event.data !== 'object' || event.data === null) return;
413
324
  if ((event.data as { type?: unknown }).type !== 'commit') return;
414
325
  // BroadcastChannel never echoes to the posting context, so this is a
415
- // sibling's commit. Re-sync (signed-in) or acquire (signed-out). Neither
416
- // path re-posts, so there is no cross-tab ping loop.
326
+ // sibling's commit. An authenticated tab re-syncs its device state; the
327
+ // re-sync does not re-post, so there is no cross-tab ping loop.
417
328
  if (this.host.getAccessToken()) {
418
329
  void this.bootstrap().catch((error) => {
419
330
  logger.warn('[SessionClient] broadcast re-sync failed', { component: 'SessionClient' }, error);
420
331
  });
421
- } else {
422
- this.requestAcquisition();
423
332
  }
424
333
  };
425
334
  this.channel = channel;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * SessionClient same-origin `BroadcastChannel` cross-tab state propagation.
3
+ *
4
+ * This is the AUTHENTICATED-only survivor of the zero-cookie cutover: when one
5
+ * authenticated tab commits an account switch / sign-out, same-origin siblings
6
+ * re-sync their device state instantly without waiting on the socket. The
7
+ * signed-out "self-acquire on sibling sign-in" wake is gone (sockets are now
8
+ * bearer-only), so every case here runs with a bearer present.
9
+ */
10
+ import type { DeviceSessionState } from '@oxyhq/contracts';
11
+
12
+ type Handler = (...args: unknown[]) => void;
13
+ class FakeSocket {
14
+ connected = false;
15
+ handlers = new Map<string, Handler[]>();
16
+ on(event: string, cb: Handler) { const l = this.handlers.get(event) ?? []; l.push(cb); this.handlers.set(event, l); }
17
+ off(event: string, cb?: Handler) { if (!cb) { this.handlers.delete(event); return; } this.handlers.set(event, (this.handlers.get(event) ?? []).filter((h) => h !== cb)); }
18
+ connect() { this.connected = true; this.trigger('connect'); }
19
+ disconnect() { this.connected = false; }
20
+ trigger(event: string, ...args: unknown[]) { for (const h of this.handlers.get(event) ?? []) h(...args); }
21
+ }
22
+ let fakeSocket: FakeSocket;
23
+ const ioMock = jest.fn((_uri: string, opts?: Record<string, unknown>) => {
24
+ if (!opts || opts.autoConnect !== false) fakeSocket.connected = true;
25
+ return fakeSocket;
26
+ });
27
+ jest.mock('socket.io-client', () => ({ __esModule: true, io: (...args: unknown[]) => ioMock(...(args as [string, Record<string, unknown>?])) }));
28
+
29
+ // A same-name in-process BroadcastChannel bus: postMessage delivers to every
30
+ // OTHER open channel of the same name (never the sender) — matching the spec.
31
+ type BusEntry = { name: string; onmessage: ((event: { data: unknown }) => void) | null };
32
+ const bus = new Set<BusEntry>();
33
+ class FakeBroadcastChannel {
34
+ private entry: BusEntry;
35
+ constructor(public name: string) { this.entry = { name, onmessage: null }; bus.add(this.entry); }
36
+ get onmessage(): ((event: { data: unknown }) => void) | null { return this.entry.onmessage; }
37
+ set onmessage(cb: ((event: { data: unknown }) => void) | null) { this.entry.onmessage = cb; }
38
+ postMessage(data: unknown) {
39
+ for (const e of bus) {
40
+ if (e === this.entry || e.name !== this.name) continue;
41
+ e.onmessage?.({ data });
42
+ }
43
+ }
44
+ close() { bus.delete(this.entry); }
45
+ }
46
+
47
+ import { SessionClient, type SessionClientHost } from '../SessionClient';
48
+
49
+ const STATE = (rev: number, accounts = [{ accountId: 'a1', sessionId: 's1', authuser: 0 }]): DeviceSessionState =>
50
+ ({ deviceId: 'd1', accounts, activeAccountId: accounts[0]?.accountId ?? null, revision: rev, updatedAt: 1720000000000 });
51
+ const SYNC = (rev: number) => ({ state: STATE(rev), activeToken: { accessToken: `jwt-${rev}`, expiresAt: 'x' } });
52
+
53
+ function makeHost(over: Partial<SessionClientHost> = {}): SessionClientHost {
54
+ return {
55
+ makeRequest: jest.fn().mockResolvedValue(SYNC(1)),
56
+ getBaseURL: () => 'http://test.invalid',
57
+ getAccessToken: () => 'tok',
58
+ onTokensChanged: () => () => undefined,
59
+ setTokens: jest.fn(),
60
+ getCurrentAccountId: () => 'a1',
61
+ ...over,
62
+ };
63
+ }
64
+
65
+ const flush = async () => { await Promise.resolve(); await Promise.resolve(); };
66
+
67
+ beforeEach(() => {
68
+ fakeSocket = new FakeSocket();
69
+ ioMock.mockClear();
70
+ bus.clear();
71
+ (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = FakeBroadcastChannel as unknown;
72
+ });
73
+ afterEach(() => {
74
+ (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = undefined;
75
+ });
76
+
77
+ describe('SessionClient BroadcastChannel cross-tab re-sync (authenticated)', () => {
78
+ it('a local mutation wakes an authenticated same-origin sibling to re-sync (bootstrap)', async () => {
79
+ const bMakeRequest = jest.fn().mockResolvedValue(SYNC(1));
80
+ const b = new SessionClient(makeHost({ makeRequest: bMakeRequest, getAccessToken: () => 'tok-b' }), {});
81
+ await b.start();
82
+ bMakeRequest.mockClear();
83
+ const a = new SessionClient(makeHost({ getAccessToken: () => 'tok-a' }), {});
84
+ await a.start();
85
+ await a.addCurrentAccount();
86
+ await flush();
87
+ expect(bMakeRequest).toHaveBeenCalledWith('GET', '/session/device/state', undefined, { cache: false });
88
+ a.stop();
89
+ b.stop();
90
+ });
91
+
92
+ it('does not re-sync the posting tab from its own commit ping (no self-loop)', async () => {
93
+ const makeRequest = jest.fn().mockResolvedValue(SYNC(1));
94
+ const a = new SessionClient(makeHost({ makeRequest, getAccessToken: () => 'tok-a' }), {});
95
+ await a.start();
96
+ makeRequest.mockClear();
97
+ // `addCurrentAccount` POSTs then posts a commit ping; the ping never echoes
98
+ // to the sender, so this tab must NOT re-fetch its own device state.
99
+ await a.addCurrentAccount();
100
+ await flush();
101
+ expect(makeRequest).not.toHaveBeenCalledWith('GET', '/session/device/state', undefined, { cache: false });
102
+ a.stop();
103
+ });
104
+
105
+ it('is a no-op on platforms without BroadcastChannel (native)', async () => {
106
+ (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = undefined;
107
+ const c = new SessionClient(makeHost({ getAccessToken: () => 'tok' }), {});
108
+ await c.start();
109
+ // Mutations must not throw when BroadcastChannel is absent.
110
+ await expect(c.switchAccount('a1')).resolves.toBeUndefined();
111
+ c.stop();
112
+ });
113
+ });
@@ -105,21 +105,21 @@ describe('SessionClient socket', () => {
105
105
  c.stop();
106
106
  });
107
107
 
108
- it('does not connect the socket when there is no token (autoConnect false)', async () => {
108
+ it('does not open a socket at all when signed out (bearer-only)', async () => {
109
109
  const c = new SessionClient(makeHost({ getAccessToken: () => null }));
110
110
  await c.start();
111
- const [, opts] = ioMock.mock.calls[0];
112
- expect(opts?.autoConnect).toBe(false);
111
+ expect(ioMock).not.toHaveBeenCalled();
113
112
  c.stop();
114
113
  });
115
114
 
116
- it('reconnects when a token arrives after being disconnected', async () => {
117
- let tokenListener: ((t: string | null) => void) | null = null;
118
- const host = makeHost({ getAccessToken: () => null, onTokensChanged: (l) => { tokenListener = l; return () => undefined; } });
115
+ it('reconnects an existing socket when a fresh token arrives after a transient drop', async () => {
116
+ const listeners: Array<(t: string | null) => void> = [];
117
+ const host = makeHost({ onTokensChanged: (l) => { listeners.push(l); return () => undefined; } });
119
118
  const c = new SessionClient(host);
120
119
  await c.start();
121
- fakeSocket.connected = false;
122
- tokenListener?.('fresh-token');
120
+ expect(fakeSocket.connected).toBe(true); // authenticated connect on start
121
+ fakeSocket.connected = false; // simulate a transient socket drop
122
+ listeners.forEach((l) => l('fresh-token'));
123
123
  expect(fakeSocket.connected).toBe(true);
124
124
  c.stop();
125
125
  });
@@ -3,16 +3,18 @@ import {
3
3
  createNativeAuthStateStore,
4
4
  createMemoryAuthStateStore,
5
5
  AUTH_STATE_STORAGE_KEY,
6
- DEVICE_TOKEN_STORAGE_KEY,
7
6
  type PersistedAuthState,
8
7
  type NativeKeyValueStorage,
9
8
  } from '../authStateStore';
10
9
 
10
+ /**
11
+ * The zero-cookie persisted shape: `sessionId` + `userId` are the only required
12
+ * fields; `deviceId` / `deviceSecret` (the mint credential) and
13
+ * `accessToken` / `expiresAt` (warm-boot) are all optional round-trip fields.
14
+ */
11
15
  const SAMPLE: PersistedAuthState = {
12
16
  sessionId: 's-1',
13
- refreshToken: 'r-abcdefghijklmnop',
14
17
  userId: 'u-1',
15
- deviceToken: 'd-1234567890',
16
18
  accessToken: 'a-jwt',
17
19
  expiresAt: '2030-01-01T00:00:00.000Z',
18
20
  };
@@ -65,7 +67,7 @@ describe('createWebAuthStateStore', () => {
65
67
  expect(await store.load()).toEqual(SAMPLE);
66
68
  });
67
69
 
68
- it('round-trips the optional phase-2c deviceId + deviceSecret', async () => {
70
+ it('round-trips the optional deviceId + deviceSecret mint credential', async () => {
69
71
  installLocalStorage(makeFakeStorage());
70
72
  const store = createWebAuthStateStore();
71
73
  const withCreds: PersistedAuthState = { ...SAMPLE, deviceId: 'dev-abc', deviceSecret: 'ds-secret-xyz' };
@@ -77,53 +79,47 @@ describe('createWebAuthStateStore', () => {
77
79
  expect(loaded).toEqual(withCreds);
78
80
  });
79
81
 
80
- it('deserializes a legacy blob with no device credentials (additive — fields absent)', async () => {
82
+ it('deserializes a minimal blob with no device credentials (fields absent)', async () => {
81
83
  const storage = makeFakeStorage();
82
84
  installLocalStorage(storage);
83
85
  const store = createWebAuthStateStore();
84
86
 
85
- storage.setItem(
86
- AUTH_STATE_STORAGE_KEY,
87
- JSON.stringify({ sessionId: 's-1', refreshToken: 'r-abcdefghijklmnop', userId: 'u-1' }),
88
- );
87
+ storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify({ sessionId: 's-1', userId: 'u-1' }));
89
88
  const loaded = await store.load();
90
- expect(loaded).not.toBeNull();
89
+ expect(loaded).toEqual({ sessionId: 's-1', userId: 'u-1' });
91
90
  expect(loaded && 'deviceId' in loaded).toBe(false);
92
91
  expect(loaded && 'deviceSecret' in loaded).toBe(false);
92
+ expect(loaded && 'accessToken' in loaded).toBe(false);
93
93
  });
94
94
 
95
- it('clear() wipes the session but the deviceToken survives', async () => {
95
+ it('clear() wipes the persisted session', async () => {
96
96
  installLocalStorage(makeFakeStorage());
97
97
  const store = createWebAuthStateStore();
98
98
 
99
- await store.save(SAMPLE);
100
- await store.saveDeviceToken('device-token-xyz');
99
+ await store.save({ ...SAMPLE, deviceId: 'dev-abc', deviceSecret: 'ds-secret-xyz' });
101
100
  await store.clear();
102
101
 
103
102
  expect(await store.load()).toBeNull();
104
- expect(await store.loadDeviceToken()).toBe('device-token-xyz');
105
103
  });
106
104
 
107
- it('persists the deviceToken under a separate long-lived key', async () => {
105
+ it('returns null for malformed JSON or a blob missing a required field', async () => {
108
106
  const storage = makeFakeStorage();
109
107
  installLocalStorage(storage);
110
108
  const store = createWebAuthStateStore();
111
109
 
112
- await store.saveDeviceToken('dt');
113
- expect(storage.getItem(DEVICE_TOKEN_STORAGE_KEY)).toBe('dt');
114
- await store.clearDeviceToken();
115
- expect(await store.loadDeviceToken()).toBeNull();
116
- });
110
+ storage.setItem(AUTH_STATE_STORAGE_KEY, 'not-json');
111
+ expect(await store.load()).toBeNull();
117
112
 
118
- it('returns null for a malformed or incomplete blob', async () => {
119
- const storage = makeFakeStorage();
120
- installLocalStorage(storage);
121
- const store = createWebAuthStateStore();
113
+ // Missing userId invalid.
114
+ storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify({ sessionId: 's' }));
115
+ expect(await store.load()).toBeNull();
122
116
 
123
- storage.setItem(AUTH_STATE_STORAGE_KEY, 'not-json');
117
+ // Missing sessionId → invalid.
118
+ storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify({ userId: 'u' }));
124
119
  expect(await store.load()).toBeNull();
125
120
 
126
- storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify({ sessionId: 's', userId: 'u' }));
121
+ // Empty required strings invalid.
122
+ storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify({ sessionId: '', userId: '' }));
127
123
  expect(await store.load()).toBeNull();
128
124
  });
129
125
 
@@ -141,23 +137,16 @@ describe('createWebAuthStateStore', () => {
141
137
  expect(await store.load()).toEqual(SAMPLE);
142
138
  });
143
139
 
144
- it('swallows a write that throws (quota / private mode) without rejecting', async () => {
140
+ it('swallows a write that throws (quota / private mode) without rejecting, and the mirror keeps the session live', async () => {
145
141
  installLocalStorage(makeFakeStorage({ throwOnSet: true }));
146
142
  const store = createWebAuthStateStore();
143
+ const withCreds: PersistedAuthState = { ...SAMPLE, deviceId: 'dev-abc', deviceSecret: 'ds-secret-xyz' };
147
144
 
148
- await expect(store.save(SAMPLE)).resolves.toBeUndefined();
145
+ await expect(store.save(withCreds)).resolves.toBeUndefined();
149
146
  // The write never reached storage...
150
147
  expect(localStorage.getItem(AUTH_STATE_STORAGE_KEY)).toBeNull();
151
- // ...but the in-memory mirror keeps the session live for this page's lifetime.
152
- expect(await store.load()).toEqual(SAMPLE);
153
- });
154
-
155
- it('the mirror keeps the deviceToken live when the write throws', async () => {
156
- installLocalStorage(makeFakeStorage({ throwOnSet: true }));
157
- const store = createWebAuthStateStore();
158
-
159
- await store.saveDeviceToken('dt-mirrored');
160
- expect(await store.loadDeviceToken()).toBe('dt-mirrored');
148
+ // ...but the in-memory mirror keeps the session (incl. the mint credential) live.
149
+ expect(await store.load()).toEqual(withCreds);
161
150
  });
162
151
 
163
152
  it('a cleared session reads null even if storage later holds a stale blob (mirror wins)', async () => {
@@ -198,13 +187,11 @@ describe('createNativeAuthStateStore', () => {
198
187
  expect(await store.load()).toEqual(SAMPLE);
199
188
  });
200
189
 
201
- it('deviceToken survives a session clear', async () => {
190
+ it('clear() wipes the persisted session', async () => {
202
191
  const store = createNativeAuthStateStore(makeNativeStorage());
203
- await store.save(SAMPLE);
204
- await store.saveDeviceToken('dt-native');
192
+ await store.save({ ...SAMPLE, deviceId: 'dev-1', deviceSecret: 'ds-1' });
205
193
  await store.clear();
206
194
  expect(await store.load()).toBeNull();
207
- expect(await store.loadDeviceToken()).toBe('dt-native');
208
195
  });
209
196
 
210
197
  it('keeps the session live via the mirror when the injected storage throws (locked keychain)', async () => {