@oxyhq/core 5.3.0 → 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/mixins/OxyServices.user.js +26 -0
- package/dist/cjs/session/SessionClient.js +15 -5
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/mixins/OxyServices.user.js +26 -0
- package/dist/esm/session/SessionClient.js +15 -5
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/mixins/OxyServices.user.d.ts +16 -0
- package/package.json +1 -1
- package/src/mixins/OxyServices.user.ts +28 -0
- package/src/session/SessionClient.ts +21 -9
- 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 +4 -3
- package/src/session/__tests__/SessionClient.socket.test.ts +3 -2
- package/src/session/__tests__/sessionIntegration.test.ts +1 -1
|
@@ -341,6 +341,22 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
|
|
|
341
341
|
getMutualUserIds(params?: {
|
|
342
342
|
limit?: number;
|
|
343
343
|
}): Promise<string[]>;
|
|
344
|
+
/**
|
|
345
|
+
* Get the authenticated VIEWER's bounded "follows-of-follows" user ids — the
|
|
346
|
+
* union of the accounts followed by the accounts the viewer follows (a
|
|
347
|
+
* two-hop walk of the follow graph), MINUS the viewer's own follows and the
|
|
348
|
+
* viewer themselves. The viewer is derived server-side from the SDK's auth
|
|
349
|
+
* token (never a param), so there is no target id to pass.
|
|
350
|
+
*
|
|
351
|
+
* Returns a bounded, lean list of ids meant to SEED a friends-of-friends
|
|
352
|
+
* feed (the consumer hydrates/ranks the posts itself), ordered by frequency
|
|
353
|
+
* (accounts followed by more of the viewer's follows first), then recency.
|
|
354
|
+
* An anonymous caller resolves to an empty array. Mirrors
|
|
355
|
+
* {@link getMutualUserIds}'s caching posture.
|
|
356
|
+
*/
|
|
357
|
+
getFollowsOfFollowsIds(params?: {
|
|
358
|
+
limit?: number;
|
|
359
|
+
}): Promise<string[]>;
|
|
344
360
|
/**
|
|
345
361
|
* Get notifications
|
|
346
362
|
*/
|
package/package.json
CHANGED
|
@@ -822,6 +822,34 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
822
822
|
}
|
|
823
823
|
}
|
|
824
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
|
+
|
|
825
853
|
/**
|
|
826
854
|
* Get notifications
|
|
827
855
|
*/
|
|
@@ -91,7 +91,19 @@ export class SessionClient {
|
|
|
91
91
|
private applySync(raw: unknown): void {
|
|
92
92
|
const sync = safeParseContract(deviceSessionSyncSchema, raw);
|
|
93
93
|
if (!sync) {
|
|
94
|
-
|
|
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 });
|
|
95
107
|
return;
|
|
96
108
|
}
|
|
97
109
|
this.applyState(sync.state);
|
|
@@ -101,23 +113,23 @@ export class SessionClient {
|
|
|
101
113
|
}
|
|
102
114
|
|
|
103
115
|
async bootstrap(): Promise<void> {
|
|
104
|
-
const res = await this.host.makeRequest<
|
|
105
|
-
this.applySync(res
|
|
116
|
+
const res = await this.host.makeRequest<unknown>('GET', '/session/device/state', undefined, { cache: false });
|
|
117
|
+
this.applySync(res);
|
|
106
118
|
}
|
|
107
119
|
|
|
108
120
|
async switchAccount(accountId: string): Promise<void> {
|
|
109
|
-
const res = await this.host.makeRequest<
|
|
110
|
-
this.applySync(res
|
|
121
|
+
const res = await this.host.makeRequest<unknown>('POST', '/session/device/switch', { accountId }, { cache: false });
|
|
122
|
+
this.applySync(res);
|
|
111
123
|
}
|
|
112
124
|
|
|
113
125
|
async signOut(target: { accountId: string } | { all: true }): Promise<void> {
|
|
114
|
-
const res = await this.host.makeRequest<
|
|
115
|
-
this.applySync(res
|
|
126
|
+
const res = await this.host.makeRequest<unknown>('POST', '/session/device/signout', target, { cache: false });
|
|
127
|
+
this.applySync(res);
|
|
116
128
|
}
|
|
117
129
|
|
|
118
130
|
async addCurrentAccount(): Promise<void> {
|
|
119
|
-
const res = await this.host.makeRequest<
|
|
120
|
-
this.applySync(res
|
|
131
|
+
const res = await this.host.makeRequest<unknown>('POST', '/session/device/add', undefined, { cache: false });
|
|
132
|
+
this.applySync(res);
|
|
121
133
|
}
|
|
122
134
|
|
|
123
135
|
async start(): Promise<void> {
|
|
@@ -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
|
+
});
|
|
@@ -16,8 +16,9 @@ function makeHost(makeRequest: jest.Mock): SessionClientHost {
|
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
|
|
19
|
+
// `makeRequest` (HttpService) already strips the server's outer `{ data }` envelope, so it
|
|
20
|
+
// returns the unwrapped sync body directly — that is exactly what SessionClient consumes.
|
|
21
|
+
const SYNC = (rev: number) => ({ state: STATE(rev), activeToken: { accessToken: `jwt-${rev}`, expiresAt: 'x' } });
|
|
21
22
|
|
|
22
23
|
describe('SessionClient REST', () => {
|
|
23
24
|
it('bootstrap GETs /session/device/state and applies it', async () => {
|
|
@@ -69,7 +70,7 @@ describe('SessionClient REST', () => {
|
|
|
69
70
|
});
|
|
70
71
|
|
|
71
72
|
it('applies state but does not plant a token when activeToken is null', async () => {
|
|
72
|
-
const makeRequest = jest.fn().mockResolvedValueOnce({
|
|
73
|
+
const makeRequest = jest.fn().mockResolvedValueOnce({ state: STATE(7), activeToken: null });
|
|
73
74
|
const host = makeHost(makeRequest);
|
|
74
75
|
const c = new SessionClient(host);
|
|
75
76
|
await c.bootstrap();
|
|
@@ -21,8 +21,9 @@ jest.mock('socket.io-client', () => ({ __esModule: true, io: (...args: unknown[]
|
|
|
21
21
|
import { SessionClient, type SessionClientHost } from '../SessionClient';
|
|
22
22
|
|
|
23
23
|
const STATE = (rev: number): DeviceSessionState => ({ deviceId: 'd1', accounts: [{ accountId: 'a1', sessionId: 's1', authuser: 0 }], activeAccountId: 'a1', revision: rev, updatedAt: 1720000000000 });
|
|
24
|
-
//
|
|
25
|
-
|
|
24
|
+
// `makeRequest` (HttpService) already strips the server's outer `{ data }` envelope, so it
|
|
25
|
+
// returns the unwrapped sync body directly — that is exactly what SessionClient consumes.
|
|
26
|
+
const SYNC = (rev: number) => ({ state: STATE(rev), activeToken: { accessToken: `jwt-${rev}`, expiresAt: 'x' } });
|
|
26
27
|
|
|
27
28
|
function makeHost(over: Partial<SessionClientHost> = {}): SessionClientHost {
|
|
28
29
|
return {
|
|
@@ -191,7 +191,7 @@ describe('createSessionClient', () => {
|
|
|
191
191
|
|
|
192
192
|
test('uses the injected transport (not a hard-coded one) when the client bootstraps', async () => {
|
|
193
193
|
const oxy = fakeOxy();
|
|
194
|
-
oxy.makeRequest.mockResolvedValue({
|
|
194
|
+
oxy.makeRequest.mockResolvedValue({ state, activeToken: null });
|
|
195
195
|
const transport = fakeTransport();
|
|
196
196
|
|
|
197
197
|
const { client } = createSessionClient(oxy as never, transport);
|