@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -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
- logger.warn('[SessionClient] discarded invalid session 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 });
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<{ data?: unknown }>('GET', '/session/device/state', undefined, { cache: false });
105
- this.applySync(res?.data);
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<{ data?: unknown }>('POST', '/session/device/switch', { accountId }, { cache: false });
110
- this.applySync(res?.data);
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<{ data?: unknown }>('POST', '/session/device/signout', target, { cache: false });
115
- this.applySync(res?.data);
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<{ data?: unknown }>('POST', '/session/device/add', undefined, { cache: false });
120
- this.applySync(res?.data);
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
- // 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' } } });
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({ data: { state: STATE(7), activeToken: null } });
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
- // 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' } } });
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({ data: { state, activeToken: null } });
194
+ oxy.makeRequest.mockResolvedValue({ state, activeToken: null });
195
195
  const transport = fakeTransport();
196
196
 
197
197
  const { client } = createSessionClient(oxy as never, transport);