@oxyhq/core 10.1.4 → 10.2.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.
@@ -4,6 +4,7 @@ import type { User } from '../../models/interfaces';
4
4
  import type { SessionLoginResponse, MinimalUserData } from '../../models/session';
5
5
  import type { AccountNode } from '../../mixins/OxyServices.accounts';
6
6
  import { SessionClient, type SessionClientHost } from '../SessionClient';
7
+ import type { MinimalSocket, SocketIOFactory } from '../socketLoader';
7
8
  import { logger } from '../../utils/loggerUtils';
8
9
  import {
9
10
  AccountDialogController,
@@ -68,6 +69,7 @@ function graphNode(id: string, over: Partial<AccountNode> = {}): AccountNode {
68
69
 
69
70
  interface OxyMock {
70
71
  getAccessToken: jest.Mock;
72
+ getBaseURL: jest.Mock;
71
73
  onTokensChanged: jest.Mock;
72
74
  listAccounts: jest.Mock;
73
75
  getUsersByIds: jest.Mock;
@@ -91,6 +93,7 @@ function makeOxy(): OxyMock {
91
93
  let currentToken: string | null = 'access-token';
92
94
  return {
93
95
  getAccessToken: jest.fn(() => currentToken),
96
+ getBaseURL: jest.fn(() => 'http://test.invalid'),
94
97
  onTokensChanged: jest.fn((listener: (token: string | null) => void) => {
95
98
  tokenListeners.add(listener);
96
99
  return () => tokenListeners.delete(listener);
@@ -727,6 +730,110 @@ describe('AccountDialogController — openPasswordAtOxyAuth', () => {
727
730
  });
728
731
  });
729
732
 
733
+ describe('AccountDialogController — /auth-session socket (instant QR wake)', () => {
734
+ type Handler = (...args: unknown[]) => void;
735
+ class FakeAuthSocket implements MinimalSocket {
736
+ connected = false;
737
+ disconnected = false;
738
+ handlers = new Map<string, Handler[]>();
739
+ emitted: Array<{ event: string; args: unknown[] }> = [];
740
+ on(event: string, cb: Handler) { const l = this.handlers.get(event) ?? []; l.push(cb); this.handlers.set(event, l); }
741
+ off(event: string, cb?: Handler) { if (!cb) { this.handlers.delete(event); return; } this.handlers.set(event, (this.handlers.get(event) ?? []).filter((h) => h !== cb)); }
742
+ emit(event: string, ...args: unknown[]) { this.emitted.push({ event, args }); }
743
+ connect() { this.connected = true; }
744
+ disconnect() { this.connected = false; this.disconnected = true; }
745
+ /** Simulate a server→client push on this socket. */
746
+ server(event: string, payload?: unknown) { for (const h of this.handlers.get(event) ?? []) h(payload); }
747
+ }
748
+
749
+ const START_HANDLE = {
750
+ sessionToken: 'secret-tok',
751
+ authorizeCode: 'AUTH-CODE',
752
+ qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
753
+ expiresAt: Date.now() + 600_000,
754
+ status: 'pending' as const,
755
+ };
756
+
757
+ function makeSocketHarness(): { controller: AccountDialogController; oxy: OxyMock; created: () => FakeAuthSocket | null; factory: jest.Mock; commitSession: jest.Mock } {
758
+ const oxy = makeOxy();
759
+ oxy.startCommonsSignIn.mockResolvedValue(START_HANDLE);
760
+ let socket: FakeAuthSocket | null = null;
761
+ const factory = jest.fn((_uri: string, _opts?: Record<string, unknown>): MinimalSocket => {
762
+ socket = new FakeAuthSocket();
763
+ socket.connected = true; // real io autoConnect resolves before we inspect
764
+ return socket;
765
+ });
766
+ const commitSession = jest.fn().mockResolvedValue(undefined);
767
+ const controller = new AccountDialogController({
768
+ oxyServices: oxy as unknown as OxyServices,
769
+ sessionClient: new TestSessionClient(host()),
770
+ clientId: 'oxy_dk_test',
771
+ commitSession,
772
+ socketFactory: factory as unknown as SocketIOFactory,
773
+ });
774
+ return { controller, oxy, created: () => socket, factory, commitSession };
775
+ }
776
+
777
+ it('connects to /auth-session, joins the flow room, and wakes the claim on auth_update — no timer advance', async () => {
778
+ const { controller, oxy, created, factory, commitSession } = makeSocketHarness();
779
+ oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-1', status: 'authorized' });
780
+ oxy.claimSessionByToken.mockResolvedValue({
781
+ accessToken: 'access-1', sessionId: 'sess-1', deviceId: 'device-1', expiresAt: '2030-01-01T00:00:00Z', user: user('a1'),
782
+ });
783
+
784
+ await controller.showQr();
785
+
786
+ expect(factory).toHaveBeenCalledWith('http://test.invalid/auth-session', expect.any(Object));
787
+ const sock = created();
788
+ if (!sock) throw new Error('socket not created');
789
+ expect(sock.emitted).toContainEqual({ event: 'join', args: ['secret-tok'] });
790
+
791
+ // The server pushes auth_update → immediate status check + claim, without any poll timer firing.
792
+ sock.server('auth_update', { status: 'authorized', sessionId: 'sess-1' });
793
+ await flush();
794
+
795
+ expect(oxy.pollCommonsSignIn).toHaveBeenCalledWith('secret-tok');
796
+ expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok');
797
+ expect(commitSession).toHaveBeenCalled();
798
+ expect(controller.getSnapshot().view).toBe('accounts');
799
+ expect(sock.disconnected).toBe(true); // torn down on completion
800
+ });
801
+
802
+ it('re-joins the room on reconnect (connect event) and tears the socket down on cancelSignIn', async () => {
803
+ const { controller, oxy, created } = makeSocketHarness();
804
+ oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
805
+
806
+ await controller.showQr();
807
+ const sock = created();
808
+ if (!sock) throw new Error('socket not created');
809
+ expect(sock.emitted.filter((e) => e.event === 'join')).toHaveLength(1);
810
+
811
+ // A reconnect fires `connect` again → the join is re-issued so it survives drops.
812
+ sock.server('connect');
813
+ expect(sock.emitted.filter((e) => e.event === 'join')).toHaveLength(2);
814
+
815
+ controller.cancelSignIn();
816
+ expect(sock.disconnected).toBe(true);
817
+ });
818
+
819
+ it('a stale auth_update after the flow was cancelled does not re-poll', async () => {
820
+ const { controller, oxy, created } = makeSocketHarness();
821
+ oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
822
+
823
+ await controller.showQr();
824
+ const sock = created();
825
+ if (!sock) throw new Error('socket not created');
826
+ oxy.pollCommonsSignIn.mockClear();
827
+
828
+ controller.cancelSignIn();
829
+ // Even if a late auth_update slips through on the (now-detached) socket, the
830
+ // superseded-token guard drops it.
831
+ sock.server('auth_update', { status: 'authorized' });
832
+ await flush();
833
+ expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
834
+ });
835
+ });
836
+
730
837
  describe('AccountDialogController — lifecycle', () => {
731
838
  it('destroy unsubscribes so later device-state changes do not notify', async () => {
732
839
  const { controller, oxy, sc } = makeHarness();
@@ -39,6 +39,7 @@ import {
39
39
  persistOAuthHandshake,
40
40
  } from '../utils/oauthPkce';
41
41
  import type { SessionClient } from './SessionClient';
42
+ import type { MinimalSocket, SocketIOFactory } from './socketLoader';
42
43
  import {
43
44
  projectSwitchableAccounts,
44
45
  switchableAccountIds,
@@ -124,8 +125,20 @@ export interface AccountDialogControllerOptions {
124
125
  * `location.origin` normalization in {@link openPasswordAtOxyAuth}.
125
126
  */
126
127
  authRedirectUri?: string | null;
127
- /** QR device-flow poll interval in ms (default 3000). */
128
+ /**
129
+ * QR device-flow FALLBACK poll interval in ms (default 12000). The primary
130
+ * approval signal is the `/auth-session` socket's `auth_update` event (instant);
131
+ * this slow poll is only the safety net for when the socket can't connect.
132
+ */
128
133
  pollIntervalMs?: number;
134
+ /**
135
+ * Statically-injected `socket.io-client` factory (its `io` export), same as
136
+ * {@link SessionClient}'s. When provided, the QR flow subscribes to the
137
+ * `/auth-session` namespace for an INSTANT `auth_update` wake instead of relying
138
+ * on the slow fallback poll. Absent on web builds without a bundled `io` and in
139
+ * headless/core usage → the controller silently degrades to poll-only.
140
+ */
141
+ socketFactory?: SocketIOFactory;
129
142
  /**
130
143
  * Optional URL opener. When provided, `openPasswordAtOxyAuth` invokes it with
131
144
  * the built URL in addition to returning it (web: `location.assign`; native:
@@ -144,7 +157,16 @@ export interface AccountDialogControllerOptions {
144
157
  canOpenApp?: (url: string) => Promise<boolean>;
145
158
  }
146
159
 
147
- const DEFAULT_POLL_INTERVAL_MS = 3000;
160
+ /**
161
+ * Slow FALLBACK poll cadence for the QR flow. The `/auth-session` socket delivers
162
+ * the approval instantly via `auth_update`; this poll only covers the case where
163
+ * the socket can't connect, so it is deliberately slow (was 3000 when polling was
164
+ * the sole mechanism).
165
+ */
166
+ const DEFAULT_POLL_INTERVAL_MS = 12000;
167
+
168
+ /** Socket.IO namespace the API emits QR-flow approval (`auth_update`) events on. */
169
+ const AUTH_SESSION_NAMESPACE = '/auth-session';
148
170
 
149
171
  /**
150
172
  * Commons's custom URL scheme. Probed via the injected `canOpenApp` to detect an
@@ -179,6 +201,7 @@ export class AccountDialogController {
179
201
  private readonly pollIntervalMs: number;
180
202
  private readonly openUrl?: (url: string) => void;
181
203
  private readonly canOpenApp?: (url: string) => Promise<boolean>;
204
+ private readonly socketFactory?: SocketIOFactory;
182
205
 
183
206
  private readonly listeners = new Set<SnapshotListener>();
184
207
 
@@ -195,6 +218,18 @@ export class AccountDialogController {
195
218
  /** The secret device-flow token of the active QR flow (never surfaced). */
196
219
  private signInToken: string | null = null;
197
220
  private pollTimer: ReturnType<typeof setTimeout> | null = null;
221
+ /**
222
+ * The `/auth-session` socket for the active QR flow, or null (poll-only). Its
223
+ * `auth_update` event wakes {@link pollOnce} instantly instead of waiting for the
224
+ * slow fallback timer.
225
+ */
226
+ private authSessionSocket: MinimalSocket | null = null;
227
+ /**
228
+ * Guards {@link pollOnce} against re-entrancy: the fallback timer and a socket
229
+ * `auth_update` wake can fire together — without this both could claim the
230
+ * single-use token concurrently.
231
+ */
232
+ private pollInFlight = false;
198
233
 
199
234
  // --- Store plumbing ---
200
235
  private unsubscribeSession: (() => void) | null = null;
@@ -217,6 +252,7 @@ export class AccountDialogController {
217
252
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
218
253
  this.openUrl = options.openUrl;
219
254
  this.canOpenApp = options.canOpenApp;
255
+ this.socketFactory = options.socketFactory;
220
256
  this.snapshot = this.computeSnapshot();
221
257
  }
222
258
 
@@ -289,6 +325,8 @@ export class AccountDialogController {
289
325
  this.unsubscribeTokens = null;
290
326
  }
291
327
  this.clearPollTimer();
328
+ this.closeAuthSessionSocket();
329
+ this.signInToken = null;
292
330
  this.listeners.clear();
293
331
  }
294
332
 
@@ -568,6 +606,10 @@ export class AccountDialogController {
568
606
  expiresAt: handle.expiresAt,
569
607
  error: null,
570
608
  });
609
+ // Primary path: an instant `auth_update` wake over the `/auth-session`
610
+ // socket. The poll below is only the fallback for when the socket can't
611
+ // connect, so it now runs at the slow fallback cadence.
612
+ this.openAuthSessionSocket(handle.sessionToken);
571
613
  this.scheduleNextPoll(handle.sessionToken);
572
614
  // Same-device convenience: if Commons is installed (native only — `canOpenApp`
573
615
  // is undefined/false on web), deep-link straight into its approve screen with
@@ -600,9 +642,10 @@ export class AccountDialogController {
600
642
  }
601
643
  }
602
644
 
603
- /** Tear down the active sign-in device flow (timers + token) and reset to idle. */
645
+ /** Tear down the active sign-in device flow (timers + socket + token) and reset to idle. */
604
646
  cancelSignIn(): void {
605
647
  this.clearPollTimer();
648
+ this.closeAuthSessionSocket();
606
649
  this.signInToken = null;
607
650
  if (this.signIn !== IDLE_SIGN_IN) {
608
651
  this.setSignIn(IDLE_SIGN_IN);
@@ -664,36 +707,48 @@ export class AccountDialogController {
664
707
  }, this.pollIntervalMs);
665
708
  }
666
709
 
710
+ /**
711
+ * Run one status check + (on approval) claim. Triggered by the fallback timer
712
+ * AND by the `/auth-session` socket's `auth_update` wake, so it is guarded
713
+ * against concurrent entry: whichever fires first claims the single-use token;
714
+ * the other no-ops. The `auth_update` payload is never trusted — this always
715
+ * re-checks the authoritative status via `pollCommonsSignIn`.
716
+ */
667
717
  private async pollOnce(sessionToken: string): Promise<void> {
668
- // A superseded / cancelled flow must not act.
669
- if (this.signInToken !== sessionToken) return;
670
- const expiresAt = this.signIn.expiresAt;
671
- if (typeof expiresAt === 'number' && Date.now() > expiresAt) {
672
- this.failSignIn('Session expired. Please try again.');
673
- return;
674
- }
718
+ // A superseded / cancelled flow must not act; a poll already running owns the claim.
719
+ if (this.signInToken !== sessionToken || this.pollInFlight) return;
720
+ this.pollInFlight = true;
675
721
  try {
676
- const status = await this.oxyServices.pollCommonsSignIn(sessionToken);
677
- if (this.signInToken !== sessionToken) return; // cancelled mid-request
678
- if (status.authorized && status.sessionId) {
679
- this.clearPollTimer();
680
- await this.claimAndComplete(status.sessionId, sessionToken);
722
+ const expiresAt = this.signIn.expiresAt;
723
+ if (typeof expiresAt === 'number' && Date.now() > expiresAt) {
724
+ this.failSignIn('Session expired. Please try again.');
681
725
  return;
682
726
  }
683
- if (status.status === 'cancelled') {
684
- this.failSignIn('Authorization was denied.');
685
- return;
727
+ try {
728
+ const status = await this.oxyServices.pollCommonsSignIn(sessionToken);
729
+ if (this.signInToken !== sessionToken) return; // cancelled mid-request
730
+ if (status.authorized && status.sessionId) {
731
+ this.clearPollTimer();
732
+ await this.claimAndComplete(status.sessionId, sessionToken);
733
+ return;
734
+ }
735
+ if (status.status === 'cancelled') {
736
+ this.failSignIn('Authorization was denied.');
737
+ return;
738
+ }
739
+ if (status.status === 'expired') {
740
+ this.failSignIn('Session expired. Please try again.');
741
+ return;
742
+ }
743
+ } catch (error) {
744
+ // Transient poll error — the next tick retries. Logged, never thrown.
745
+ logger.debug('[AccountDialogController] poll error (will retry)', { component: 'AccountDialogController' }, error);
686
746
  }
687
- if (status.status === 'expired') {
688
- this.failSignIn('Session expired. Please try again.');
689
- return;
747
+ if (this.signInToken === sessionToken) {
748
+ this.scheduleNextPoll(sessionToken);
690
749
  }
691
- } catch (error) {
692
- // Transient poll error — the next tick retries. Logged, never thrown.
693
- logger.debug('[AccountDialogController] poll error (will retry)', { component: 'AccountDialogController' }, error);
694
- }
695
- if (this.signInToken === sessionToken) {
696
- this.scheduleNextPoll(sessionToken);
750
+ } finally {
751
+ this.pollInFlight = false;
697
752
  }
698
753
  }
699
754
 
@@ -754,6 +809,7 @@ export class AccountDialogController {
754
809
  await this.commitAuthorizedSession(session, user);
755
810
  this.signInToken = null;
756
811
  this.clearPollTimer();
812
+ this.closeAuthSessionSocket();
757
813
  this.signIn = IDLE_SIGN_IN;
758
814
  this.view = 'accounts';
759
815
  this.emit();
@@ -779,6 +835,7 @@ export class AccountDialogController {
779
835
 
780
836
  private failSignIn(message: string): void {
781
837
  this.clearPollTimer();
838
+ this.closeAuthSessionSocket();
782
839
  this.signInToken = null;
783
840
  this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: message });
784
841
  }
@@ -790,6 +847,72 @@ export class AccountDialogController {
790
847
  }
791
848
  }
792
849
 
850
+ // =========================================================================
851
+ // /auth-session socket (instant QR approval wake — replaces 3s polling)
852
+ // =========================================================================
853
+
854
+ /**
855
+ * Subscribe the active QR flow to the `/auth-session` namespace so the API's
856
+ * `auth_update` event wakes {@link pollOnce} the instant the approval lands.
857
+ *
858
+ * The join is keyed by the secret `sessionToken` (the server's `auth:<token>`
859
+ * room, joined by emitting `join`) and re-issued on every (re)connect so it
860
+ * survives socket drops. `auth_update` is treated as a pure SIGNAL — the payload
861
+ * is never trusted; `pollOnce` re-checks the authoritative status and claims.
862
+ *
863
+ * No-op (poll-only) when no `socketFactory` was injected (web without a bundled
864
+ * `io`, headless/core usage, tests). The namespace needs no auth.
865
+ */
866
+ private openAuthSessionSocket(sessionToken: string): void {
867
+ this.closeAuthSessionSocket();
868
+ if (!this.socketFactory) return;
869
+ let socket: MinimalSocket;
870
+ try {
871
+ socket = this.socketFactory(`${this.oxyServices.getBaseURL()}${AUTH_SESSION_NAMESPACE}`, {
872
+ transports: ['websocket'],
873
+ autoConnect: true,
874
+ reconnection: true,
875
+ reconnectionAttempts: Number.POSITIVE_INFINITY,
876
+ reconnectionDelay: 1000,
877
+ reconnectionDelayMax: 10000,
878
+ });
879
+ } catch (error) {
880
+ // Socket unavailable — the fallback poll still completes the flow.
881
+ logger.debug('[AccountDialogController] auth-session socket create failed (poll fallback)', { component: 'AccountDialogController' }, error);
882
+ return;
883
+ }
884
+ const join = (): void => {
885
+ if (this.signInToken !== sessionToken) return;
886
+ try {
887
+ socket.emit('join', sessionToken);
888
+ } catch (error) {
889
+ logger.debug('[AccountDialogController] auth-session join failed', { component: 'AccountDialogController' }, error);
890
+ }
891
+ };
892
+ socket.on('connect', join);
893
+ if (socket.connected) join();
894
+ socket.on('auth_update', () => {
895
+ if (this.signInToken !== sessionToken) return;
896
+ // Pure wake signal — re-check the authoritative status + claim the poll would have.
897
+ void this.pollOnce(sessionToken);
898
+ });
899
+ this.authSessionSocket = socket;
900
+ }
901
+
902
+ /** Tear down the `/auth-session` socket, if any. Idempotent. */
903
+ private closeAuthSessionSocket(): void {
904
+ const socket = this.authSessionSocket;
905
+ if (!socket) return;
906
+ this.authSessionSocket = null;
907
+ try {
908
+ socket.off('auth_update');
909
+ socket.off('connect');
910
+ socket.disconnect();
911
+ } catch (error) {
912
+ logger.debug('[AccountDialogController] auth-session socket close failed', { component: 'AccountDialogController' }, error);
913
+ }
914
+ }
915
+
793
916
  // =========================================================================
794
917
  // Snapshot plumbing
795
918
  // =========================================================================
@@ -4,6 +4,8 @@ export interface MinimalSocket {
4
4
  connected: boolean;
5
5
  on(event: string, handler: (...args: unknown[]) => void): void;
6
6
  off(event: string, handler?: (...args: unknown[]) => void): void;
7
+ /** Client→server emit (e.g. joining the `/auth-session` room for a QR flow). */
8
+ emit(event: string, ...args: unknown[]): void;
7
9
  connect(): void;
8
10
  disconnect(): void;
9
11
  }