@oxyhq/core 10.1.3 → 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/cjs/utils/textNormalization.js +0 -28
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/session/SessionClient.js +36 -1
- package/dist/esm/session/accountDialogController.js +141 -27
- package/dist/esm/utils/textNormalization.js +0 -28
- 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
- package/src/utils/textNormalization.ts +0 -30
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { deviceSessionStateSchema, deviceSessionSyncSchema, safeParseContract, } from '@oxyhq/contracts';
|
|
1
|
+
import { deviceSessionStateSchema, deviceSessionSyncSchema, safeParseContract, SESSION_ACCOUNTS_CHANGED_EVENT, sessionAccountsChangedEventSchema, } from '@oxyhq/contracts';
|
|
2
2
|
import { logger } from '../utils/loggerUtils.js';
|
|
3
3
|
import { getSocketIO } from './socketLoader.js';
|
|
4
4
|
/**
|
|
@@ -308,6 +308,9 @@ export class SessionClient {
|
|
|
308
308
|
});
|
|
309
309
|
}
|
|
310
310
|
});
|
|
311
|
+
socket.on(SESSION_ACCOUNTS_CHANGED_EVENT, (payload) => {
|
|
312
|
+
this.onSessionAccountsChanged(payload);
|
|
313
|
+
});
|
|
311
314
|
this.socket = socket;
|
|
312
315
|
// (Re)bind app-facing server-event subscriptions on the fresh socket.
|
|
313
316
|
this.boundServerEvents.clear();
|
|
@@ -315,6 +318,38 @@ export class SessionClient {
|
|
|
315
318
|
this.bindServerEvent(event);
|
|
316
319
|
}
|
|
317
320
|
}
|
|
321
|
+
/**
|
|
322
|
+
* Handle the token-free `session_accounts_changed` signal (room `user:<userId>`).
|
|
323
|
+
*
|
|
324
|
+
* Unlike `session_state` (device-scoped, carries the new state to APPLY), this
|
|
325
|
+
* reaches ALL of a user's connected sockets across their devices/origins and is
|
|
326
|
+
* a pure SIGNAL: it carries no token, no secret, and no account bodies. The only
|
|
327
|
+
* trustworthy bit is "something changed for this user", so — matching the
|
|
328
|
+
* `session_state` contract's guidance — we re-fetch our OWN authoritative device
|
|
329
|
+
* state (`bootstrap` → `GET /session/device/state`) and let the existing
|
|
330
|
+
* `applyState` revision guard reconcile it. We never trust any field on the event
|
|
331
|
+
* beyond routing it to the current user.
|
|
332
|
+
*
|
|
333
|
+
* The refetch is a private (bearer) call: the socket only joins `user:<userId>`
|
|
334
|
+
* when authenticated, so a signed-out client never receives this — but we guard
|
|
335
|
+
* the bearer anyway so a race at sign-out can't 401.
|
|
336
|
+
*/
|
|
337
|
+
onSessionAccountsChanged(payload) {
|
|
338
|
+
const event = safeParseContract(sessionAccountsChangedEventSchema, payload);
|
|
339
|
+
if (!event) {
|
|
340
|
+
logger.warn('[SessionClient] discarded invalid session_accounts_changed', { component: 'SessionClient' });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
// The socket is in `user:<activeUserId>` for the planted bearer, so this should
|
|
344
|
+
// always be the current user; ignore a foreign id defensively (out-of-band relay).
|
|
345
|
+
if (event.userId !== this.host.getCurrentAccountId())
|
|
346
|
+
return;
|
|
347
|
+
if (!this.host.getAccessToken())
|
|
348
|
+
return;
|
|
349
|
+
void this.bootstrap().catch((error) => {
|
|
350
|
+
logger.warn('[SessionClient] session_accounts_changed refetch failed', { component: 'SessionClient' }, error);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
318
353
|
/**
|
|
319
354
|
* Open the same-origin `BroadcastChannel` (web only). A sibling tab that
|
|
320
355
|
* commits an account switch / sign-out posts a wake ping; on receipt an
|
|
@@ -30,7 +30,15 @@ import { extractErrorStatus } from '../utils/errorUtils.js';
|
|
|
30
30
|
import { CENTRAL_IDP_APEX } from '../utils/authWebUrl.js';
|
|
31
31
|
import { generateOAuthState, generatePkcePair, normalizeOAuthRedirectUri, persistOAuthHandshake, } from '../utils/oauthPkce.js';
|
|
32
32
|
import { projectSwitchableAccounts, switchableAccountIds, } from './accountProjection.js';
|
|
33
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Slow FALLBACK poll cadence for the QR flow. The `/auth-session` socket delivers
|
|
35
|
+
* the approval instantly via `auth_update`; this poll only covers the case where
|
|
36
|
+
* the socket can't connect, so it is deliberately slow (was 3000 when polling was
|
|
37
|
+
* the sole mechanism).
|
|
38
|
+
*/
|
|
39
|
+
const DEFAULT_POLL_INTERVAL_MS = 12000;
|
|
40
|
+
/** Socket.IO namespace the API emits QR-flow approval (`auth_update`) events on. */
|
|
41
|
+
const AUTH_SESSION_NAMESPACE = '/auth-session';
|
|
34
42
|
/**
|
|
35
43
|
* Commons's custom URL scheme. Probed via the injected `canOpenApp` to detect an
|
|
36
44
|
* installed Commons on the same device; the `oxycommons://approve?...` deep link
|
|
@@ -62,6 +70,18 @@ export class AccountDialogController {
|
|
|
62
70
|
/** The secret device-flow token of the active QR flow (never surfaced). */
|
|
63
71
|
this.signInToken = null;
|
|
64
72
|
this.pollTimer = null;
|
|
73
|
+
/**
|
|
74
|
+
* The `/auth-session` socket for the active QR flow, or null (poll-only). Its
|
|
75
|
+
* `auth_update` event wakes {@link pollOnce} instantly instead of waiting for the
|
|
76
|
+
* slow fallback timer.
|
|
77
|
+
*/
|
|
78
|
+
this.authSessionSocket = null;
|
|
79
|
+
/**
|
|
80
|
+
* Guards {@link pollOnce} against re-entrancy: the fallback timer and a socket
|
|
81
|
+
* `auth_update` wake can fire together — without this both could claim the
|
|
82
|
+
* single-use token concurrently.
|
|
83
|
+
*/
|
|
84
|
+
this.pollInFlight = false;
|
|
65
85
|
// --- Store plumbing ---
|
|
66
86
|
this.unsubscribeSession = null;
|
|
67
87
|
this.unsubscribeTokens = null;
|
|
@@ -80,6 +100,7 @@ export class AccountDialogController {
|
|
|
80
100
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
81
101
|
this.openUrl = options.openUrl;
|
|
82
102
|
this.canOpenApp = options.canOpenApp;
|
|
103
|
+
this.socketFactory = options.socketFactory;
|
|
83
104
|
this.snapshot = this.computeSnapshot();
|
|
84
105
|
}
|
|
85
106
|
// =========================================================================
|
|
@@ -147,6 +168,8 @@ export class AccountDialogController {
|
|
|
147
168
|
this.unsubscribeTokens = null;
|
|
148
169
|
}
|
|
149
170
|
this.clearPollTimer();
|
|
171
|
+
this.closeAuthSessionSocket();
|
|
172
|
+
this.signInToken = null;
|
|
150
173
|
this.listeners.clear();
|
|
151
174
|
}
|
|
152
175
|
// =========================================================================
|
|
@@ -420,6 +443,10 @@ export class AccountDialogController {
|
|
|
420
443
|
expiresAt: handle.expiresAt,
|
|
421
444
|
error: null,
|
|
422
445
|
});
|
|
446
|
+
// Primary path: an instant `auth_update` wake over the `/auth-session`
|
|
447
|
+
// socket. The poll below is only the fallback for when the socket can't
|
|
448
|
+
// connect, so it now runs at the slow fallback cadence.
|
|
449
|
+
this.openAuthSessionSocket(handle.sessionToken);
|
|
423
450
|
this.scheduleNextPoll(handle.sessionToken);
|
|
424
451
|
// Same-device convenience: if Commons is installed (native only — `canOpenApp`
|
|
425
452
|
// is undefined/false on web), deep-link straight into its approve screen with
|
|
@@ -449,9 +476,10 @@ export class AccountDialogController {
|
|
|
449
476
|
logger.debug('[AccountDialogController] Commons deep-link probe failed (QR fallback active)', { component: 'AccountDialogController' }, error);
|
|
450
477
|
}
|
|
451
478
|
}
|
|
452
|
-
/** Tear down the active sign-in device flow (timers + token) and reset to idle. */
|
|
479
|
+
/** Tear down the active sign-in device flow (timers + socket + token) and reset to idle. */
|
|
453
480
|
cancelSignIn() {
|
|
454
481
|
this.clearPollTimer();
|
|
482
|
+
this.closeAuthSessionSocket();
|
|
455
483
|
this.signInToken = null;
|
|
456
484
|
if (this.signIn !== IDLE_SIGN_IN) {
|
|
457
485
|
this.setSignIn(IDLE_SIGN_IN);
|
|
@@ -506,39 +534,52 @@ export class AccountDialogController {
|
|
|
506
534
|
void this.pollOnce(sessionToken);
|
|
507
535
|
}, this.pollIntervalMs);
|
|
508
536
|
}
|
|
537
|
+
/**
|
|
538
|
+
* Run one status check + (on approval) claim. Triggered by the fallback timer
|
|
539
|
+
* AND by the `/auth-session` socket's `auth_update` wake, so it is guarded
|
|
540
|
+
* against concurrent entry: whichever fires first claims the single-use token;
|
|
541
|
+
* the other no-ops. The `auth_update` payload is never trusted — this always
|
|
542
|
+
* re-checks the authoritative status via `pollCommonsSignIn`.
|
|
543
|
+
*/
|
|
509
544
|
async pollOnce(sessionToken) {
|
|
510
|
-
// A superseded / cancelled flow must not act.
|
|
511
|
-
if (this.signInToken !== sessionToken)
|
|
512
|
-
return;
|
|
513
|
-
const expiresAt = this.signIn.expiresAt;
|
|
514
|
-
if (typeof expiresAt === 'number' && Date.now() > expiresAt) {
|
|
515
|
-
this.failSignIn('Session expired. Please try again.');
|
|
545
|
+
// A superseded / cancelled flow must not act; a poll already running owns the claim.
|
|
546
|
+
if (this.signInToken !== sessionToken || this.pollInFlight)
|
|
516
547
|
return;
|
|
517
|
-
|
|
548
|
+
this.pollInFlight = true;
|
|
518
549
|
try {
|
|
519
|
-
const
|
|
520
|
-
if (
|
|
521
|
-
|
|
522
|
-
if (status.authorized && status.sessionId) {
|
|
523
|
-
this.clearPollTimer();
|
|
524
|
-
await this.claimAndComplete(status.sessionId, sessionToken);
|
|
550
|
+
const expiresAt = this.signIn.expiresAt;
|
|
551
|
+
if (typeof expiresAt === 'number' && Date.now() > expiresAt) {
|
|
552
|
+
this.failSignIn('Session expired. Please try again.');
|
|
525
553
|
return;
|
|
526
554
|
}
|
|
527
|
-
|
|
528
|
-
this.
|
|
529
|
-
|
|
555
|
+
try {
|
|
556
|
+
const status = await this.oxyServices.pollCommonsSignIn(sessionToken);
|
|
557
|
+
if (this.signInToken !== sessionToken)
|
|
558
|
+
return; // cancelled mid-request
|
|
559
|
+
if (status.authorized && status.sessionId) {
|
|
560
|
+
this.clearPollTimer();
|
|
561
|
+
await this.claimAndComplete(status.sessionId, sessionToken);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (status.status === 'cancelled') {
|
|
565
|
+
this.failSignIn('Authorization was denied.');
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (status.status === 'expired') {
|
|
569
|
+
this.failSignIn('Session expired. Please try again.');
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
530
572
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
573
|
+
catch (error) {
|
|
574
|
+
// Transient poll error — the next tick retries. Logged, never thrown.
|
|
575
|
+
logger.debug('[AccountDialogController] poll error (will retry)', { component: 'AccountDialogController' }, error);
|
|
576
|
+
}
|
|
577
|
+
if (this.signInToken === sessionToken) {
|
|
578
|
+
this.scheduleNextPoll(sessionToken);
|
|
534
579
|
}
|
|
535
580
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
logger.debug('[AccountDialogController] poll error (will retry)', { component: 'AccountDialogController' }, error);
|
|
539
|
-
}
|
|
540
|
-
if (this.signInToken === sessionToken) {
|
|
541
|
-
this.scheduleNextPoll(sessionToken);
|
|
581
|
+
finally {
|
|
582
|
+
this.pollInFlight = false;
|
|
542
583
|
}
|
|
543
584
|
}
|
|
544
585
|
async claimAndComplete(sessionId, sessionToken) {
|
|
@@ -586,6 +627,7 @@ export class AccountDialogController {
|
|
|
586
627
|
await this.commitAuthorizedSession(session, user);
|
|
587
628
|
this.signInToken = null;
|
|
588
629
|
this.clearPollTimer();
|
|
630
|
+
this.closeAuthSessionSocket();
|
|
589
631
|
this.signIn = IDLE_SIGN_IN;
|
|
590
632
|
this.view = 'accounts';
|
|
591
633
|
this.emit();
|
|
@@ -607,6 +649,7 @@ export class AccountDialogController {
|
|
|
607
649
|
}
|
|
608
650
|
failSignIn(message) {
|
|
609
651
|
this.clearPollTimer();
|
|
652
|
+
this.closeAuthSessionSocket();
|
|
610
653
|
this.signInToken = null;
|
|
611
654
|
this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: message });
|
|
612
655
|
}
|
|
@@ -617,6 +660,77 @@ export class AccountDialogController {
|
|
|
617
660
|
}
|
|
618
661
|
}
|
|
619
662
|
// =========================================================================
|
|
663
|
+
// /auth-session socket (instant QR approval wake — replaces 3s polling)
|
|
664
|
+
// =========================================================================
|
|
665
|
+
/**
|
|
666
|
+
* Subscribe the active QR flow to the `/auth-session` namespace so the API's
|
|
667
|
+
* `auth_update` event wakes {@link pollOnce} the instant the approval lands.
|
|
668
|
+
*
|
|
669
|
+
* The join is keyed by the secret `sessionToken` (the server's `auth:<token>`
|
|
670
|
+
* room, joined by emitting `join`) and re-issued on every (re)connect so it
|
|
671
|
+
* survives socket drops. `auth_update` is treated as a pure SIGNAL — the payload
|
|
672
|
+
* is never trusted; `pollOnce` re-checks the authoritative status and claims.
|
|
673
|
+
*
|
|
674
|
+
* No-op (poll-only) when no `socketFactory` was injected (web without a bundled
|
|
675
|
+
* `io`, headless/core usage, tests). The namespace needs no auth.
|
|
676
|
+
*/
|
|
677
|
+
openAuthSessionSocket(sessionToken) {
|
|
678
|
+
this.closeAuthSessionSocket();
|
|
679
|
+
if (!this.socketFactory)
|
|
680
|
+
return;
|
|
681
|
+
let socket;
|
|
682
|
+
try {
|
|
683
|
+
socket = this.socketFactory(`${this.oxyServices.getBaseURL()}${AUTH_SESSION_NAMESPACE}`, {
|
|
684
|
+
transports: ['websocket'],
|
|
685
|
+
autoConnect: true,
|
|
686
|
+
reconnection: true,
|
|
687
|
+
reconnectionAttempts: Number.POSITIVE_INFINITY,
|
|
688
|
+
reconnectionDelay: 1000,
|
|
689
|
+
reconnectionDelayMax: 10000,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
catch (error) {
|
|
693
|
+
// Socket unavailable — the fallback poll still completes the flow.
|
|
694
|
+
logger.debug('[AccountDialogController] auth-session socket create failed (poll fallback)', { component: 'AccountDialogController' }, error);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const join = () => {
|
|
698
|
+
if (this.signInToken !== sessionToken)
|
|
699
|
+
return;
|
|
700
|
+
try {
|
|
701
|
+
socket.emit('join', sessionToken);
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
logger.debug('[AccountDialogController] auth-session join failed', { component: 'AccountDialogController' }, error);
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
socket.on('connect', join);
|
|
708
|
+
if (socket.connected)
|
|
709
|
+
join();
|
|
710
|
+
socket.on('auth_update', () => {
|
|
711
|
+
if (this.signInToken !== sessionToken)
|
|
712
|
+
return;
|
|
713
|
+
// Pure wake signal — re-check the authoritative status + claim the poll would have.
|
|
714
|
+
void this.pollOnce(sessionToken);
|
|
715
|
+
});
|
|
716
|
+
this.authSessionSocket = socket;
|
|
717
|
+
}
|
|
718
|
+
/** Tear down the `/auth-session` socket, if any. Idempotent. */
|
|
719
|
+
closeAuthSessionSocket() {
|
|
720
|
+
const socket = this.authSessionSocket;
|
|
721
|
+
if (!socket)
|
|
722
|
+
return;
|
|
723
|
+
this.authSessionSocket = null;
|
|
724
|
+
try {
|
|
725
|
+
socket.off('auth_update');
|
|
726
|
+
socket.off('connect');
|
|
727
|
+
socket.disconnect();
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
logger.debug('[AccountDialogController] auth-session socket close failed', { component: 'AccountDialogController' }, error);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
// =========================================================================
|
|
620
734
|
// Snapshot plumbing
|
|
621
735
|
// =========================================================================
|
|
622
736
|
setSignIn(next) {
|
|
@@ -42,28 +42,6 @@
|
|
|
42
42
|
* the display-name character policy). These functions do exactly one thing:
|
|
43
43
|
* normalize whitespace and Unicode form.
|
|
44
44
|
*/
|
|
45
|
-
/**
|
|
46
|
-
* Characters that make a value ineligible for the zero-work fast path, and the
|
|
47
|
-
* whitespace shapes that a normalized INLINE value can never contain.
|
|
48
|
-
*
|
|
49
|
-
* A value that matches nothing here is, by construction, already normalized:
|
|
50
|
-
* it holds only printable ASCII (which is NFC-stable, so `normalize('NFC')`
|
|
51
|
-
* would be a no-op), its only whitespace is the plain space, it has no leading
|
|
52
|
-
* or trailing space, and no run of two spaces. Returning it untouched skips
|
|
53
|
-
* three string allocations — worth it because the common case in the feed
|
|
54
|
-
* hydration hot path is text that is already clean.
|
|
55
|
-
*
|
|
56
|
-
* Non-global (safe for repeated `.test()`; a global regex is stateful).
|
|
57
|
-
*/
|
|
58
|
-
const INLINE_NEEDS_NORMALIZATION = /[^\x20-\x7E]|^ | $| {2}/;
|
|
59
|
-
/**
|
|
60
|
-
* Same idea as {@link INLINE_NEEDS_NORMALIZATION}, for MULTILINE values: `\n`
|
|
61
|
-
* joins the printable-ASCII fast-path alphabet, and the additional shapes a
|
|
62
|
-
* normalized body can never contain are a space adjacent to a line break — on
|
|
63
|
-
* either side, since every line is trimmed — and a run of three line breaks
|
|
64
|
-
* (more than one blank line).
|
|
65
|
-
*/
|
|
66
|
-
const MULTILINE_NEEDS_NORMALIZATION = /[^\x20-\x7E\n]|^[ \n]|[ \n]$| {2}| \n|\n |\n{3}/;
|
|
67
45
|
/** Any run of whitespace, including tabs, line breaks and Unicode spaces. */
|
|
68
46
|
const ANY_WHITESPACE_RUN = /\s+/g;
|
|
69
47
|
/**
|
|
@@ -125,9 +103,6 @@ const EXCESS_BLANK_LINES = /\n{3,}/g;
|
|
|
125
103
|
* Idempotent: `f(f(x)) === f(x)`.
|
|
126
104
|
*/
|
|
127
105
|
export function normalizeInlineText(value) {
|
|
128
|
-
if (!INLINE_NEEDS_NORMALIZATION.test(value)) {
|
|
129
|
-
return value;
|
|
130
|
-
}
|
|
131
106
|
return value.normalize('NFC').replace(ANY_WHITESPACE_RUN, ' ').trim();
|
|
132
107
|
}
|
|
133
108
|
/**
|
|
@@ -163,9 +138,6 @@ export function normalizeInlineText(value) {
|
|
|
163
138
|
* Idempotent: `f(f(x)) === f(x)`.
|
|
164
139
|
*/
|
|
165
140
|
export function normalizeMultilineText(value) {
|
|
166
|
-
if (!MULTILINE_NEEDS_NORMALIZATION.test(value)) {
|
|
167
|
-
return value;
|
|
168
|
-
}
|
|
169
141
|
return value
|
|
170
142
|
.normalize('NFC')
|
|
171
143
|
.replace(LINE_BREAK_FORMS, '\n')
|