@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
|
@@ -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
|
-
|
|
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
|
|
524
|
-
if (
|
|
525
|
-
|
|
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
|
-
|
|
532
|
-
this.
|
|
533
|
-
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
541
|
-
|
|
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) {
|