@oxyhq/core 9.2.1 → 9.2.2

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.
@@ -56,9 +56,20 @@ export declare class SessionClient {
56
56
  private started;
57
57
  /** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
58
58
  private channel;
59
+ /** App-facing subscriptions to named server-pushed socket events. */
60
+ private readonly serverEvents;
61
+ /** Event names already bound on the CURRENT socket instance. */
62
+ private readonly boundServerEvents;
59
63
  constructor(host: SessionClientHost, options?: SessionClientOptions);
60
64
  getState(): DeviceSessionState | null;
61
65
  subscribe(listener: StateListener): () => void;
66
+ /**
67
+ * Subscribe to a named server-pushed Socket.IO event (e.g. `civic:attested`).
68
+ * Listeners survive reconnects and socket re-creation; the returned function
69
+ * unsubscribes. Payloads are delivered as-is — callers validate shape.
70
+ */
71
+ onServerEvent(event: string, listener: (payload: unknown) => void): () => void;
72
+ private bindServerEvent;
62
73
  protected notify(): void;
63
74
  /** Validate + last-writer-wins by revision. Returns true if applied. */
64
75
  protected applyState(raw: unknown): boolean;
@@ -111,6 +111,16 @@ export interface AccountDialogControllerOptions {
111
111
  * `Linking.openURL`). Headless core never touches `window`/`Linking` itself.
112
112
  */
113
113
  openUrl?: (url: string) => void;
114
+ /**
115
+ * Optional "can this app open this URL scheme?" probe, symmetric to
116
+ * {@link openUrl}. When provided, `showQr` uses it to detect an installed
117
+ * Commons (`oxycommons://`) and, if present, deep-links straight into its
118
+ * approve screen via {@link openUrl} — while KEEPING the QR/polling active as
119
+ * the fallback. Injected by the provider (native: `Linking.canOpenURL`; web:
120
+ * absent/false). Headless core never touches `Linking` itself; when absent
121
+ * `showQr` behaves exactly as before (render QR only).
122
+ */
123
+ canOpenApp?: (url: string) => Promise<boolean>;
114
124
  }
115
125
  type SnapshotListener = (snapshot: AccountDialogSnapshot) => void;
116
126
  export declare class AccountDialogController {
@@ -124,6 +134,7 @@ export declare class AccountDialogController {
124
134
  private readonly authRedirectUri;
125
135
  private readonly pollIntervalMs;
126
136
  private readonly openUrl?;
137
+ private readonly canOpenApp?;
127
138
  private readonly listeners;
128
139
  private view;
129
140
  private graph;
@@ -232,6 +243,12 @@ export declare class AccountDialogController {
232
243
  * session committed. Requires `clientId`.
233
244
  */
234
245
  showQr(): Promise<void>;
246
+ /**
247
+ * When a `canOpenApp` probe is injected and reports Commons installed, open the
248
+ * approve deep link via the injected `openUrl`. Best-effort and non-blocking: a
249
+ * probe/open failure is logged and swallowed — the QR/polling fallback remains.
250
+ */
251
+ private maybeOpenCommons;
235
252
  /** Tear down the active sign-in device flow (timers + token) and reset to idle. */
236
253
  cancelSignIn(): void;
237
254
  /**
@@ -77,11 +77,32 @@ export interface NativeKeyValueStorage {
77
77
  removeItem(key: string): Promise<void>;
78
78
  }
79
79
  /**
80
- * Versioned storage key. The `.v1` suffix lets a future shape change ship a
81
- * `.v2` key without reading a stale/incompatible `.v1` blob. Distinct from the
82
- * `oxy_shared_*` keychain keys in `KeyManager`, so it never collides.
80
+ * Versioned DURABLE storage key. Holds ONLY the small, re-mint-critical fields
81
+ * (`sessionId`, `userId`, `deviceId`, `deviceSecret`) never the large JWT
82
+ * `accessToken`. Keeping this blob small (<2KB) matters on Android
83
+ * `expo-secure-store`, whose backing store can silently fail to persist an
84
+ * oversize value; bundling the token here previously took the mint credential
85
+ * down with it on every write, losing the session on cold restart.
86
+ *
87
+ * The `.v1` suffix lets a future shape change ship a `.v2` key without reading a
88
+ * stale/incompatible `.v1` blob. Distinct from the `oxy_shared_*` keychain keys
89
+ * in `KeyManager`, so it never collides.
90
+ *
91
+ * BACK-COMPAT: pre-split builds wrote the WHOLE state (including `accessToken` /
92
+ * `expiresAt`) into this single key. `load()` still reads those token fields
93
+ * from here when the warm key ({@link AUTH_STATE_TOKEN_STORAGE_KEY}) is absent,
94
+ * so upgrading users are not signed out; the next `save()` splits them apart.
83
95
  */
84
96
  export declare const AUTH_STATE_STORAGE_KEY = "oxy.auth.v1";
97
+ /**
98
+ * Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
99
+ * `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
100
+ * failure (quota / oversize keychain value) is swallowed because the session is
101
+ * fully re-mintable from the durable `deviceSecret`. Kept separate from
102
+ * {@link AUTH_STATE_STORAGE_KEY} so a failed token write can NEVER abort or
103
+ * corrupt the durable credential write.
104
+ */
105
+ export declare const AUTH_STATE_TOKEN_STORAGE_KEY = "oxy.auth.token.v1";
85
106
  /**
86
107
  * A process-lifetime, in-memory {@link AuthStateStore}. Used directly for
87
108
  * tests/SSR and as the degraded fallback of the web store when `localStorage`
@@ -90,8 +111,9 @@ export declare const AUTH_STATE_STORAGE_KEY = "oxy.auth.v1";
90
111
  */
91
112
  export declare function createMemoryAuthStateStore(): AuthStateStore;
92
113
  /**
93
- * A `localStorage`-backed {@link AuthStateStore} under the versioned
94
- * {@link AUTH_STATE_STORAGE_KEY}.
114
+ * A `localStorage`-backed {@link AuthStateStore} split across the durable
115
+ * {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the best-effort
116
+ * {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token).
95
117
  *
96
118
  * Resilience:
97
119
  * - If `localStorage` is unreachable (sandboxed-iframe `SecurityError`, SSR),
@@ -107,8 +129,11 @@ export declare function createWebAuthStateStore(): AuthStateStore;
107
129
  * A native {@link AuthStateStore} over an injected async key/value store.
108
130
  *
109
131
  * `@oxyhq/core` never imports `expo-secure-store`; `@oxyhq/services` constructs
110
- * the SecureStore-backed adapter and passes it here. Every operation is wrapped
111
- * so a storage exception degrades gracefully (read `null`, write → swallowed)
112
- * exactly like the web store.
132
+ * the SecureStore-backed adapter and passes it here. Persistence is split across
133
+ * the durable {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the
134
+ * best-effort {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token) — the
135
+ * durable write is read-back-verified and its failure surfaced (not swallowed),
136
+ * while the warm-token write and all reads degrade gracefully exactly like the
137
+ * web store.
113
138
  */
114
139
  export declare function createNativeAuthStateStore(storage: NativeKeyValueStorage): AuthStateStore;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "9.2.1",
3
+ "version": "9.2.2",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -94,7 +94,7 @@
94
94
  }
95
95
  },
96
96
  "dependencies": {
97
- "@oxyhq/contracts": "^0.13.1",
97
+ "@oxyhq/contracts": "^0.13.2",
98
98
  "@oxyhq/protocol": "^0.1.3",
99
99
  "bip39": "^3.1.0",
100
100
  "buffer": "^6.0.3",
@@ -5,9 +5,11 @@
5
5
  * and Express.js authentication middleware
6
6
  */
7
7
  import { jwtDecode } from 'jwt-decode';
8
+ import type { LinkPreview } from '@oxyhq/contracts';
8
9
  import type { ApiError, User } from '../models/interfaces';
9
10
  import type { OxyServicesBase } from '../OxyServices.base';
10
11
  import { loadNodeCrypto } from '@oxyhq/protocol';
12
+ import { buildUrl } from '../utils/apiUtils';
11
13
  import { logger } from '../utils/loggerUtils';
12
14
  import { CACHE_TIMES } from './mixinHelpers';
13
15
 
@@ -217,15 +219,14 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
217
219
  image?: string;
218
220
  }> {
219
221
  try {
220
- return await this.makeRequest<{
221
- url: string;
222
- title: string;
223
- description: string;
224
- image?: string;
225
- }>('GET', '/link-metadata', { url }, {
226
- cache: true,
227
- cacheTTL: CACHE_TIMES.EXTRA_LONG,
228
- });
222
+ const path = buildUrl('/links/preview', { url, wait: 1 });
223
+ const preview = await this.makeRequest<LinkPreview>('GET', path, undefined, { cache: false });
224
+ return {
225
+ url: preview.url,
226
+ title: preview.title?.trim() || preview.url.replace(/^https?:\/\//, '').replace(/\/$/, ''),
227
+ description: preview.description?.trim() || 'Link',
228
+ image: preview.image,
229
+ };
229
230
  } catch (error) {
230
231
  throw this.handleError(error);
231
232
  }
@@ -82,6 +82,10 @@ export class SessionClient {
82
82
  private started = false;
83
83
  /** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
84
84
  private channel: SessionBroadcastChannel | null = null;
85
+ /** App-facing subscriptions to named server-pushed socket events. */
86
+ private readonly serverEvents = new Map<string, Set<(payload: unknown) => void>>();
87
+ /** Event names already bound on the CURRENT socket instance. */
88
+ private readonly boundServerEvents = new Set<string>();
85
89
 
86
90
  constructor(
87
91
  protected readonly host: SessionClientHost,
@@ -99,6 +103,40 @@ export class SessionClient {
99
103
  };
100
104
  }
101
105
 
106
+ /**
107
+ * Subscribe to a named server-pushed Socket.IO event (e.g. `civic:attested`).
108
+ * Listeners survive reconnects and socket re-creation; the returned function
109
+ * unsubscribes. Payloads are delivered as-is — callers validate shape.
110
+ */
111
+ onServerEvent(event: string, listener: (payload: unknown) => void): () => void {
112
+ let listeners = this.serverEvents.get(event);
113
+ if (!listeners) {
114
+ listeners = new Set();
115
+ this.serverEvents.set(event, listeners);
116
+ }
117
+ listeners.add(listener);
118
+ this.bindServerEvent(event);
119
+ return () => {
120
+ listeners.delete(listener);
121
+ };
122
+ }
123
+
124
+ private bindServerEvent(event: string): void {
125
+ if (!this.socket || this.boundServerEvents.has(event)) return;
126
+ this.boundServerEvents.add(event);
127
+ this.socket.on(event, (payload: unknown) => {
128
+ const listeners = this.serverEvents.get(event);
129
+ if (!listeners) return;
130
+ for (const listener of [...listeners]) {
131
+ try {
132
+ listener(payload);
133
+ } catch (error) {
134
+ logger.warn('[SessionClient] server-event listener threw', { component: 'SessionClient' }, error);
135
+ }
136
+ }
137
+ });
138
+ }
139
+
102
140
  protected notify(): void {
103
141
  for (const listener of this.listeners) {
104
142
  try {
@@ -287,6 +325,7 @@ export class SessionClient {
287
325
  if (this.socket) {
288
326
  this.socket.disconnect();
289
327
  this.socket = null;
328
+ this.boundServerEvents.clear();
290
329
  }
291
330
  }
292
331
 
@@ -341,6 +380,11 @@ export class SessionClient {
341
380
  }
342
381
  });
343
382
  this.socket = socket;
383
+ // (Re)bind app-facing server-event subscriptions on the fresh socket.
384
+ this.boundServerEvents.clear();
385
+ for (const event of this.serverEvents.keys()) {
386
+ this.bindServerEvent(event);
387
+ }
344
388
  }
345
389
 
346
390
  /**
@@ -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
+ });
@@ -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';
@@ -161,6 +162,103 @@ describe('createWebAuthStateStore', () => {
161
162
  // The authoritative in-memory mirror still reports the cleared state.
162
163
  expect(await store.load()).toBeNull();
163
164
  });
165
+
166
+ it('splits the token into the warm key and keeps the durable blob token-free', async () => {
167
+ const storage = makeFakeStorage();
168
+ installLocalStorage(storage);
169
+ const store = createWebAuthStateStore();
170
+
171
+ await store.save({ ...SAMPLE, deviceId: 'dev-1', deviceSecret: 'ds-1' });
172
+
173
+ // Durable key holds ONLY the small mint-critical fields — never the JWT.
174
+ const durableRaw = storage.getItem(AUTH_STATE_STORAGE_KEY);
175
+ expect(durableRaw).toBeTruthy();
176
+ expect(JSON.parse(durableRaw ?? '{}')).toEqual({
177
+ sessionId: 's-1',
178
+ userId: 'u-1',
179
+ deviceId: 'dev-1',
180
+ deviceSecret: 'ds-1',
181
+ });
182
+
183
+ // Warm key holds ONLY the short-lived token pair.
184
+ const warmRaw = storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY);
185
+ expect(warmRaw).toBeTruthy();
186
+ expect(JSON.parse(warmRaw ?? '{}')).toEqual({
187
+ accessToken: 'a-jwt',
188
+ expiresAt: '2030-01-01T00:00:00.000Z',
189
+ });
190
+
191
+ // A FRESH store (empty mirror) composes both keys back into the same shape.
192
+ expect(await createWebAuthStateStore().load()).toEqual({
193
+ ...SAMPLE,
194
+ deviceId: 'dev-1',
195
+ deviceSecret: 'ds-1',
196
+ });
197
+ });
198
+
199
+ it('persists the durable credential even when the warm-token write fails', async () => {
200
+ const map = new Map<string, string>();
201
+ const storage = {
202
+ getItem: (k: string) => map.get(k) ?? null,
203
+ setItem: (k: string, v: string) => {
204
+ // Simulate the warm token exceeding the store's capacity while the small
205
+ // durable blob writes fine.
206
+ if (k === AUTH_STATE_TOKEN_STORAGE_KEY) {
207
+ throw new DOMException('QuotaExceededError', 'QuotaExceededError');
208
+ }
209
+ map.set(k, v);
210
+ },
211
+ removeItem: (k: string) => {
212
+ map.delete(k);
213
+ },
214
+ clear: () => map.clear(),
215
+ key: (i: number) => Array.from(map.keys())[i] ?? null,
216
+ get length() {
217
+ return map.size;
218
+ },
219
+ } as Storage;
220
+ installLocalStorage(storage);
221
+ const store = createWebAuthStateStore();
222
+
223
+ await expect(
224
+ store.save({ ...SAMPLE, deviceId: 'dev-abc', deviceSecret: 'ds-secret-xyz' }),
225
+ ).resolves.toBeUndefined();
226
+
227
+ // The durable mint credential landed despite the warm-token write throwing.
228
+ const durableRaw = storage.getItem(AUTH_STATE_STORAGE_KEY);
229
+ expect(durableRaw).toBeTruthy();
230
+ const durable = JSON.parse(durableRaw ?? '{}');
231
+ expect(durable.deviceId).toBe('dev-abc');
232
+ expect(durable.deviceSecret).toBe('ds-secret-xyz');
233
+ expect(durable.accessToken).toBeUndefined();
234
+ // The warm-token key never persisted.
235
+ expect(storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY)).toBeNull();
236
+
237
+ // A fresh store restores the mint credential from disk; no warm token survives.
238
+ const loaded = await createWebAuthStateStore().load();
239
+ expect(loaded?.deviceId).toBe('dev-abc');
240
+ expect(loaded?.deviceSecret).toBe('ds-secret-xyz');
241
+ expect(loaded?.accessToken).toBeUndefined();
242
+ });
243
+
244
+ it('load() reads an old combined oxy.auth.v1 blob (pre-split back-compat)', async () => {
245
+ const storage = makeFakeStorage();
246
+ installLocalStorage(storage);
247
+ // A user upgraded from the pre-split build: the WHOLE state (incl. the token)
248
+ // lives in the single durable key; the warm key does not exist yet.
249
+ storage.setItem(
250
+ AUTH_STATE_STORAGE_KEY,
251
+ JSON.stringify({ ...SAMPLE, deviceId: 'dev-old', deviceSecret: 'ds-old' }),
252
+ );
253
+ expect(storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY)).toBeNull();
254
+
255
+ const store = createWebAuthStateStore();
256
+ const loaded = await store.load();
257
+ // The token is read back from the combined blob (no one is logged out).
258
+ expect(loaded).toEqual({ ...SAMPLE, deviceId: 'dev-old', deviceSecret: 'ds-old' });
259
+ expect(loaded?.accessToken).toBe('a-jwt');
260
+ expect(loaded?.expiresAt).toBe('2030-01-01T00:00:00.000Z');
261
+ });
164
262
  });
165
263
 
166
264
  describe('createNativeAuthStateStore', () => {
@@ -210,6 +308,78 @@ describe('createNativeAuthStateStore', () => {
210
308
  // The write threw, but the in-memory mirror preserves the session.
211
309
  expect(await store.load()).toEqual(SAMPLE);
212
310
  });
311
+
312
+ it('persists the durable credential even when the warm-token write fails (oversize SecureStore value)', async () => {
313
+ const map = new Map<string, string>();
314
+ const storage: NativeKeyValueStorage = {
315
+ getItem: async (k) => map.get(k) ?? null,
316
+ // The large JWT exceeds the SecureStore value limit; the small durable blob
317
+ // writes fine.
318
+ setItem: async (k, v) => {
319
+ if (k === AUTH_STATE_TOKEN_STORAGE_KEY) {
320
+ throw new Error('Value too large for SecureStore');
321
+ }
322
+ map.set(k, v);
323
+ },
324
+ removeItem: async (k) => {
325
+ map.delete(k);
326
+ },
327
+ };
328
+ const store = createNativeAuthStateStore(storage);
329
+
330
+ await expect(
331
+ store.save({ ...SAMPLE, deviceId: 'dev-n', deviceSecret: 'ds-n' }),
332
+ ).resolves.toBeUndefined();
333
+
334
+ // The durable mint credential landed to disk.
335
+ expect(map.get(AUTH_STATE_STORAGE_KEY)).toBeTruthy();
336
+ const durable = JSON.parse(map.get(AUTH_STATE_STORAGE_KEY) ?? '{}');
337
+ expect(durable.deviceId).toBe('dev-n');
338
+ expect(durable.deviceSecret).toBe('ds-n');
339
+ expect(durable.accessToken).toBeUndefined();
340
+ expect(map.get(AUTH_STATE_TOKEN_STORAGE_KEY)).toBeUndefined();
341
+
342
+ // A FRESH store (empty mirror) restores the mint credential from disk.
343
+ const loaded = await createNativeAuthStateStore(storage).load();
344
+ expect(loaded?.deviceId).toBe('dev-n');
345
+ expect(loaded?.deviceSecret).toBe('ds-n');
346
+ expect(loaded?.accessToken).toBeUndefined();
347
+ });
348
+
349
+ it('load() reads an old combined blob (pre-split back-compat)', async () => {
350
+ const map = new Map<string, string>();
351
+ const storage: NativeKeyValueStorage = {
352
+ getItem: async (k) => map.get(k) ?? null,
353
+ setItem: async (k, v) => {
354
+ map.set(k, v);
355
+ },
356
+ removeItem: async (k) => {
357
+ map.delete(k);
358
+ },
359
+ };
360
+ // Pre-split combined blob in the single durable key; no warm key.
361
+ map.set(
362
+ AUTH_STATE_STORAGE_KEY,
363
+ JSON.stringify({ ...SAMPLE, deviceId: 'dev-old', deviceSecret: 'ds-old' }),
364
+ );
365
+
366
+ const store = createNativeAuthStateStore(storage);
367
+ expect(await store.load()).toEqual({ ...SAMPLE, deviceId: 'dev-old', deviceSecret: 'ds-old' });
368
+ });
369
+
370
+ it('clear() wipes BOTH the durable and warm keys', async () => {
371
+ const storage = makeNativeStorage();
372
+ const store = createNativeAuthStateStore(storage);
373
+ await store.save({ ...SAMPLE, deviceId: 'dev-1', deviceSecret: 'ds-1' });
374
+ // Both keys were written by the split save.
375
+ expect(storage.map.get(AUTH_STATE_STORAGE_KEY)).toBeTruthy();
376
+ expect(storage.map.get(AUTH_STATE_TOKEN_STORAGE_KEY)).toBeTruthy();
377
+
378
+ await store.clear();
379
+ expect(storage.map.get(AUTH_STATE_STORAGE_KEY)).toBeUndefined();
380
+ expect(storage.map.get(AUTH_STATE_TOKEN_STORAGE_KEY)).toBeUndefined();
381
+ expect(await store.load()).toBeNull();
382
+ });
213
383
  });
214
384
 
215
385
  describe('createMemoryAuthStateStore', () => {