@oxyhq/core 5.2.1 → 5.3.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.
Files changed (59) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/CrossDomainAuth.js +32 -42
  3. package/dist/cjs/index.js +24 -2
  4. package/dist/cjs/mixins/OxyServices.sso.js +36 -0
  5. package/dist/cjs/mixins/OxyServices.user.js +24 -0
  6. package/dist/cjs/server/index.js +13 -1
  7. package/dist/cjs/session/SessionClient.js +142 -0
  8. package/dist/cjs/session/createSessionClient.js +26 -0
  9. package/dist/cjs/session/projectSessionState.js +75 -0
  10. package/dist/cjs/session/sessionClientHost.js +30 -0
  11. package/dist/cjs/session/socketLoader.js +55 -0
  12. package/dist/cjs/utils/ssoBounce.js +9 -9
  13. package/dist/cjs/utils/ssoEstablish.js +110 -0
  14. package/dist/esm/.tsbuildinfo +1 -1
  15. package/dist/esm/CrossDomainAuth.js +32 -42
  16. package/dist/esm/index.js +14 -0
  17. package/dist/esm/mixins/OxyServices.sso.js +36 -0
  18. package/dist/esm/mixins/OxyServices.user.js +24 -0
  19. package/dist/esm/server/index.js +10 -0
  20. package/dist/esm/session/SessionClient.js +138 -0
  21. package/dist/esm/session/createSessionClient.js +23 -0
  22. package/dist/esm/session/projectSessionState.js +69 -0
  23. package/dist/esm/session/sessionClientHost.js +27 -0
  24. package/dist/esm/session/socketLoader.js +19 -0
  25. package/dist/esm/utils/ssoBounce.js +9 -9
  26. package/dist/esm/utils/ssoEstablish.js +107 -0
  27. package/dist/types/.tsbuildinfo +1 -1
  28. package/dist/types/CrossDomainAuth.d.ts +33 -13
  29. package/dist/types/index.d.ts +7 -0
  30. package/dist/types/mixins/OxyServices.sso.d.ts +24 -0
  31. package/dist/types/mixins/OxyServices.user.d.ts +14 -0
  32. package/dist/types/server/index.d.ts +2 -0
  33. package/dist/types/session/SessionClient.d.ts +55 -0
  34. package/dist/types/session/createSessionClient.d.ts +23 -0
  35. package/dist/types/session/projectSessionState.d.ts +43 -0
  36. package/dist/types/session/sessionClientHost.d.ts +18 -0
  37. package/dist/types/session/socketLoader.d.ts +9 -0
  38. package/dist/types/utils/ssoBounce.d.ts +9 -9
  39. package/dist/types/utils/ssoEstablish.d.ts +85 -0
  40. package/package.json +1 -1
  41. package/src/CrossDomainAuth.ts +33 -44
  42. package/src/__tests__/crossDomainAuth.test.ts +33 -16
  43. package/src/index.ts +24 -0
  44. package/src/mixins/OxyServices.sso.ts +55 -0
  45. package/src/mixins/OxyServices.user.ts +26 -0
  46. package/src/mixins/__tests__/sso.test.ts +41 -0
  47. package/src/server/index.ts +12 -0
  48. package/src/session/SessionClient.ts +175 -0
  49. package/src/session/__tests__/SessionClient.rest.test.ts +79 -0
  50. package/src/session/__tests__/SessionClient.socket.test.ts +132 -0
  51. package/src/session/__tests__/SessionClient.state.test.ts +64 -0
  52. package/src/session/__tests__/sessionIntegration.test.ts +202 -0
  53. package/src/session/createSessionClient.ts +31 -0
  54. package/src/session/projectSessionState.ts +83 -0
  55. package/src/session/sessionClientHost.ts +32 -0
  56. package/src/session/socketLoader.ts +28 -0
  57. package/src/utils/__tests__/ssoEstablish.test.ts +204 -0
  58. package/src/utils/ssoBounce.ts +9 -9
  59. package/src/utils/ssoEstablish.ts +174 -0
@@ -6,6 +6,7 @@ interface MockServices {
6
6
  signInWithFedCM: jest.Mock;
7
7
  signInWithRedirect: jest.Mock;
8
8
  silentSignInWithFedCM: jest.Mock;
9
+ silentSignIn: jest.Mock;
9
10
  isFedCMSupported: jest.Mock;
10
11
  getCurrentUser: jest.Mock;
11
12
  handleAuthCallback: jest.Mock;
@@ -28,6 +29,7 @@ function createMockServices(overrides: Partial<MockServices> = {}): MockServices
28
29
  signInWithFedCM: jest.fn(async () => fakeSession('fedcm-sess')),
29
30
  signInWithRedirect: jest.fn(),
30
31
  silentSignInWithFedCM: jest.fn(async () => null),
32
+ silentSignIn: jest.fn(async () => null),
31
33
  isFedCMSupported: jest.fn(() => true),
32
34
  getCurrentUser: jest.fn(),
33
35
  handleAuthCallback: jest.fn(() => null),
@@ -62,38 +64,53 @@ describe('CrossDomainAuth', () => {
62
64
  });
63
65
  });
64
66
 
65
- it('uses FedCM first in auto mode when supported', async () => {
66
- const services = createMockServices();
67
+ it('auto mode goes straight to redirect and never calls FedCM, even when FedCM is supported', async () => {
68
+ const services = createMockServices({ isFedCMSupported: jest.fn(() => true) });
67
69
  const auth = new CrossDomainAuth(services as unknown as OxyServices);
68
70
  const selected: string[] = [];
69
71
 
70
- const session = await auth.signIn({
72
+ const result = await auth.signIn({
71
73
  method: 'auto',
72
74
  onMethodSelected: (method) => selected.push(method),
73
75
  });
74
76
 
75
- expect(session?.sessionId).toBe('fedcm-sess');
76
- expect(selected).toEqual(['fedcm']);
77
- expect(services.signInWithRedirect).not.toHaveBeenCalled();
77
+ expect(result).toBeNull();
78
+ expect(selected).toEqual(['redirect']);
79
+ expect(services.signInWithFedCM).not.toHaveBeenCalled();
80
+ expect(services.signInWithRedirect).toHaveBeenCalledTimes(1);
78
81
  });
79
82
 
80
- it('falls back to redirect in auto mode when FedCM fails', async () => {
83
+ it('autoSignIn does not call FedCM (goes to redirect) regardless of browser support', async () => {
81
84
  const services = createMockServices({
82
- signInWithFedCM: jest.fn(async () => { throw new Error('fedcm fail'); }),
85
+ isFedCMSupported: jest.fn(() => true),
86
+ signInWithFedCM: jest.fn(async () => {
87
+ throw new Error('autoSignIn must never call signInWithFedCM');
88
+ }),
83
89
  });
84
90
  const auth = new CrossDomainAuth(services as unknown as OxyServices);
85
- const selected: string[] = [];
86
- const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
87
91
 
88
- const result = await auth.signIn({
89
- method: 'auto',
90
- onMethodSelected: (method) => selected.push(method),
91
- });
92
+ const result = await auth.signIn({ method: 'auto' });
92
93
 
93
94
  expect(result).toBeNull();
94
- expect(selected).toEqual(['fedcm', 'redirect']);
95
+ expect(services.signInWithFedCM).not.toHaveBeenCalled();
96
+ expect(services.isFedCMSupported).not.toHaveBeenCalled();
95
97
  expect(services.signInWithRedirect).toHaveBeenCalledTimes(1);
98
+ });
99
+
100
+ it('getRecommendedMethod always recommends redirect', () => {
101
+ const services = createMockServices({ isFedCMSupported: jest.fn(() => true) });
102
+ const auth = new CrossDomainAuth(services as unknown as OxyServices);
103
+
104
+ expect(auth.getRecommendedMethod().method).toBe('redirect');
105
+ expect(services.isFedCMSupported).not.toHaveBeenCalled();
106
+ });
107
+
108
+ it('silentSignIn falls back to iframe-based silent auth without ever calling FedCM', async () => {
109
+ const services = createMockServices({ isFedCMSupported: jest.fn(() => true) });
110
+ const auth = new CrossDomainAuth(services as unknown as OxyServices);
111
+
112
+ await auth.silentSignIn();
96
113
 
97
- warnSpy.mockRestore();
114
+ expect(services.silentSignInWithFedCM).not.toHaveBeenCalled();
98
115
  });
99
116
  });
package/src/index.ts CHANGED
@@ -530,6 +530,10 @@ export { parseSsoReturnFragment, consumeSsoReturn } from './utils/ssoReturn';
530
530
  export type { SsoReturnKind, SsoReturnResult, ConsumeSsoReturnDeps } from './utils/ssoReturn';
531
531
  export { generateSsoState } from './mixins/OxyServices.sso';
532
532
 
533
+ // Post-claim durable-session establish hop (web device-flow / QR sign-in).
534
+ export { establishIdpSessionAfterClaim } from './utils/ssoEstablish';
535
+ export type { SsoEstablishClient, EstablishAfterClaimDeps } from './utils/ssoEstablish';
536
+
533
537
  // SSO bounce — per-origin sessionStorage keys, bounce URL builder, predicates
534
538
  export {
535
539
  SSO_CALLBACK_PATH,
@@ -562,6 +566,26 @@ export type {
562
566
  RunColdBootOptions,
563
567
  } from './utils/coldBoot';
564
568
 
569
+ // ---------------------------------------------------------------------------
570
+ // Session sync (device-scoped multi-account session client)
571
+ // ---------------------------------------------------------------------------
572
+ export { SessionClient } from './session/SessionClient';
573
+ export type { TokenTransport, SessionClientHost, SessionClientOptions } from './session/SessionClient';
574
+
575
+ // Shared SessionClient integration layer: the host adapter, the pure
576
+ // DeviceSessionState projection helpers, and the client factory are defined
577
+ // ONCE here so `@oxyhq/services` and `@oxyhq/auth` both reuse them instead of
578
+ // duplicating a local copy. Each consumer supplies its own `TokenTransport`
579
+ // (native vs. web mint strategies differ) to `createSessionClient`.
580
+ export { createSessionClientHost } from './session/sessionClientHost';
581
+ export { createSessionClient } from './session/createSessionClient';
582
+ export {
583
+ deviceStateToClientSessions,
584
+ activeSessionIdOf,
585
+ activeUserOf,
586
+ accountIdsOf,
587
+ } from './session/projectSessionState';
588
+
565
589
  // API response contracts (request/response Zod schemas + inferred types) live in
566
590
  // `@oxyhq/contracts` — the single source of truth shared by the backend and every
567
591
  // client SDK. Import them directly from `@oxyhq/contracts`; `@oxyhq/core` does NOT
@@ -49,6 +49,11 @@ interface SsoExchangeWireResponse {
49
49
  authuser?: number;
50
50
  }
51
51
 
52
+ /** Wire shape of `POST /sso/establish-token`. */
53
+ interface SsoEstablishTokenWireResponse {
54
+ establishUrl: string;
55
+ }
56
+
52
57
  /**
53
58
  * Generate a cryptographically secure state value for the SSO bounce.
54
59
  *
@@ -202,5 +207,55 @@ export function OxyServicesSsoMixin<T extends typeof OxyServicesBase>(Base: T) {
202
207
 
203
208
  return session;
204
209
  }
210
+
211
+ /**
212
+ * Mint a server-formed `/sso/establish` URL for the caller's OWN session,
213
+ * bound to an approved RP `origin`.
214
+ *
215
+ * Bearer-authenticated (the session id is taken from the caller's own
216
+ * bearer, server-side — never from any argument). The server validates that
217
+ * `origin` is an approved client origin (and matches the request `Origin`),
218
+ * derives the per-apex IdP host (`auth.<apex>`), mints a short-lived HS256
219
+ * establish-token, and returns a fully-formed
220
+ * `https://<auth-host>/sso/establish?et=…&return_to=<origin>/__oxy/sso-callback&state=<state>`.
221
+ *
222
+ * Used AFTER a web device-flow claim to plant the durable first-party
223
+ * `fedcm_session` cookie so a reload can re-mint a token (see
224
+ * {@link establishIdpSessionAfterClaim}). Cache-free (a POST is never
225
+ * cached, but `cache: false` is explicit).
226
+ *
227
+ * @param origin - The RP origin (`window.location.origin`) to establish for.
228
+ * @param state - The CSRF state echoed back in the callback fragment; the
229
+ * caller persists the SAME value under `ssoStateKey(origin)` so the
230
+ * post-bounce `sso-return` step validates it.
231
+ */
232
+ public async requestSsoEstablishUrl(
233
+ origin: string,
234
+ state: string,
235
+ ): Promise<{ establishUrl: string }> {
236
+ if (typeof origin !== 'string' || origin.length === 0) {
237
+ throw this.handleError(new Error('requestSsoEstablishUrl requires a non-empty origin'));
238
+ }
239
+ if (typeof state !== 'string' || state.length === 0) {
240
+ throw this.handleError(new Error('requestSsoEstablishUrl requires a non-empty state'));
241
+ }
242
+
243
+ const response = await this.makeRequest<SsoEstablishTokenWireResponse>(
244
+ 'POST',
245
+ '/sso/establish-token',
246
+ { origin, state },
247
+ { cache: false },
248
+ );
249
+
250
+ if (
251
+ !response ||
252
+ typeof response.establishUrl !== 'string' ||
253
+ response.establishUrl.length === 0
254
+ ) {
255
+ throw this.handleError(new Error('SSO establish-token returned no establishUrl'));
256
+ }
257
+
258
+ return { establishUrl: response.establishUrl };
259
+ }
205
260
  };
206
261
  }
@@ -796,6 +796,32 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
796
796
  }
797
797
  }
798
798
 
799
+ /**
800
+ * Get the authenticated VIEWER's OWN mutual-follow user ids — the accounts the
801
+ * viewer follows that ALSO follow the viewer back (a bidirectional follow
802
+ * edge). The viewer is derived server-side from the SDK's auth token (never a
803
+ * param), so there is no target id to pass.
804
+ *
805
+ * Returns a bounded, lean list of ids meant to SEED a "Mutuals" feed (the
806
+ * consumer hydrates/ranks the posts itself) — distinct from
807
+ * {@link getUserMutuals}, which returns hydrated "followers you know" DTOs
808
+ * about ANOTHER profile. An anonymous caller resolves to an empty array.
809
+ */
810
+ async getMutualUserIds(
811
+ params?: { limit?: number }
812
+ ): Promise<string[]> {
813
+ try {
814
+ const query = buildPaginationParams(params || {});
815
+ const response = await this.makeRequest<{ data: string[] }>('GET', '/users/mutual-ids', query, {
816
+ cache: true,
817
+ cacheTTL: 2 * 60 * 1000, // 2 minutes cache
818
+ });
819
+ return response.data || [];
820
+ } catch (error) {
821
+ throw this.handleError(error);
822
+ }
823
+ }
824
+
799
825
  /**
800
826
  * Get notifications
801
827
  */
@@ -166,6 +166,47 @@ describe('OxyServices.exchangeSsoCode', () => {
166
166
  });
167
167
  });
168
168
 
169
+ describe('OxyServices.requestSsoEstablishUrl', () => {
170
+ const ESTABLISH_URL =
171
+ 'https://auth.oxy.so/sso/establish?et=jwt&return_to=https%3A%2F%2Faccounts.oxy.so%2F__oxy%2Fsso-callback&state=s';
172
+
173
+ it('POSTs origin + state to /sso/establish-token (bearer, cache-free) and returns the URL', async () => {
174
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
175
+ const spy = jest
176
+ .spyOn(oxy, 'makeRequest')
177
+ .mockResolvedValue({ establishUrl: ESTABLISH_URL } as never);
178
+
179
+ const result = await oxy.requestSsoEstablishUrl('https://accounts.oxy.so', 's');
180
+
181
+ expect(result).toEqual({ establishUrl: ESTABLISH_URL });
182
+ expect(spy).toHaveBeenCalledWith(
183
+ 'POST',
184
+ '/sso/establish-token',
185
+ { origin: 'https://accounts.oxy.so', state: 's' },
186
+ { cache: false },
187
+ );
188
+ spy.mockRestore();
189
+ });
190
+
191
+ it('rejects an empty origin or state without calling the API', async () => {
192
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
193
+ const spy = jest.spyOn(oxy, 'makeRequest');
194
+
195
+ await expect(oxy.requestSsoEstablishUrl('', 's')).rejects.toThrow();
196
+ await expect(oxy.requestSsoEstablishUrl('https://accounts.oxy.so', '')).rejects.toThrow();
197
+ expect(spy).not.toHaveBeenCalled();
198
+ spy.mockRestore();
199
+ });
200
+
201
+ it('throws when the server returns no establishUrl', async () => {
202
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
203
+ const spy = jest.spyOn(oxy, 'makeRequest').mockResolvedValue({} as never);
204
+
205
+ await expect(oxy.requestSsoEstablishUrl('https://accounts.oxy.so', 's')).rejects.toThrow();
206
+ spy.mockRestore();
207
+ });
208
+ });
209
+
169
210
  describe('generateSsoState', () => {
170
211
  it('returns a non-empty unique string (module-level helper)', () => {
171
212
  const a = generateSsoState();
@@ -63,3 +63,15 @@ export type { OxyCorsOptions } from './cors';
63
63
 
64
64
  // Constant-time secret comparison.
65
65
  export { verifySecret } from './verifySecret';
66
+
67
+ // Registrable-apex (eTLD+1) derivation via the Public Suffix List — the SINGLE
68
+ // SOURCE OF TRUTH shared with the IdP worker and the client FAPI auto-detect.
69
+ // Pure host handling (no browser deps), so it is safe on the server subpath and
70
+ // lets `@oxyhq/api` derive `auth.<apex>` without duplicating PSL logic.
71
+ export { registrableApex } from '../utils/fapiAutoDetect';
72
+
73
+ // The single RP callback path the IdP redirects back to. A pure wire-contract
74
+ // constant (no browser deps at module top level), re-used server-side so the
75
+ // `/sso/establish-token` `return_to` cannot drift from what `/sso/establish`
76
+ // validates.
77
+ export { SSO_CALLBACK_PATH } from '../utils/ssoBounce';
@@ -0,0 +1,175 @@
1
+ import {
2
+ deviceSessionStateSchema,
3
+ deviceSessionSyncSchema,
4
+ safeParseContract,
5
+ type DeviceSessionState,
6
+ } from '@oxyhq/contracts';
7
+ import { logger } from '../utils/loggerUtils';
8
+ import { getSocketIO } from './socketLoader';
9
+ import type { MinimalSocket } from './socketLoader';
10
+
11
+ export interface TokenTransport {
12
+ /** Ensure this app holds a per-domain access token for state.activeAccountId (mint via FedCM/silent/sso/keychain). Best-effort. */
13
+ ensureActiveToken(state: DeviceSessionState): Promise<void>;
14
+ }
15
+
16
+ export interface SessionClientHost {
17
+ makeRequest<T>(method: 'GET' | 'POST', url: string, data?: unknown, options?: { cache?: boolean }): Promise<T>;
18
+ getBaseURL(): string;
19
+ getAccessToken(): string | null;
20
+ onTokensChanged(listener: (token: string | null) => void): () => void;
21
+ setTokens(accessToken: string): void;
22
+ getCurrentAccountId(): string | null;
23
+ }
24
+
25
+ export interface SessionClientOptions {
26
+ transport?: TokenTransport;
27
+ }
28
+
29
+ type StateListener = (state: DeviceSessionState | null) => void;
30
+
31
+ export class SessionClient {
32
+ private state: DeviceSessionState | null = null;
33
+ private readonly listeners = new Set<StateListener>();
34
+ protected socket: MinimalSocket | null = null;
35
+ private tokenUnsub: (() => void) | null = null;
36
+ private started = false;
37
+
38
+ constructor(
39
+ protected readonly host: SessionClientHost,
40
+ protected readonly options: SessionClientOptions = {},
41
+ ) {}
42
+
43
+ getState(): DeviceSessionState | null {
44
+ return this.state;
45
+ }
46
+
47
+ subscribe(listener: StateListener): () => void {
48
+ this.listeners.add(listener);
49
+ return () => {
50
+ this.listeners.delete(listener);
51
+ };
52
+ }
53
+
54
+ protected notify(): void {
55
+ for (const listener of this.listeners) {
56
+ try {
57
+ listener(this.state);
58
+ } catch (error) {
59
+ logger.error('[SessionClient] subscriber threw', error);
60
+ }
61
+ }
62
+ }
63
+
64
+ /** Validate + last-writer-wins by revision. Returns true if applied. */
65
+ protected applyState(raw: unknown): boolean {
66
+ const next = safeParseContract(deviceSessionStateSchema, raw);
67
+ if (!next) {
68
+ logger.warn('[SessionClient] discarded invalid session state');
69
+ return false;
70
+ }
71
+ if (this.state && next.revision <= this.state.revision) {
72
+ return false;
73
+ }
74
+ this.state = next;
75
+ this.notify();
76
+ if (this.options.transport) {
77
+ void this.options.transport.ensureActiveToken(next).catch((error) => {
78
+ logger.warn('[SessionClient] ensureActiveToken failed', { component: 'SessionClient' }, error);
79
+ });
80
+ }
81
+ return true;
82
+ }
83
+
84
+ /**
85
+ * Validate `{ state, activeToken }`, apply the state, and plant the active token host-side.
86
+ * Token-planting is decoupled from whether `applyState` advanced the revision: a socket push
87
+ * followed by this same `GET /state` fetch returns the SAME revision (applyState no-ops), but
88
+ * the token still needs to be planted. The account-match guard rejects a stale response for an
89
+ * account that is no longer active.
90
+ */
91
+ private applySync(raw: unknown): void {
92
+ const sync = safeParseContract(deviceSessionSyncSchema, raw);
93
+ if (!sync) {
94
+ logger.warn('[SessionClient] discarded invalid session sync');
95
+ return;
96
+ }
97
+ this.applyState(sync.state);
98
+ if (sync.activeToken && this.state && sync.state.activeAccountId === this.state.activeAccountId) {
99
+ this.host.setTokens(sync.activeToken.accessToken);
100
+ }
101
+ }
102
+
103
+ async bootstrap(): Promise<void> {
104
+ const res = await this.host.makeRequest<{ data?: unknown }>('GET', '/session/device/state', undefined, { cache: false });
105
+ this.applySync(res?.data);
106
+ }
107
+
108
+ async switchAccount(accountId: string): Promise<void> {
109
+ const res = await this.host.makeRequest<{ data?: unknown }>('POST', '/session/device/switch', { accountId }, { cache: false });
110
+ this.applySync(res?.data);
111
+ }
112
+
113
+ async signOut(target: { accountId: string } | { all: true }): Promise<void> {
114
+ const res = await this.host.makeRequest<{ data?: unknown }>('POST', '/session/device/signout', target, { cache: false });
115
+ this.applySync(res?.data);
116
+ }
117
+
118
+ async addCurrentAccount(): Promise<void> {
119
+ const res = await this.host.makeRequest<{ data?: unknown }>('POST', '/session/device/add', undefined, { cache: false });
120
+ this.applySync(res?.data);
121
+ }
122
+
123
+ async start(): Promise<void> {
124
+ if (this.started) return;
125
+ this.started = true;
126
+ this.tokenUnsub = this.host.onTokensChanged((token) => {
127
+ if (token && this.socket && !this.socket.connected) {
128
+ this.socket.connect();
129
+ }
130
+ });
131
+ await this.bootstrap();
132
+ await this.connectSocket();
133
+ }
134
+
135
+ stop(): void {
136
+ this.started = false;
137
+ if (this.tokenUnsub) {
138
+ this.tokenUnsub();
139
+ this.tokenUnsub = null;
140
+ }
141
+ if (this.socket) {
142
+ this.socket.disconnect();
143
+ this.socket = null;
144
+ }
145
+ }
146
+
147
+ private async connectSocket(): Promise<void> {
148
+ const io = await getSocketIO();
149
+ if (!io) {
150
+ logger.warn('[SessionClient] no socket.io-client; running REST-only (no realtime sync)', { component: 'SessionClient' });
151
+ return;
152
+ }
153
+ if (!this.started) return; // stopped while the dynamic import was in flight
154
+ const hasToken = Boolean(this.host.getAccessToken());
155
+ const socket = io(this.host.getBaseURL(), {
156
+ transports: ['websocket'],
157
+ autoConnect: hasToken,
158
+ auth: (cb: (data: { token: string }) => void) => {
159
+ cb({ token: this.host.getAccessToken() ?? '' });
160
+ },
161
+ });
162
+ socket.on('session_state', (payload: unknown) => {
163
+ const applied = this.applyState(payload);
164
+ if (applied) {
165
+ const active = this.state?.activeAccountId ?? null;
166
+ if (active && active !== this.host.getCurrentAccountId()) {
167
+ void this.bootstrap().catch((error) => {
168
+ logger.warn('[SessionClient] post-push token fetch failed', { component: 'SessionClient' }, error);
169
+ });
170
+ }
171
+ }
172
+ });
173
+ this.socket = socket;
174
+ }
175
+ }
@@ -0,0 +1,79 @@
1
+ import type { DeviceSessionState } from '@oxyhq/contracts';
2
+ import { SessionClient, type SessionClientHost } from '../SessionClient';
3
+
4
+ const STATE = (rev: number): DeviceSessionState => ({
5
+ deviceId: 'd1', accounts: [{ accountId: 'a1', sessionId: 's1', authuser: 0 }], activeAccountId: 'a1', revision: rev, updatedAt: 1720000000000,
6
+ });
7
+
8
+ function makeHost(makeRequest: jest.Mock): SessionClientHost {
9
+ return {
10
+ makeRequest,
11
+ getBaseURL: () => 'http://test.invalid',
12
+ getAccessToken: () => 't',
13
+ onTokensChanged: () => () => undefined,
14
+ setTokens: jest.fn(),
15
+ getCurrentAccountId: () => null,
16
+ };
17
+ }
18
+
19
+ // The server wraps the sync payload in a REST `{ data }` envelope; makeRequest does NOT unwrap it.
20
+ const SYNC = (rev: number) => ({ data: { state: STATE(rev), activeToken: { accessToken: `jwt-${rev}`, expiresAt: 'x' } } });
21
+
22
+ describe('SessionClient REST', () => {
23
+ it('bootstrap GETs /session/device/state and applies it', async () => {
24
+ const makeRequest = jest.fn().mockResolvedValueOnce(SYNC(3));
25
+ const host = makeHost(makeRequest);
26
+ const c = new SessionClient(host);
27
+ await c.bootstrap();
28
+ expect(makeRequest).toHaveBeenCalledWith('GET', '/session/device/state', undefined, { cache: false });
29
+ expect(c.getState()?.revision).toBe(3);
30
+ expect(host.setTokens).toHaveBeenCalledWith('jwt-3');
31
+ });
32
+
33
+ it('switchAccount POSTs and applies the returned state', async () => {
34
+ const makeRequest = jest.fn().mockResolvedValueOnce(SYNC(4));
35
+ const host = makeHost(makeRequest);
36
+ const c = new SessionClient(host);
37
+ await c.switchAccount('a1');
38
+ expect(makeRequest).toHaveBeenCalledWith('POST', '/session/device/switch', { accountId: 'a1' }, { cache: false });
39
+ expect(c.getState()?.revision).toBe(4);
40
+ expect(host.setTokens).toHaveBeenCalledWith('jwt-4');
41
+ });
42
+
43
+ it('signOut one account POSTs { accountId }', async () => {
44
+ const makeRequest = jest.fn().mockResolvedValueOnce(SYNC(5));
45
+ const c = new SessionClient(makeHost(makeRequest));
46
+ await c.signOut({ accountId: 'a1' });
47
+ expect(makeRequest).toHaveBeenCalledWith('POST', '/session/device/signout', { accountId: 'a1' }, { cache: false });
48
+ });
49
+
50
+ it('signOut all POSTs { all: true }', async () => {
51
+ const makeRequest = jest.fn().mockResolvedValueOnce(SYNC(6));
52
+ const c = new SessionClient(makeHost(makeRequest));
53
+ await c.signOut({ all: true });
54
+ expect(makeRequest).toHaveBeenCalledWith('POST', '/session/device/signout', { all: true }, { cache: false });
55
+ });
56
+
57
+ it('addCurrentAccount POSTs /session/device/add with no body', async () => {
58
+ const makeRequest = jest.fn().mockResolvedValueOnce(SYNC(2));
59
+ const c = new SessionClient(makeHost(makeRequest));
60
+ await c.addCurrentAccount();
61
+ expect(makeRequest).toHaveBeenCalledWith('POST', '/session/device/add', undefined, { cache: false });
62
+ });
63
+
64
+ it('does not throw / does not apply when the server returns invalid state', async () => {
65
+ const makeRequest = jest.fn().mockResolvedValueOnce({ bogus: true });
66
+ const c = new SessionClient(makeHost(makeRequest));
67
+ await c.bootstrap();
68
+ expect(c.getState()).toBeNull();
69
+ });
70
+
71
+ it('applies state but does not plant a token when activeToken is null', async () => {
72
+ const makeRequest = jest.fn().mockResolvedValueOnce({ data: { state: STATE(7), activeToken: null } });
73
+ const host = makeHost(makeRequest);
74
+ const c = new SessionClient(host);
75
+ await c.bootstrap();
76
+ expect(c.getState()?.revision).toBe(7);
77
+ expect(host.setTokens).not.toHaveBeenCalled();
78
+ });
79
+ });
@@ -0,0 +1,132 @@
1
+ import type { DeviceSessionState } from '@oxyhq/contracts';
2
+
3
+ type Handler = (...args: unknown[]) => void;
4
+ class FakeSocket {
5
+ connected = false;
6
+ handlers = new Map<string, Handler[]>();
7
+ on(event: string, cb: Handler) { const l = this.handlers.get(event) ?? []; l.push(cb); this.handlers.set(event, l); }
8
+ off(event: string, cb?: Handler) { if (!cb) { this.handlers.delete(event); return; } this.handlers.set(event, (this.handlers.get(event) ?? []).filter((h) => h !== cb)); }
9
+ connect() { this.connected = true; this.trigger('connect'); }
10
+ disconnect() { this.connected = false; }
11
+ trigger(event: string, ...args: unknown[]) { for (const h of this.handlers.get(event) ?? []) h(...args); }
12
+ }
13
+ let fakeSocket: FakeSocket;
14
+ const ioMock = jest.fn((_uri: string, opts?: Record<string, unknown>) => {
15
+ // honor autoConnect like real socket.io (connect immediately unless autoConnect:false)
16
+ if (!opts || opts.autoConnect !== false) fakeSocket.connected = true;
17
+ return fakeSocket;
18
+ });
19
+ jest.mock('socket.io-client', () => ({ __esModule: true, io: (...args: unknown[]) => ioMock(...(args as [string, Record<string, unknown>?])) }));
20
+
21
+ import { SessionClient, type SessionClientHost } from '../SessionClient';
22
+
23
+ const STATE = (rev: number): DeviceSessionState => ({ deviceId: 'd1', accounts: [{ accountId: 'a1', sessionId: 's1', authuser: 0 }], activeAccountId: 'a1', revision: rev, updatedAt: 1720000000000 });
24
+ // The server wraps the sync payload in a REST `{ data }` envelope; makeRequest does NOT unwrap it.
25
+ const SYNC = (rev: number) => ({ data: { state: STATE(rev), activeToken: { accessToken: `jwt-${rev}`, expiresAt: 'x' } } });
26
+
27
+ function makeHost(over: Partial<SessionClientHost> = {}): SessionClientHost {
28
+ return {
29
+ makeRequest: jest.fn().mockResolvedValue(SYNC(1)),
30
+ getBaseURL: () => 'http://test.invalid',
31
+ getAccessToken: () => 'tok',
32
+ onTokensChanged: () => () => undefined,
33
+ setTokens: jest.fn(),
34
+ getCurrentAccountId: () => 'a1',
35
+ ...over,
36
+ };
37
+ }
38
+
39
+ beforeEach(() => { fakeSocket = new FakeSocket(); ioMock.mockClear(); });
40
+
41
+ describe('SessionClient socket', () => {
42
+ it('start() bootstraps then opens ONE socket to the base URL with a token-in-handshake auth callback', async () => {
43
+ const host = makeHost();
44
+ const c = new SessionClient(host);
45
+ await c.start();
46
+ expect(host.makeRequest).toHaveBeenCalledWith('GET', '/session/device/state', undefined, { cache: false });
47
+ expect(ioMock).toHaveBeenCalledTimes(1);
48
+ const [uri, opts] = ioMock.mock.calls[0];
49
+ expect(uri).toBe('http://test.invalid');
50
+ const authCb = jest.fn();
51
+ (opts?.auth as (cb: (d: { token: string }) => void) => void)(authCb);
52
+ expect(authCb).toHaveBeenCalledWith({ token: 'tok' });
53
+ c.stop();
54
+ });
55
+
56
+ it('applies a pushed session_state event', async () => {
57
+ const c = new SessionClient(makeHost());
58
+ await c.start();
59
+ fakeSocket.trigger('session_state', STATE(9));
60
+ expect(c.getState()?.revision).toBe(9);
61
+ c.stop();
62
+ });
63
+
64
+ it('fetches the active token via bootstrap when a pushed state changes the active account', async () => {
65
+ const makeRequest = jest.fn().mockResolvedValue(SYNC(1));
66
+ const host = makeHost({ makeRequest, getCurrentAccountId: () => 'other-account' });
67
+ const c = new SessionClient(host);
68
+ await c.start();
69
+ makeRequest.mockClear();
70
+ fakeSocket.trigger('session_state', STATE(9));
71
+ await Promise.resolve();
72
+ expect(makeRequest).toHaveBeenCalledWith('GET', '/session/device/state', undefined, { cache: false });
73
+ c.stop();
74
+ });
75
+
76
+ it('C1 regression: plants the active token on a socket-pushed switch even when the post-push bootstrap returns the SAME revision as the push', async () => {
77
+ const setTokens = jest.fn();
78
+ const makeRequest = jest
79
+ .fn()
80
+ .mockResolvedValueOnce(SYNC(1)) // initial bootstrap in start()
81
+ .mockResolvedValue(SYNC(9)); // post-push bootstrap: same revision as the socket push below
82
+ const host = makeHost({ makeRequest, setTokens, getCurrentAccountId: () => 'other-account' });
83
+ const c = new SessionClient(host);
84
+ await c.start();
85
+ makeRequest.mockClear();
86
+ setTokens.mockClear();
87
+ fakeSocket.trigger('session_state', STATE(9));
88
+ await Promise.resolve();
89
+ await Promise.resolve();
90
+ expect(makeRequest).toHaveBeenCalledWith('GET', '/session/device/state', undefined, { cache: false });
91
+ expect(setTokens).toHaveBeenCalledWith('jwt-9');
92
+ c.stop();
93
+ });
94
+
95
+ it('does not re-fetch when the pushed active account matches the host-held account', async () => {
96
+ const makeRequest = jest.fn().mockResolvedValue(SYNC(1));
97
+ const host = makeHost({ makeRequest, getCurrentAccountId: () => 'a1' });
98
+ const c = new SessionClient(host);
99
+ await c.start();
100
+ makeRequest.mockClear();
101
+ fakeSocket.trigger('session_state', STATE(9));
102
+ await Promise.resolve();
103
+ expect(makeRequest).not.toHaveBeenCalled();
104
+ c.stop();
105
+ });
106
+
107
+ it('does not connect the socket when there is no token (autoConnect false)', async () => {
108
+ const c = new SessionClient(makeHost({ getAccessToken: () => null }));
109
+ await c.start();
110
+ const [, opts] = ioMock.mock.calls[0];
111
+ expect(opts?.autoConnect).toBe(false);
112
+ c.stop();
113
+ });
114
+
115
+ it('reconnects when a token arrives after being disconnected', async () => {
116
+ let tokenListener: ((t: string | null) => void) | null = null;
117
+ const host = makeHost({ getAccessToken: () => null, onTokensChanged: (l) => { tokenListener = l; return () => undefined; } });
118
+ const c = new SessionClient(host);
119
+ await c.start();
120
+ fakeSocket.connected = false;
121
+ tokenListener?.('fresh-token');
122
+ expect(fakeSocket.connected).toBe(true);
123
+ c.stop();
124
+ });
125
+
126
+ it('stop() disconnects the socket', async () => {
127
+ const c = new SessionClient(makeHost());
128
+ await c.start();
129
+ c.stop();
130
+ expect(fakeSocket.connected).toBe(false);
131
+ });
132
+ });