@oxyhq/core 5.1.0 → 5.1.1

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.
@@ -48,6 +48,17 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
48
48
  * server-to-server `/users/by-ids` bulk fetch with a bearer service token.
49
49
  */
50
50
  makeServiceRequest: <R = unknown>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: unknown, userId?: string) => Promise<R>;
51
+ /**
52
+ * Raw service credentials stored by `configureServiceAuth()` on the auth
53
+ * mixin (earlier in the pipeline). Surfaced here via `declare` — for the
54
+ * same typing reason as `makeServiceRequest` above — so `getUsersByIds` can
55
+ * detect whether this instance is service-configured (a backend) and pick
56
+ * the bearer-service path, or fall back to the user-session path (a browser/
57
+ * RN client). Both are `null` until `configureServiceAuth(apiKey, apiSecret)`
58
+ * is called.
59
+ */
60
+ _serviceApiKey: string | null;
61
+ _serviceApiSecret: string | null;
51
62
  /**
52
63
  * Get profile by username
53
64
  */
@@ -138,16 +149,28 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
138
149
  * by `id`); each is run through `normalizeUserIdentity`, matching
139
150
  * `getUserById`.
140
151
  *
141
- * **Service-token auth (required).** `/users/by-ids` is a server-to-server
142
- * bulk fetch of PUBLIC user data and is called via `makeServiceRequest`,
143
- * which attaches `Authorization: Bearer <serviceToken>`. oxy-api's CSRF
144
- * middleware skips bearer-authenticated requests, so the calling client
145
- * MUST be service-configured (`configureServiceAuth(apiKey, apiSecret)`)
146
- * before invoking this method; otherwise `getServiceToken()` throws because
147
- * no credentials are available. (A plain user-session request fails here:
148
- * server-to-server there is no cookie jar, so the auto-attached
149
- * `X-CSRF-Token` has no matching cookie and oxy-api rejects the POST with
150
- * 403 "CSRF token missing".)
152
+ * **Dual-mode auth.** `/users/by-ids` is `optionalUserOrServiceAuth` on
153
+ * oxy-api: it accepts a service token, a user session, or an anonymous
154
+ * caller, and returns the SAME public `{ data: PublicUserProfile[] }`
155
+ * payload (canonical `name.displayName` + `_count`) in every case — no
156
+ * viewer-specific fields. This method picks the path automatically:
157
+ * - **Service-configured host (backend):** when `configureServiceAuth(apiKey,
158
+ * apiSecret)` has been called, the chunk is fetched via `makeServiceRequest`
159
+ * (attaches `Authorization: Bearer <serviceToken>`). This is the
160
+ * server-to-server feed/notification hydration path (e.g. Mention's
161
+ * `PostHydrationService`) and is unchanged.
162
+ * - **Plain client (browser / React Native with a user session):** when no
163
+ * service credentials are configured, the chunk is fetched via
164
+ * `makeRequest`, which attaches the configured user bearer. oxy-api's CSRF
165
+ * middleware skips bearer-authenticated writes, and `makeRequest` only
166
+ * fetches a CSRF token for cookie-only (no-bearer) state-changing requests,
167
+ * so the user-bearer POST is sent without CSRF and succeeds. Previously
168
+ * this method always used the service path, so every client-side caller
169
+ * silently received `[]` because `getServiceToken()` had no credentials.
170
+ *
171
+ * Both paths run results through `normalizeUserIdentity` and unwrap the
172
+ * API's `{ data }` envelope identically (`makeServiceRequest` is literally
173
+ * `makeRequest` plus a bearer service header).
151
174
  *
152
175
  * Resilience: chunks are independent. A failed chunk is logged and skipped
153
176
  * — the method returns every user that resolved successfully rather than
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "5.1.0",
3
+ "version": "5.1.1",
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",
@@ -88,6 +88,18 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
88
88
  userId?: string,
89
89
  ) => Promise<R>;
90
90
 
91
+ /**
92
+ * Raw service credentials stored by `configureServiceAuth()` on the auth
93
+ * mixin (earlier in the pipeline). Surfaced here via `declare` — for the
94
+ * same typing reason as `makeServiceRequest` above — so `getUsersByIds` can
95
+ * detect whether this instance is service-configured (a backend) and pick
96
+ * the bearer-service path, or fall back to the user-session path (a browser/
97
+ * RN client). Both are `null` until `configureServiceAuth(apiKey, apiSecret)`
98
+ * is called.
99
+ */
100
+ declare _serviceApiKey: string | null;
101
+ declare _serviceApiSecret: string | null;
102
+
91
103
  /**
92
104
  * Get profile by username
93
105
  */
@@ -349,16 +361,28 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
349
361
  * by `id`); each is run through `normalizeUserIdentity`, matching
350
362
  * `getUserById`.
351
363
  *
352
- * **Service-token auth (required).** `/users/by-ids` is a server-to-server
353
- * bulk fetch of PUBLIC user data and is called via `makeServiceRequest`,
354
- * which attaches `Authorization: Bearer <serviceToken>`. oxy-api's CSRF
355
- * middleware skips bearer-authenticated requests, so the calling client
356
- * MUST be service-configured (`configureServiceAuth(apiKey, apiSecret)`)
357
- * before invoking this method; otherwise `getServiceToken()` throws because
358
- * no credentials are available. (A plain user-session request fails here:
359
- * server-to-server there is no cookie jar, so the auto-attached
360
- * `X-CSRF-Token` has no matching cookie and oxy-api rejects the POST with
361
- * 403 "CSRF token missing".)
364
+ * **Dual-mode auth.** `/users/by-ids` is `optionalUserOrServiceAuth` on
365
+ * oxy-api: it accepts a service token, a user session, or an anonymous
366
+ * caller, and returns the SAME public `{ data: PublicUserProfile[] }`
367
+ * payload (canonical `name.displayName` + `_count`) in every case — no
368
+ * viewer-specific fields. This method picks the path automatically:
369
+ * - **Service-configured host (backend):** when `configureServiceAuth(apiKey,
370
+ * apiSecret)` has been called, the chunk is fetched via `makeServiceRequest`
371
+ * (attaches `Authorization: Bearer <serviceToken>`). This is the
372
+ * server-to-server feed/notification hydration path (e.g. Mention's
373
+ * `PostHydrationService`) and is unchanged.
374
+ * - **Plain client (browser / React Native with a user session):** when no
375
+ * service credentials are configured, the chunk is fetched via
376
+ * `makeRequest`, which attaches the configured user bearer. oxy-api's CSRF
377
+ * middleware skips bearer-authenticated writes, and `makeRequest` only
378
+ * fetches a CSRF token for cookie-only (no-bearer) state-changing requests,
379
+ * so the user-bearer POST is sent without CSRF and succeeds. Previously
380
+ * this method always used the service path, so every client-side caller
381
+ * silently received `[]` because `getServiceToken()` had no credentials.
382
+ *
383
+ * Both paths run results through `normalizeUserIdentity` and unwrap the
384
+ * API's `{ data }` envelope identically (`makeServiceRequest` is literally
385
+ * `makeRequest` plus a bearer service header).
362
386
  *
363
387
  * Resilience: chunks are independent. A failed chunk is logged and skipped
364
388
  * — the method returns every user that resolved successfully rather than
@@ -381,15 +405,23 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
381
405
  chunks.push(uniqueIds.slice(i, i + USERS_BY_IDS_CHUNK_SIZE));
382
406
  }
383
407
 
408
+ // A backend that called configureServiceAuth() uses the bearer-service
409
+ // path; any other caller (browser / RN with a user session) uses the
410
+ // user-bearer path. See the method doc for why the user path is CSRF-safe.
411
+ const useServiceAuth = Boolean(this._serviceApiKey && this._serviceApiSecret);
412
+
384
413
  // Run chunks concurrently; a single chunk failure must not sink the rest.
385
414
  const settled = await Promise.all(
386
415
  chunks.map(async (chunk): Promise<User[]> => {
387
416
  try {
388
- const users = await this.makeServiceRequest<User[]>('POST', '/users/by-ids', { ids: chunk });
417
+ const users = useServiceAuth
418
+ ? await this.makeServiceRequest<User[]>('POST', '/users/by-ids', { ids: chunk })
419
+ : await this.makeRequest<User[]>('POST', '/users/by-ids', { ids: chunk }, { cache: false });
389
420
  return Array.isArray(users) ? users.map((user) => normalizeUserIdentity(user)) : [];
390
421
  } catch (error: unknown) {
391
422
  logger.warn('getUsersByIds: chunk failed, continuing with remaining chunks', {
392
423
  method: 'getUsersByIds',
424
+ mode: useServiceAuth ? 'service' : 'user',
393
425
  chunkSize: chunk.length,
394
426
  status: extractErrorStatus(error),
395
427
  error: error instanceof Error ? error.message : String(error),
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `getUsersByIds` dual-mode auth tests.
3
+ *
4
+ * `POST /users/by-ids` is `optionalUserOrServiceAuth` on oxy-api and returns
5
+ * the identical public `{ data: PublicUserProfile[] }` shape for both a service
6
+ * token and a user session. The SDK method must therefore:
7
+ * - use the bearer-SERVICE path (`makeServiceRequest`) when the instance was
8
+ * configured via `configureServiceAuth()` (the backend / hydration case), and
9
+ * - use the USER path (`makeRequest('POST', ..., { cache: false })`) otherwise
10
+ * (a browser / RN client with only a user session). Before the dual-mode fix
11
+ * the method always took the service path, so every client-side caller got
12
+ * `[]` because `getServiceToken()` had no credentials.
13
+ *
14
+ * Both paths must dedup + chunk, map every result through `normalizeUserIdentity`
15
+ * (so `name.displayName` is present), and tolerate a per-chunk failure without
16
+ * sinking the rest. `makeRequest` / `makeServiceRequest` are stubbed so the
17
+ * tests run with no network.
18
+ */
19
+
20
+ import { OxyServices } from '../../OxyServices';
21
+ import type { User } from '../../models/interfaces';
22
+
23
+ const makeRawUser = (id: string): User =>
24
+ // The wire payload is a PublicUserProfile with server-owned name.displayName.
25
+ // Cast through the User shape the API returns post-unwrap.
26
+ ({
27
+ id,
28
+ username: `user_${id}`,
29
+ name: { displayName: `Display ${id}` },
30
+ _count: { followers: 1, following: 2 },
31
+ }) as unknown as User;
32
+
33
+ describe('OxyServices.getUsersByIds — dual-mode auth', () => {
34
+ let oxy: OxyServices;
35
+ let makeRequestSpy: jest.SpyInstance;
36
+ let makeServiceRequestSpy: jest.SpyInstance;
37
+
38
+ beforeEach(() => {
39
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
40
+ makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
41
+ makeServiceRequestSpy = jest.spyOn(
42
+ oxy as unknown as { makeServiceRequest: jest.Mock },
43
+ 'makeServiceRequest',
44
+ );
45
+ });
46
+
47
+ afterEach(() => {
48
+ jest.restoreAllMocks();
49
+ });
50
+
51
+ it('returns [] and performs no network call for empty / whitespace input', async () => {
52
+ await expect(oxy.getUsersByIds([])).resolves.toEqual([]);
53
+ await expect(oxy.getUsersByIds(['', ' '])).resolves.toEqual([]);
54
+ expect(makeRequestSpy).not.toHaveBeenCalled();
55
+ expect(makeServiceRequestSpy).not.toHaveBeenCalled();
56
+ });
57
+
58
+ describe('without service credentials (client / user-session path)', () => {
59
+ it('uses makeRequest with the user bearer and cache:false', async () => {
60
+ makeRequestSpy.mockResolvedValueOnce([makeRawUser('a'), makeRawUser('b')]);
61
+
62
+ const result = await oxy.getUsersByIds(['a', 'b']);
63
+
64
+ expect(makeServiceRequestSpy).not.toHaveBeenCalled();
65
+ expect(makeRequestSpy).toHaveBeenCalledTimes(1);
66
+ expect(makeRequestSpy).toHaveBeenCalledWith(
67
+ 'POST',
68
+ '/users/by-ids',
69
+ { ids: ['a', 'b'] },
70
+ { cache: false },
71
+ );
72
+ // normalizeUserIdentity ran on every entry (display name preserved).
73
+ expect(result).toHaveLength(2);
74
+ expect(result[0].name.displayName).toBe('Display a');
75
+ expect(result[1].id).toBe('b');
76
+ });
77
+
78
+ it('de-duplicates ids and drops blanks before the request', async () => {
79
+ makeRequestSpy.mockResolvedValueOnce([makeRawUser('a')]);
80
+
81
+ await oxy.getUsersByIds(['a', 'a', ' ', 'a']);
82
+
83
+ expect(makeRequestSpy).toHaveBeenCalledTimes(1);
84
+ expect(makeRequestSpy).toHaveBeenCalledWith(
85
+ 'POST',
86
+ '/users/by-ids',
87
+ { ids: ['a'] },
88
+ { cache: false },
89
+ );
90
+ });
91
+ });
92
+
93
+ describe('with service credentials (backend / hydration path)', () => {
94
+ beforeEach(() => {
95
+ oxy.configureServiceAuth('oxy_dk_key', 'secret');
96
+ });
97
+
98
+ it('uses makeServiceRequest and never the user path — unchanged behavior', async () => {
99
+ makeServiceRequestSpy.mockResolvedValueOnce([makeRawUser('a'), makeRawUser('b')]);
100
+
101
+ const result = await oxy.getUsersByIds(['a', 'b']);
102
+
103
+ expect(makeRequestSpy).not.toHaveBeenCalled();
104
+ expect(makeServiceRequestSpy).toHaveBeenCalledTimes(1);
105
+ expect(makeServiceRequestSpy).toHaveBeenCalledWith('POST', '/users/by-ids', {
106
+ ids: ['a', 'b'],
107
+ });
108
+ expect(result).toHaveLength(2);
109
+ expect(result[0].name.displayName).toBe('Display a');
110
+ });
111
+ });
112
+
113
+ describe('chunking + resilience (applies to both modes)', () => {
114
+ it('chunks at 100 ids per request on the user path and flattens results', async () => {
115
+ const ids = Array.from({ length: 250 }, (_, i) => `id-${i}`);
116
+ makeRequestSpy.mockImplementation(
117
+ async (
118
+ _method: string,
119
+ _url: string,
120
+ data?: { ids: string[] },
121
+ ): Promise<User[]> => (data?.ids ?? []).map((id) => makeRawUser(id)),
122
+ );
123
+
124
+ const result = await oxy.getUsersByIds(ids);
125
+
126
+ // 250 unique ids => 100 + 100 + 50 across three POSTs.
127
+ expect(makeRequestSpy).toHaveBeenCalledTimes(3);
128
+ const chunkSizes = makeRequestSpy.mock.calls.map(
129
+ (call) => (call[2] as { ids: string[] }).ids.length,
130
+ );
131
+ expect(chunkSizes).toEqual([100, 100, 50]);
132
+ expect(result).toHaveLength(250);
133
+ });
134
+
135
+ it('skips a failed chunk and returns the users that resolved (user path)', async () => {
136
+ const ids = Array.from({ length: 150 }, (_, i) => `id-${i}`);
137
+ makeRequestSpy
138
+ .mockResolvedValueOnce([makeRawUser('id-0')])
139
+ .mockRejectedValueOnce(new Error('chunk boom'));
140
+
141
+ const result = await oxy.getUsersByIds(ids);
142
+
143
+ expect(makeRequestSpy).toHaveBeenCalledTimes(2);
144
+ // The failed second chunk contributes nothing; the first survives.
145
+ expect(result).toHaveLength(1);
146
+ expect(result[0].id).toBe('id-0');
147
+ });
148
+ });
149
+ });