@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/mixins/OxyServices.user.js +30 -11
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/mixins/OxyServices.user.js +30 -11
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/mixins/OxyServices.user.d.ts +33 -10
- package/package.json +1 -1
- package/src/mixins/OxyServices.user.ts +43 -11
- package/src/mixins/__tests__/getUsersByIds.test.ts +149 -0
|
@@ -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
|
-
* **
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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
|
@@ -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
|
-
* **
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
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 =
|
|
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
|
+
});
|