@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/session/SessionClient.js +35 -0
- package/dist/cjs/session/accountDialogController.js +141 -27
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/session/SessionClient.js +36 -1
- package/dist/esm/session/accountDialogController.js +141 -27
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/session/SessionClient.d.ts +17 -0
- package/dist/types/session/accountDialogController.d.ts +50 -2
- package/dist/types/session/socketLoader.d.ts +2 -0
- package/package.json +2 -2
- package/src/session/SessionClient.ts +36 -0
- package/src/session/__tests__/SessionClient.serverEvents.test.ts +1 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +36 -0
- package/src/session/__tests__/SessionClient.socketFactory.test.ts +1 -0
- package/src/session/__tests__/accountDialogController.test.ts +107 -0
- package/src/session/accountDialogController.ts +150 -27
- package/src/session/socketLoader.ts +2 -0
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
677
|
-
if (
|
|
678
|
-
|
|
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
|
-
|
|
684
|
-
this.
|
|
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 (
|
|
688
|
-
this.
|
|
689
|
-
return;
|
|
747
|
+
if (this.signInToken === sessionToken) {
|
|
748
|
+
this.scheduleNextPoll(sessionToken);
|
|
690
749
|
}
|
|
691
|
-
}
|
|
692
|
-
|
|
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
|
}
|