@oxyhq/core 5.2.1 → 5.4.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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/CrossDomainAuth.js +32 -42
- package/dist/cjs/index.js +24 -2
- package/dist/cjs/mixins/OxyServices.sso.js +36 -0
- package/dist/cjs/mixins/OxyServices.user.js +50 -0
- package/dist/cjs/server/index.js +13 -1
- package/dist/cjs/session/SessionClient.js +152 -0
- package/dist/cjs/session/createSessionClient.js +26 -0
- package/dist/cjs/session/projectSessionState.js +75 -0
- package/dist/cjs/session/sessionClientHost.js +30 -0
- package/dist/cjs/session/socketLoader.js +55 -0
- package/dist/cjs/utils/ssoBounce.js +9 -9
- package/dist/cjs/utils/ssoEstablish.js +110 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/CrossDomainAuth.js +32 -42
- package/dist/esm/index.js +14 -0
- package/dist/esm/mixins/OxyServices.sso.js +36 -0
- package/dist/esm/mixins/OxyServices.user.js +50 -0
- package/dist/esm/server/index.js +10 -0
- package/dist/esm/session/SessionClient.js +148 -0
- package/dist/esm/session/createSessionClient.js +23 -0
- package/dist/esm/session/projectSessionState.js +69 -0
- package/dist/esm/session/sessionClientHost.js +27 -0
- package/dist/esm/session/socketLoader.js +19 -0
- package/dist/esm/utils/ssoBounce.js +9 -9
- package/dist/esm/utils/ssoEstablish.js +107 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/CrossDomainAuth.d.ts +33 -13
- package/dist/types/index.d.ts +7 -0
- package/dist/types/mixins/OxyServices.sso.d.ts +24 -0
- package/dist/types/mixins/OxyServices.user.d.ts +30 -0
- package/dist/types/server/index.d.ts +2 -0
- package/dist/types/session/SessionClient.d.ts +55 -0
- package/dist/types/session/createSessionClient.d.ts +23 -0
- package/dist/types/session/projectSessionState.d.ts +43 -0
- package/dist/types/session/sessionClientHost.d.ts +18 -0
- package/dist/types/session/socketLoader.d.ts +9 -0
- package/dist/types/utils/ssoBounce.d.ts +9 -9
- package/dist/types/utils/ssoEstablish.d.ts +85 -0
- package/package.json +1 -1
- package/src/CrossDomainAuth.ts +33 -44
- package/src/__tests__/crossDomainAuth.test.ts +33 -16
- package/src/index.ts +24 -0
- package/src/mixins/OxyServices.sso.ts +55 -0
- package/src/mixins/OxyServices.user.ts +54 -0
- package/src/mixins/__tests__/sso.test.ts +41 -0
- package/src/server/index.ts +12 -0
- package/src/session/SessionClient.ts +187 -0
- package/src/session/__tests__/SessionClient.diagnostics.test.ts +90 -0
- package/src/session/__tests__/SessionClient.httpIntegration.test.ts +65 -0
- package/src/session/__tests__/SessionClient.rest.test.ts +80 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +133 -0
- package/src/session/__tests__/SessionClient.state.test.ts +64 -0
- package/src/session/__tests__/sessionIntegration.test.ts +202 -0
- package/src/session/createSessionClient.ts +31 -0
- package/src/session/projectSessionState.ts +83 -0
- package/src/session/sessionClientHost.ts +32 -0
- package/src/session/socketLoader.ts +28 -0
- package/src/utils/__tests__/ssoEstablish.test.ts +204 -0
- package/src/utils/ssoBounce.ts +9 -9
- 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('
|
|
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
|
|
72
|
+
const result = await auth.signIn({
|
|
71
73
|
method: 'auto',
|
|
72
74
|
onMethodSelected: (method) => selected.push(method),
|
|
73
75
|
});
|
|
74
76
|
|
|
75
|
-
expect(
|
|
76
|
-
expect(selected).toEqual(['
|
|
77
|
-
expect(services.
|
|
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('
|
|
83
|
+
it('autoSignIn does not call FedCM (goes to redirect) regardless of browser support', async () => {
|
|
81
84
|
const services = createMockServices({
|
|
82
|
-
|
|
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(
|
|
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
|
-
|
|
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,60 @@ 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
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Get the authenticated VIEWER's bounded "follows-of-follows" user ids — the
|
|
827
|
+
* union of the accounts followed by the accounts the viewer follows (a
|
|
828
|
+
* two-hop walk of the follow graph), MINUS the viewer's own follows and the
|
|
829
|
+
* viewer themselves. The viewer is derived server-side from the SDK's auth
|
|
830
|
+
* token (never a param), so there is no target id to pass.
|
|
831
|
+
*
|
|
832
|
+
* Returns a bounded, lean list of ids meant to SEED a friends-of-friends
|
|
833
|
+
* feed (the consumer hydrates/ranks the posts itself), ordered by frequency
|
|
834
|
+
* (accounts followed by more of the viewer's follows first), then recency.
|
|
835
|
+
* An anonymous caller resolves to an empty array. Mirrors
|
|
836
|
+
* {@link getMutualUserIds}'s caching posture.
|
|
837
|
+
*/
|
|
838
|
+
async getFollowsOfFollowsIds(
|
|
839
|
+
params?: { limit?: number }
|
|
840
|
+
): Promise<string[]> {
|
|
841
|
+
try {
|
|
842
|
+
const query = buildPaginationParams(params || {});
|
|
843
|
+
const response = await this.makeRequest<{ data: string[] }>('GET', '/users/follows-of-follows-ids', query, {
|
|
844
|
+
cache: true,
|
|
845
|
+
cacheTTL: 2 * 60 * 1000, // 2 minutes cache
|
|
846
|
+
});
|
|
847
|
+
return response.data || [];
|
|
848
|
+
} catch (error) {
|
|
849
|
+
throw this.handleError(error);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
799
853
|
/**
|
|
800
854
|
* Get notifications
|
|
801
855
|
*/
|
|
@@ -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();
|
package/src/server/index.ts
CHANGED
|
@@ -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,187 @@
|
|
|
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
|
+
const parsed = deviceSessionSyncSchema.safeParse(raw);
|
|
95
|
+
// Log field-level type diagnostics ONLY — never values. The payload carries tokens and
|
|
96
|
+
// session ids; issue.path/code and the invalid_type expected/received TYPE names are safe,
|
|
97
|
+
// but zod messages can embed offending values for other codes, so they are omitted.
|
|
98
|
+
const issues = parsed.success
|
|
99
|
+
? []
|
|
100
|
+
: parsed.error.issues.map((issue) =>
|
|
101
|
+
issue.code === 'invalid_type'
|
|
102
|
+
? { path: issue.path.join('.'), code: issue.code, expected: issue.expected, received: issue.received }
|
|
103
|
+
: { path: issue.path.join('.'), code: issue.code },
|
|
104
|
+
);
|
|
105
|
+
const keys = raw && typeof raw === 'object' ? Object.keys(raw) : [];
|
|
106
|
+
logger.warn('[SessionClient] discarded invalid session sync', { component: 'SessionClient', issues, keys });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
this.applyState(sync.state);
|
|
110
|
+
if (sync.activeToken && this.state && sync.state.activeAccountId === this.state.activeAccountId) {
|
|
111
|
+
this.host.setTokens(sync.activeToken.accessToken);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async bootstrap(): Promise<void> {
|
|
116
|
+
const res = await this.host.makeRequest<unknown>('GET', '/session/device/state', undefined, { cache: false });
|
|
117
|
+
this.applySync(res);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async switchAccount(accountId: string): Promise<void> {
|
|
121
|
+
const res = await this.host.makeRequest<unknown>('POST', '/session/device/switch', { accountId }, { cache: false });
|
|
122
|
+
this.applySync(res);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async signOut(target: { accountId: string } | { all: true }): Promise<void> {
|
|
126
|
+
const res = await this.host.makeRequest<unknown>('POST', '/session/device/signout', target, { cache: false });
|
|
127
|
+
this.applySync(res);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async addCurrentAccount(): Promise<void> {
|
|
131
|
+
const res = await this.host.makeRequest<unknown>('POST', '/session/device/add', undefined, { cache: false });
|
|
132
|
+
this.applySync(res);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async start(): Promise<void> {
|
|
136
|
+
if (this.started) return;
|
|
137
|
+
this.started = true;
|
|
138
|
+
this.tokenUnsub = this.host.onTokensChanged((token) => {
|
|
139
|
+
if (token && this.socket && !this.socket.connected) {
|
|
140
|
+
this.socket.connect();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
await this.bootstrap();
|
|
144
|
+
await this.connectSocket();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
stop(): void {
|
|
148
|
+
this.started = false;
|
|
149
|
+
if (this.tokenUnsub) {
|
|
150
|
+
this.tokenUnsub();
|
|
151
|
+
this.tokenUnsub = null;
|
|
152
|
+
}
|
|
153
|
+
if (this.socket) {
|
|
154
|
+
this.socket.disconnect();
|
|
155
|
+
this.socket = null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private async connectSocket(): Promise<void> {
|
|
160
|
+
const io = await getSocketIO();
|
|
161
|
+
if (!io) {
|
|
162
|
+
logger.warn('[SessionClient] no socket.io-client; running REST-only (no realtime sync)', { component: 'SessionClient' });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (!this.started) return; // stopped while the dynamic import was in flight
|
|
166
|
+
const hasToken = Boolean(this.host.getAccessToken());
|
|
167
|
+
const socket = io(this.host.getBaseURL(), {
|
|
168
|
+
transports: ['websocket'],
|
|
169
|
+
autoConnect: hasToken,
|
|
170
|
+
auth: (cb: (data: { token: string }) => void) => {
|
|
171
|
+
cb({ token: this.host.getAccessToken() ?? '' });
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
socket.on('session_state', (payload: unknown) => {
|
|
175
|
+
const applied = this.applyState(payload);
|
|
176
|
+
if (applied) {
|
|
177
|
+
const active = this.state?.activeAccountId ?? null;
|
|
178
|
+
if (active && active !== this.host.getCurrentAccountId()) {
|
|
179
|
+
void this.bootstrap().catch((error) => {
|
|
180
|
+
logger.warn('[SessionClient] post-push token fetch failed', { component: 'SessionClient' }, error);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
this.socket = socket;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
2
|
+
import { SessionClient, type SessionClientHost } from '../SessionClient';
|
|
3
|
+
import { logger } from '../../utils/loggerUtils';
|
|
4
|
+
|
|
5
|
+
const STATE = (rev: number): DeviceSessionState => ({
|
|
6
|
+
deviceId: 'd1', accounts: [{ accountId: 'a1', sessionId: 's1', authuser: 0 }], activeAccountId: 'a1', revision: rev, updatedAt: 1720000000000,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
function makeHost(makeRequest: jest.Mock): SessionClientHost {
|
|
10
|
+
return {
|
|
11
|
+
makeRequest,
|
|
12
|
+
getBaseURL: () => 'http://test.invalid',
|
|
13
|
+
getAccessToken: () => 't',
|
|
14
|
+
onTokensChanged: () => () => undefined,
|
|
15
|
+
setTokens: jest.fn(),
|
|
16
|
+
getCurrentAccountId: () => null,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('SessionClient sync diagnostics', () => {
|
|
21
|
+
let warnSpy: jest.SpyInstance;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
warnSpy.mockRestore();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('logs the failing zod issue path + code when a nested field is the wrong type', async () => {
|
|
32
|
+
// authuser must be a non-negative integer; a string trips invalid_type at accounts[0].authuser.
|
|
33
|
+
const badAuthuser = { accountId: 'a1', sessionId: 's1', authuser: 'not-a-number' };
|
|
34
|
+
const state = { ...STATE(3), accounts: [badAuthuser] };
|
|
35
|
+
const makeRequest = jest.fn().mockResolvedValueOnce({ state, activeToken: null });
|
|
36
|
+
const c = new SessionClient(makeHost(makeRequest));
|
|
37
|
+
|
|
38
|
+
await c.bootstrap();
|
|
39
|
+
|
|
40
|
+
expect(c.getState()).toBeNull();
|
|
41
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
42
|
+
'[SessionClient] discarded invalid session sync',
|
|
43
|
+
expect.objectContaining({ component: 'SessionClient' }),
|
|
44
|
+
);
|
|
45
|
+
const context = warnSpy.mock.calls[0][1];
|
|
46
|
+
expect(context.issues).toEqual(
|
|
47
|
+
expect.arrayContaining([
|
|
48
|
+
expect.objectContaining({ path: 'state.accounts.0.authuser', code: 'invalid_type' }),
|
|
49
|
+
]),
|
|
50
|
+
);
|
|
51
|
+
// invalid_type issues carry TYPE names (safe), not values.
|
|
52
|
+
const authuserIssue = context.issues.find((i: { path: string }) => i.path === 'state.accounts.0.authuser');
|
|
53
|
+
expect(authuserIssue.received).toBe('string');
|
|
54
|
+
expect(authuserIssue.expected).toBe('number');
|
|
55
|
+
// Top-level envelope keys are summarized to catch drift.
|
|
56
|
+
expect(context.keys).toEqual(['state', 'activeToken']);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('never leaks token-like values into the logged diagnostics', async () => {
|
|
60
|
+
// A valid-looking accessToken alongside an otherwise-invalid state must not surface in the log.
|
|
61
|
+
const secretToken = 'jwt-SUPER-SECRET-ACCESS-TOKEN-abc123';
|
|
62
|
+
const makeRequest = jest.fn().mockResolvedValueOnce({
|
|
63
|
+
state: { deviceId: 'd1' /* missing required fields */ },
|
|
64
|
+
activeToken: { accessToken: secretToken, expiresAt: 'x' },
|
|
65
|
+
});
|
|
66
|
+
const c = new SessionClient(makeHost(makeRequest));
|
|
67
|
+
|
|
68
|
+
await c.bootstrap();
|
|
69
|
+
|
|
70
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
71
|
+
const serialized = JSON.stringify(warnSpy.mock.calls[0]);
|
|
72
|
+
expect(serialized).not.toContain(secretToken);
|
|
73
|
+
expect(serialized).not.toContain('SUPER-SECRET');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('reports envelope drift via keys and a top-level invalid_type when raw is undefined', async () => {
|
|
77
|
+
const makeRequest = jest.fn().mockResolvedValueOnce(undefined);
|
|
78
|
+
const c = new SessionClient(makeHost(makeRequest));
|
|
79
|
+
|
|
80
|
+
await c.bootstrap();
|
|
81
|
+
|
|
82
|
+
const context = warnSpy.mock.calls[0][1];
|
|
83
|
+
expect(context.keys).toEqual([]);
|
|
84
|
+
expect(context.issues).toEqual(
|
|
85
|
+
expect.arrayContaining([
|
|
86
|
+
expect.objectContaining({ path: '', code: 'invalid_type', expected: 'object', received: 'undefined' }),
|
|
87
|
+
]),
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
2
|
+
import { OxyServices } from '../../OxyServices';
|
|
3
|
+
import { SessionClient } from '../SessionClient';
|
|
4
|
+
import { createSessionClientHost } from '../sessionClientHost';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Real-stack integration test: a genuine `HttpService` (via `OxyServices`) →
|
|
8
|
+
* `createSessionClientHost` → `SessionClient`, with `global.fetch` stubbed to
|
|
9
|
+
* return the EXACT wire body the server sends for `GET /session/device/state`:
|
|
10
|
+
* `{ data: { state, activeToken } }`.
|
|
11
|
+
*
|
|
12
|
+
* This is the test that would have caught the P0: `HttpService.unwrapResponse`
|
|
13
|
+
* strips the outer `{ data }` envelope, so `makeRequest` already returns
|
|
14
|
+
* `{ state, activeToken }`. If `SessionClient` reads `.data` a SECOND time (or
|
|
15
|
+
* if `HttpService` stops unwrapping), the sync silently discards and neither
|
|
16
|
+
* the state nor the token reach the client — exactly the prod symptom.
|
|
17
|
+
*/
|
|
18
|
+
const WIRE_STATE: DeviceSessionState = {
|
|
19
|
+
deviceId: 'device-real',
|
|
20
|
+
accounts: [{ accountId: 'acct-1', sessionId: 'sess-1', authuser: 0 }],
|
|
21
|
+
activeAccountId: 'acct-1',
|
|
22
|
+
revision: 42,
|
|
23
|
+
updatedAt: 1720000000000,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const ROUTE_BODY = {
|
|
27
|
+
data: {
|
|
28
|
+
state: WIRE_STATE,
|
|
29
|
+
activeToken: { accessToken: 'planted-access-token', expiresAt: '2026-01-01T00:00:00.000Z' },
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe('SessionClient over a real HttpService (unwrap contract)', () => {
|
|
34
|
+
const originalFetch = global.fetch;
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
global.fetch = originalFetch;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('bootstrap() applies the server state and plants the active token through the real unwrap path', async () => {
|
|
41
|
+
const fetchMock = jest.fn(async () =>
|
|
42
|
+
new Response(JSON.stringify(ROUTE_BODY), {
|
|
43
|
+
status: 200,
|
|
44
|
+
headers: { 'content-type': 'application/json' },
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
48
|
+
|
|
49
|
+
const oxy = new OxyServices({ baseURL: 'http://api.test.invalid' });
|
|
50
|
+
const host = createSessionClientHost(oxy);
|
|
51
|
+
const client = new SessionClient(host);
|
|
52
|
+
|
|
53
|
+
await client.bootstrap();
|
|
54
|
+
|
|
55
|
+
// The exact URL the server route serves.
|
|
56
|
+
const calledUrl = String((fetchMock.mock.calls[0] ?? [])[0]);
|
|
57
|
+
expect(calledUrl).toContain('/session/device/state');
|
|
58
|
+
|
|
59
|
+
// State reached the client (would be null if `.data` were read twice).
|
|
60
|
+
expect(client.getState()).toEqual(WIRE_STATE);
|
|
61
|
+
|
|
62
|
+
// Active token planted host-side (would be absent on a discarded sync).
|
|
63
|
+
expect(oxy.getAccessToken()).toBe('planted-access-token');
|
|
64
|
+
});
|
|
65
|
+
});
|