@oxyhq/core 9.2.0 → 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.
Files changed (40) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +13 -0
  3. package/dist/cjs/index.js +6 -4
  4. package/dist/cjs/mixins/OxyServices.accounts.js +3 -0
  5. package/dist/cjs/mixins/OxyServices.utility.js +9 -5
  6. package/dist/cjs/session/SessionClient.js +45 -0
  7. package/dist/cjs/session/accountDialogController.js +31 -0
  8. package/dist/cjs/session/authStateStore.js +196 -16
  9. package/dist/esm/.tsbuildinfo +1 -1
  10. package/dist/esm/boot/sessionColdBoot.js +13 -0
  11. package/dist/esm/index.js +1 -0
  12. package/dist/esm/mixins/OxyServices.accounts.js +1 -0
  13. package/dist/esm/mixins/OxyServices.utility.js +9 -5
  14. package/dist/esm/session/SessionClient.js +45 -0
  15. package/dist/esm/session/accountDialogController.js +31 -0
  16. package/dist/esm/session/authStateStore.js +195 -15
  17. package/dist/types/.tsbuildinfo +1 -1
  18. package/dist/types/index.d.ts +2 -1
  19. package/dist/types/mixins/OxyServices.accounts.d.ts +8 -0
  20. package/dist/types/mixins/OxyServices.auth.d.ts +1 -0
  21. package/dist/types/models/interfaces.d.ts +3 -1
  22. package/dist/types/models/session.d.ts +6 -0
  23. package/dist/types/session/SessionClient.d.ts +11 -0
  24. package/dist/types/session/accountDialogController.d.ts +17 -0
  25. package/dist/types/session/authStateStore.d.ts +33 -8
  26. package/package.json +2 -2
  27. package/src/boot/__tests__/sessionColdBoot.test.ts +20 -0
  28. package/src/boot/sessionColdBoot.ts +13 -0
  29. package/src/index.ts +3 -0
  30. package/src/mixins/OxyServices.accounts.ts +9 -0
  31. package/src/mixins/OxyServices.auth.ts +2 -0
  32. package/src/mixins/OxyServices.utility.ts +10 -9
  33. package/src/models/interfaces.ts +3 -1
  34. package/src/models/session.ts +6 -0
  35. package/src/session/SessionClient.ts +44 -0
  36. package/src/session/__tests__/SessionClient.serverEvents.test.ts +71 -0
  37. package/src/session/__tests__/accountDialogController.test.ts +93 -1
  38. package/src/session/__tests__/authStateStore.test.ts +170 -0
  39. package/src/session/accountDialogController.ts +54 -1
  40. package/src/session/authStateStore.ts +219 -15
@@ -461,6 +461,7 @@ describe('AccountDialogController — sign in with Oxy', () => {
461
461
  accessToken: 'access-1',
462
462
  sessionId: 'sess-1',
463
463
  deviceId: 'device-1',
464
+ deviceSecret: 'claimed-secret',
464
465
  expiresAt: '2030-01-01T00:00:00Z',
465
466
  user: user('a1'),
466
467
  });
@@ -473,7 +474,13 @@ describe('AccountDialogController — sign in with Oxy', () => {
473
474
 
474
475
  await jest.advanceTimersByTimeAsync(1000); // second poll → authorized → claim
475
476
  expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok');
476
- expect(commitSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'sess-1', accessToken: 'access-1' }));
477
+ expect(commitSession).toHaveBeenCalledWith(
478
+ expect.objectContaining({
479
+ sessionId: 'sess-1',
480
+ accessToken: 'access-1',
481
+ deviceSecret: 'claimed-secret',
482
+ }),
483
+ );
477
484
  expect(onSignedIn).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' }));
478
485
  expect(controller.getSnapshot().view).toBe('accounts');
479
486
  } finally {
@@ -533,6 +540,91 @@ describe('AccountDialogController — sign in with Oxy', () => {
533
540
  });
534
541
  });
535
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
+
536
628
  describe('AccountDialogController — openPasswordAtOxyAuth', () => {
537
629
  beforeEach(() => {
538
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', () => {
@@ -131,10 +131,27 @@ export interface AccountDialogControllerOptions {
131
131
  * `Linking.openURL`). Headless core never touches `window`/`Linking` itself.
132
132
  */
133
133
  openUrl?: (url: string) => void;
134
+ /**
135
+ * Optional "can this app open this URL scheme?" probe, symmetric to
136
+ * {@link openUrl}. When provided, `showQr` uses it to detect an installed
137
+ * Commons (`oxycommons://`) and, if present, deep-links straight into its
138
+ * approve screen via {@link openUrl} — while KEEPING the QR/polling active as
139
+ * the fallback. Injected by the provider (native: `Linking.canOpenURL`; web:
140
+ * absent/false). Headless core never touches `Linking` itself; when absent
141
+ * `showQr` behaves exactly as before (render QR only).
142
+ */
143
+ canOpenApp?: (url: string) => Promise<boolean>;
134
144
  }
135
145
 
136
146
  const DEFAULT_POLL_INTERVAL_MS = 3000;
137
147
 
148
+ /**
149
+ * Commons's custom URL scheme. Probed via the injected `canOpenApp` to detect an
150
+ * installed Commons on the same device; the `oxycommons://approve?...` deep link
151
+ * itself is the flow's `qrPayload`.
152
+ */
153
+ const COMMONS_APP_SCHEME = 'oxycommons://';
154
+
138
155
  const IDLE_SIGN_IN: SignInFlowState = {
139
156
  phase: 'idle',
140
157
  authorizeCode: null,
@@ -160,6 +177,7 @@ export class AccountDialogController {
160
177
  private readonly authRedirectUri: string | null;
161
178
  private readonly pollIntervalMs: number;
162
179
  private readonly openUrl?: (url: string) => void;
180
+ private readonly canOpenApp?: (url: string) => Promise<boolean>;
163
181
 
164
182
  private readonly listeners = new Set<SnapshotListener>();
165
183
 
@@ -197,6 +215,7 @@ export class AccountDialogController {
197
215
  this.authRedirectUri = options.authRedirectUri ?? null;
198
216
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
199
217
  this.openUrl = options.openUrl;
218
+ this.canOpenApp = options.canOpenApp;
200
219
  this.snapshot = this.computeSnapshot();
201
220
  }
202
221
 
@@ -534,11 +553,37 @@ export class AccountDialogController {
534
553
  error: null,
535
554
  });
536
555
  this.scheduleNextPoll(handle.sessionToken);
556
+ // Same-device convenience: if Commons is installed (native only — `canOpenApp`
557
+ // is undefined/false on web), deep-link straight into its approve screen with
558
+ // the same `oxycommons://approve?...` payload the QR encodes. The QR + polling
559
+ // stay live as the fallback, so a user who dismisses the app-open still
560
+ // completes the sign-in by scanning.
561
+ void this.maybeOpenCommons(handle.qrPayload);
537
562
  } catch (error) {
538
563
  this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: errorMessage(error) });
539
564
  }
540
565
  }
541
566
 
567
+ /**
568
+ * When a `canOpenApp` probe is injected and reports Commons installed, open the
569
+ * approve deep link via the injected `openUrl`. Best-effort and non-blocking: a
570
+ * probe/open failure is logged and swallowed — the QR/polling fallback remains.
571
+ */
572
+ private async maybeOpenCommons(qrPayload: string): Promise<void> {
573
+ if (!this.canOpenApp || !this.openUrl) return;
574
+ try {
575
+ if (await this.canOpenApp(COMMONS_APP_SCHEME)) {
576
+ this.openUrl(qrPayload);
577
+ }
578
+ } catch (error) {
579
+ logger.debug(
580
+ '[AccountDialogController] Commons deep-link probe failed (QR fallback active)',
581
+ { component: 'AccountDialogController' },
582
+ error,
583
+ );
584
+ }
585
+ }
586
+
542
587
  /** Tear down the active sign-in device flow (timers + token) and reset to idle. */
543
588
  cancelSignIn(): void {
544
589
  this.clearPollTimer();
@@ -638,7 +683,14 @@ export class AccountDialogController {
638
683
 
639
684
  private async claimAndComplete(sessionId: string, sessionToken: string): Promise<void> {
640
685
  this.setSignIn({ ...this.signIn, phase: 'authorized' });
641
- let claimed: { accessToken: string; sessionId: string; deviceId: string; expiresAt: string; user: User };
686
+ let claimed: {
687
+ accessToken: string;
688
+ sessionId: string;
689
+ deviceId: string;
690
+ expiresAt: string;
691
+ user: User;
692
+ deviceSecret?: string;
693
+ };
642
694
  try {
643
695
  claimed = await this.oxyServices.claimSessionByToken(sessionToken);
644
696
  } catch (error) {
@@ -666,6 +718,7 @@ export class AccountDialogController {
666
718
  expiresAt: claimed.expiresAt ?? '',
667
719
  user: minimalUser,
668
720
  accessToken: claimed.accessToken,
721
+ ...(claimed.deviceSecret ? { deviceSecret: claimed.deviceSecret } : {}),
669
722
  },
670
723
  minimalUser,
671
724
  );