@oxyhq/core 10.1.4 → 10.1.5

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.
@@ -311,6 +311,9 @@ class SessionClient {
311
311
  });
312
312
  }
313
313
  });
314
+ socket.on(contracts_1.SESSION_ACCOUNTS_CHANGED_EVENT, (payload) => {
315
+ this.onSessionAccountsChanged(payload);
316
+ });
314
317
  this.socket = socket;
315
318
  // (Re)bind app-facing server-event subscriptions on the fresh socket.
316
319
  this.boundServerEvents.clear();
@@ -318,6 +321,38 @@ class SessionClient {
318
321
  this.bindServerEvent(event);
319
322
  }
320
323
  }
324
+ /**
325
+ * Handle the token-free `session_accounts_changed` signal (room `user:<userId>`).
326
+ *
327
+ * Unlike `session_state` (device-scoped, carries the new state to APPLY), this
328
+ * reaches ALL of a user's connected sockets across their devices/origins and is
329
+ * a pure SIGNAL: it carries no token, no secret, and no account bodies. The only
330
+ * trustworthy bit is "something changed for this user", so — matching the
331
+ * `session_state` contract's guidance — we re-fetch our OWN authoritative device
332
+ * state (`bootstrap` → `GET /session/device/state`) and let the existing
333
+ * `applyState` revision guard reconcile it. We never trust any field on the event
334
+ * beyond routing it to the current user.
335
+ *
336
+ * The refetch is a private (bearer) call: the socket only joins `user:<userId>`
337
+ * when authenticated, so a signed-out client never receives this — but we guard
338
+ * the bearer anyway so a race at sign-out can't 401.
339
+ */
340
+ onSessionAccountsChanged(payload) {
341
+ const event = (0, contracts_1.safeParseContract)(contracts_1.sessionAccountsChangedEventSchema, payload);
342
+ if (!event) {
343
+ loggerUtils_1.logger.warn('[SessionClient] discarded invalid session_accounts_changed', { component: 'SessionClient' });
344
+ return;
345
+ }
346
+ // The socket is in `user:<activeUserId>` for the planted bearer, so this should
347
+ // always be the current user; ignore a foreign id defensively (out-of-band relay).
348
+ if (event.userId !== this.host.getCurrentAccountId())
349
+ return;
350
+ if (!this.host.getAccessToken())
351
+ return;
352
+ void this.bootstrap().catch((error) => {
353
+ loggerUtils_1.logger.warn('[SessionClient] session_accounts_changed refetch failed', { component: 'SessionClient' }, error);
354
+ });
355
+ }
321
356
  /**
322
357
  * Open the same-origin `BroadcastChannel` (web only). A sibling tab that
323
358
  * commits an account switch / sign-out posts a wake ping; on receipt an
@@ -34,7 +34,15 @@ const errorUtils_1 = require("../utils/errorUtils");
34
34
  const authWebUrl_1 = require("../utils/authWebUrl");
35
35
  const oauthPkce_1 = require("../utils/oauthPkce");
36
36
  const accountProjection_1 = require("./accountProjection");
37
- const DEFAULT_POLL_INTERVAL_MS = 3000;
37
+ /**
38
+ * Slow FALLBACK poll cadence for the QR flow. The `/auth-session` socket delivers
39
+ * the approval instantly via `auth_update`; this poll only covers the case where
40
+ * the socket can't connect, so it is deliberately slow (was 3000 when polling was
41
+ * the sole mechanism).
42
+ */
43
+ const DEFAULT_POLL_INTERVAL_MS = 12000;
44
+ /** Socket.IO namespace the API emits QR-flow approval (`auth_update`) events on. */
45
+ const AUTH_SESSION_NAMESPACE = '/auth-session';
38
46
  /**
39
47
  * Commons's custom URL scheme. Probed via the injected `canOpenApp` to detect an
40
48
  * installed Commons on the same device; the `oxycommons://approve?...` deep link
@@ -66,6 +74,18 @@ class AccountDialogController {
66
74
  /** The secret device-flow token of the active QR flow (never surfaced). */
67
75
  this.signInToken = null;
68
76
  this.pollTimer = null;
77
+ /**
78
+ * The `/auth-session` socket for the active QR flow, or null (poll-only). Its
79
+ * `auth_update` event wakes {@link pollOnce} instantly instead of waiting for the
80
+ * slow fallback timer.
81
+ */
82
+ this.authSessionSocket = null;
83
+ /**
84
+ * Guards {@link pollOnce} against re-entrancy: the fallback timer and a socket
85
+ * `auth_update` wake can fire together — without this both could claim the
86
+ * single-use token concurrently.
87
+ */
88
+ this.pollInFlight = false;
69
89
  // --- Store plumbing ---
70
90
  this.unsubscribeSession = null;
71
91
  this.unsubscribeTokens = null;
@@ -84,6 +104,7 @@ class AccountDialogController {
84
104
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
85
105
  this.openUrl = options.openUrl;
86
106
  this.canOpenApp = options.canOpenApp;
107
+ this.socketFactory = options.socketFactory;
87
108
  this.snapshot = this.computeSnapshot();
88
109
  }
89
110
  // =========================================================================
@@ -151,6 +172,8 @@ class AccountDialogController {
151
172
  this.unsubscribeTokens = null;
152
173
  }
153
174
  this.clearPollTimer();
175
+ this.closeAuthSessionSocket();
176
+ this.signInToken = null;
154
177
  this.listeners.clear();
155
178
  }
156
179
  // =========================================================================
@@ -424,6 +447,10 @@ class AccountDialogController {
424
447
  expiresAt: handle.expiresAt,
425
448
  error: null,
426
449
  });
450
+ // Primary path: an instant `auth_update` wake over the `/auth-session`
451
+ // socket. The poll below is only the fallback for when the socket can't
452
+ // connect, so it now runs at the slow fallback cadence.
453
+ this.openAuthSessionSocket(handle.sessionToken);
427
454
  this.scheduleNextPoll(handle.sessionToken);
428
455
  // Same-device convenience: if Commons is installed (native only — `canOpenApp`
429
456
  // is undefined/false on web), deep-link straight into its approve screen with
@@ -453,9 +480,10 @@ class AccountDialogController {
453
480
  loggerUtils_1.logger.debug('[AccountDialogController] Commons deep-link probe failed (QR fallback active)', { component: 'AccountDialogController' }, error);
454
481
  }
455
482
  }
456
- /** Tear down the active sign-in device flow (timers + token) and reset to idle. */
483
+ /** Tear down the active sign-in device flow (timers + socket + token) and reset to idle. */
457
484
  cancelSignIn() {
458
485
  this.clearPollTimer();
486
+ this.closeAuthSessionSocket();
459
487
  this.signInToken = null;
460
488
  if (this.signIn !== IDLE_SIGN_IN) {
461
489
  this.setSignIn(IDLE_SIGN_IN);
@@ -510,39 +538,52 @@ class AccountDialogController {
510
538
  void this.pollOnce(sessionToken);
511
539
  }, this.pollIntervalMs);
512
540
  }
541
+ /**
542
+ * Run one status check + (on approval) claim. Triggered by the fallback timer
543
+ * AND by the `/auth-session` socket's `auth_update` wake, so it is guarded
544
+ * against concurrent entry: whichever fires first claims the single-use token;
545
+ * the other no-ops. The `auth_update` payload is never trusted — this always
546
+ * re-checks the authoritative status via `pollCommonsSignIn`.
547
+ */
513
548
  async pollOnce(sessionToken) {
514
- // A superseded / cancelled flow must not act.
515
- if (this.signInToken !== sessionToken)
516
- return;
517
- const expiresAt = this.signIn.expiresAt;
518
- if (typeof expiresAt === 'number' && Date.now() > expiresAt) {
519
- this.failSignIn('Session expired. Please try again.');
549
+ // A superseded / cancelled flow must not act; a poll already running owns the claim.
550
+ if (this.signInToken !== sessionToken || this.pollInFlight)
520
551
  return;
521
- }
552
+ this.pollInFlight = true;
522
553
  try {
523
- const status = await this.oxyServices.pollCommonsSignIn(sessionToken);
524
- if (this.signInToken !== sessionToken)
525
- return; // cancelled mid-request
526
- if (status.authorized && status.sessionId) {
527
- this.clearPollTimer();
528
- await this.claimAndComplete(status.sessionId, sessionToken);
554
+ const expiresAt = this.signIn.expiresAt;
555
+ if (typeof expiresAt === 'number' && Date.now() > expiresAt) {
556
+ this.failSignIn('Session expired. Please try again.');
529
557
  return;
530
558
  }
531
- if (status.status === 'cancelled') {
532
- this.failSignIn('Authorization was denied.');
533
- return;
559
+ try {
560
+ const status = await this.oxyServices.pollCommonsSignIn(sessionToken);
561
+ if (this.signInToken !== sessionToken)
562
+ return; // cancelled mid-request
563
+ if (status.authorized && status.sessionId) {
564
+ this.clearPollTimer();
565
+ await this.claimAndComplete(status.sessionId, sessionToken);
566
+ return;
567
+ }
568
+ if (status.status === 'cancelled') {
569
+ this.failSignIn('Authorization was denied.');
570
+ return;
571
+ }
572
+ if (status.status === 'expired') {
573
+ this.failSignIn('Session expired. Please try again.');
574
+ return;
575
+ }
534
576
  }
535
- if (status.status === 'expired') {
536
- this.failSignIn('Session expired. Please try again.');
537
- return;
577
+ catch (error) {
578
+ // Transient poll error — the next tick retries. Logged, never thrown.
579
+ loggerUtils_1.logger.debug('[AccountDialogController] poll error (will retry)', { component: 'AccountDialogController' }, error);
580
+ }
581
+ if (this.signInToken === sessionToken) {
582
+ this.scheduleNextPoll(sessionToken);
538
583
  }
539
584
  }
540
- catch (error) {
541
- // Transient poll error — the next tick retries. Logged, never thrown.
542
- loggerUtils_1.logger.debug('[AccountDialogController] poll error (will retry)', { component: 'AccountDialogController' }, error);
543
- }
544
- if (this.signInToken === sessionToken) {
545
- this.scheduleNextPoll(sessionToken);
585
+ finally {
586
+ this.pollInFlight = false;
546
587
  }
547
588
  }
548
589
  async claimAndComplete(sessionId, sessionToken) {
@@ -590,6 +631,7 @@ class AccountDialogController {
590
631
  await this.commitAuthorizedSession(session, user);
591
632
  this.signInToken = null;
592
633
  this.clearPollTimer();
634
+ this.closeAuthSessionSocket();
593
635
  this.signIn = IDLE_SIGN_IN;
594
636
  this.view = 'accounts';
595
637
  this.emit();
@@ -611,6 +653,7 @@ class AccountDialogController {
611
653
  }
612
654
  failSignIn(message) {
613
655
  this.clearPollTimer();
656
+ this.closeAuthSessionSocket();
614
657
  this.signInToken = null;
615
658
  this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: message });
616
659
  }
@@ -621,6 +664,77 @@ class AccountDialogController {
621
664
  }
622
665
  }
623
666
  // =========================================================================
667
+ // /auth-session socket (instant QR approval wake — replaces 3s polling)
668
+ // =========================================================================
669
+ /**
670
+ * Subscribe the active QR flow to the `/auth-session` namespace so the API's
671
+ * `auth_update` event wakes {@link pollOnce} the instant the approval lands.
672
+ *
673
+ * The join is keyed by the secret `sessionToken` (the server's `auth:<token>`
674
+ * room, joined by emitting `join`) and re-issued on every (re)connect so it
675
+ * survives socket drops. `auth_update` is treated as a pure SIGNAL — the payload
676
+ * is never trusted; `pollOnce` re-checks the authoritative status and claims.
677
+ *
678
+ * No-op (poll-only) when no `socketFactory` was injected (web without a bundled
679
+ * `io`, headless/core usage, tests). The namespace needs no auth.
680
+ */
681
+ openAuthSessionSocket(sessionToken) {
682
+ this.closeAuthSessionSocket();
683
+ if (!this.socketFactory)
684
+ return;
685
+ let socket;
686
+ try {
687
+ socket = this.socketFactory(`${this.oxyServices.getBaseURL()}${AUTH_SESSION_NAMESPACE}`, {
688
+ transports: ['websocket'],
689
+ autoConnect: true,
690
+ reconnection: true,
691
+ reconnectionAttempts: Number.POSITIVE_INFINITY,
692
+ reconnectionDelay: 1000,
693
+ reconnectionDelayMax: 10000,
694
+ });
695
+ }
696
+ catch (error) {
697
+ // Socket unavailable — the fallback poll still completes the flow.
698
+ loggerUtils_1.logger.debug('[AccountDialogController] auth-session socket create failed (poll fallback)', { component: 'AccountDialogController' }, error);
699
+ return;
700
+ }
701
+ const join = () => {
702
+ if (this.signInToken !== sessionToken)
703
+ return;
704
+ try {
705
+ socket.emit('join', sessionToken);
706
+ }
707
+ catch (error) {
708
+ loggerUtils_1.logger.debug('[AccountDialogController] auth-session join failed', { component: 'AccountDialogController' }, error);
709
+ }
710
+ };
711
+ socket.on('connect', join);
712
+ if (socket.connected)
713
+ join();
714
+ socket.on('auth_update', () => {
715
+ if (this.signInToken !== sessionToken)
716
+ return;
717
+ // Pure wake signal — re-check the authoritative status + claim the poll would have.
718
+ void this.pollOnce(sessionToken);
719
+ });
720
+ this.authSessionSocket = socket;
721
+ }
722
+ /** Tear down the `/auth-session` socket, if any. Idempotent. */
723
+ closeAuthSessionSocket() {
724
+ const socket = this.authSessionSocket;
725
+ if (!socket)
726
+ return;
727
+ this.authSessionSocket = null;
728
+ try {
729
+ socket.off('auth_update');
730
+ socket.off('connect');
731
+ socket.disconnect();
732
+ }
733
+ catch (error) {
734
+ loggerUtils_1.logger.debug('[AccountDialogController] auth-session socket close failed', { component: 'AccountDialogController' }, error);
735
+ }
736
+ }
737
+ // =========================================================================
624
738
  // Snapshot plumbing
625
739
  // =========================================================================
626
740
  setSignIn(next) {