@oxyhq/core 5.0.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.accounts.js +42 -6
- package/dist/cjs/mixins/OxyServices.assets.js +66 -0
- package/dist/cjs/mixins/OxyServices.user.js +30 -11
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/mixins/OxyServices.accounts.js +42 -6
- package/dist/esm/mixins/OxyServices.assets.js +66 -0
- package/dist/esm/mixins/OxyServices.user.js +30 -11
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/mixins/OxyServices.accounts.d.ts +13 -4
- package/dist/types/mixins/OxyServices.assets.d.ts +42 -1
- package/dist/types/mixins/OxyServices.user.d.ts +33 -10
- package/dist/types/models/interfaces.d.ts +18 -0
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/mixins/OxyServices.accounts.ts +51 -6
- package/src/mixins/OxyServices.assets.ts +95 -1
- package/src/mixins/OxyServices.user.ts +43 -11
- package/src/mixins/__tests__/OxyServices.serviceAssetMetadata.test.ts +116 -0
- package/src/mixins/__tests__/accounts.test.ts +66 -16
- package/src/mixins/__tests__/getUsersByIds.test.ts +149 -0
- package/src/models/interfaces.ts +19 -0
|
@@ -176,57 +176,100 @@ describe('OxyServices.accounts', () => {
|
|
|
176
176
|
});
|
|
177
177
|
|
|
178
178
|
describe('switchToAccount', () => {
|
|
179
|
+
// The switch route NO LONGER returns `authuser` or sets the device cookie —
|
|
180
|
+
// it can't (it's at /accounts/*, outside the Path=/auth cookie scope). The SDK
|
|
181
|
+
// establishes the cookie + resolves the slot via a follow-up POST /auth/session.
|
|
179
182
|
const switchResponse: SwitchAccountResult = {
|
|
180
183
|
sessionId: 'sess_switch',
|
|
181
184
|
deviceId: 'dev_switch',
|
|
182
185
|
expiresAt: '2026-06-30T01:00:00.000Z',
|
|
183
186
|
accessToken: 'access_switch',
|
|
184
187
|
user: { id: 'acc1', username: 'oxy-org', name: { displayName: 'Oxy Org' } },
|
|
185
|
-
authuser: 2,
|
|
186
188
|
};
|
|
189
|
+
// POST /auth/session establishes the cookie in a correctly-allocated NEW slot
|
|
190
|
+
// and returns that slot + a fresh access token off the same session.
|
|
191
|
+
const sessionResponse = { accessToken: 'access_session', authuser: 1 };
|
|
192
|
+
|
|
193
|
+
// Route makeRequest by path: the switch call vs the /auth/session establishment.
|
|
194
|
+
const routeByPath = (response = switchResponse) =>
|
|
195
|
+
makeRequestSpy.mockImplementation((_method: string, path: string) =>
|
|
196
|
+
Promise.resolve(path === '/auth/session' ? sessionResponse : response),
|
|
197
|
+
);
|
|
187
198
|
|
|
188
|
-
it('posts to /:id/switch
|
|
199
|
+
it('posts to /:id/switch then establishes the cookie via POST /auth/session, planting both tokens and sweeping the cache', async () => {
|
|
189
200
|
const setTokensSpy = jest.spyOn(oxy, 'setTokens');
|
|
190
201
|
const clearCacheSpy = jest.spyOn(oxy, 'clearCache');
|
|
191
|
-
|
|
202
|
+
routeByPath();
|
|
192
203
|
|
|
193
204
|
const result = await oxy.switchToAccount('acc1');
|
|
194
205
|
|
|
195
|
-
//
|
|
206
|
+
// Switch request shape: POST, exact path, no body, cache disabled.
|
|
196
207
|
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
197
208
|
'POST',
|
|
198
209
|
'/accounts/acc1/switch',
|
|
199
210
|
undefined,
|
|
200
211
|
expect.objectContaining({ cache: false }),
|
|
201
212
|
);
|
|
213
|
+
// Then the canonical refresh-cookie establishment under /auth (where the
|
|
214
|
+
// device's oxy_rt_* slots ARE visible, so a NEW slot is allocated).
|
|
215
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
216
|
+
'POST',
|
|
217
|
+
'/auth/session',
|
|
218
|
+
undefined,
|
|
219
|
+
expect.objectContaining({ cache: false }),
|
|
220
|
+
);
|
|
202
221
|
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
expect(setTokensSpy).
|
|
206
|
-
expect(oxy.getAccessToken()).toBe('
|
|
222
|
+
// The switch token is planted first; /auth/session's fresh token re-planted.
|
|
223
|
+
expect(setTokensSpy).toHaveBeenNthCalledWith(1, 'access_switch');
|
|
224
|
+
expect(setTokensSpy).toHaveBeenNthCalledWith(2, 'access_session');
|
|
225
|
+
expect(oxy.getAccessToken()).toBe('access_session');
|
|
207
226
|
expect(oxy.hasValidToken()).toBe(true);
|
|
208
227
|
|
|
209
|
-
//
|
|
210
|
-
// new account, AND it happens AFTER the token is planted.
|
|
228
|
+
// Cache swept once, AFTER both tokens are planted.
|
|
211
229
|
expect(clearCacheSpy).toHaveBeenCalledTimes(1);
|
|
212
|
-
expect(setTokensSpy.mock.invocationCallOrder[
|
|
230
|
+
expect(setTokensSpy.mock.invocationCallOrder[1]).toBeLessThan(
|
|
213
231
|
clearCacheSpy.mock.invocationCallOrder[0],
|
|
214
232
|
);
|
|
215
233
|
|
|
216
|
-
// The returned session carries the target account (id-normalised)
|
|
217
|
-
|
|
218
|
-
expect(result.
|
|
234
|
+
// The returned session carries the target account (id-normalised) and the
|
|
235
|
+
// slot resolved by /auth/session — NEVER the clobbering slot 0.
|
|
236
|
+
expect(result.sessionId).toBe('sess_switch');
|
|
237
|
+
expect(result.user).toEqual({ id: 'acc1', username: 'oxy-org', name: { displayName: 'Oxy Org' } });
|
|
238
|
+
expect(result.authuser).toBe(1);
|
|
219
239
|
|
|
220
240
|
setTokensSpy.mockRestore();
|
|
221
241
|
clearCacheSpy.mockRestore();
|
|
222
242
|
});
|
|
223
243
|
|
|
224
244
|
it('URL-encodes the accountId path segment', async () => {
|
|
225
|
-
|
|
245
|
+
routeByPath();
|
|
226
246
|
await oxy.switchToAccount('a b/c');
|
|
227
247
|
expect(makeRequestSpy.mock.calls[0][1]).toBe('/accounts/a%20b%2Fc/switch');
|
|
228
248
|
});
|
|
229
249
|
|
|
250
|
+
it('keeps the switch active in-session when /auth/session fails (best-effort cookie)', async () => {
|
|
251
|
+
const setTokensSpy = jest.spyOn(oxy, 'setTokens');
|
|
252
|
+
const clearCacheSpy = jest.spyOn(oxy, 'clearCache');
|
|
253
|
+
makeRequestSpy.mockImplementation((_method: string, path: string) =>
|
|
254
|
+
path === '/auth/session'
|
|
255
|
+
? Promise.reject(Object.assign(new Error('origin'), { response: { status: 403 } }))
|
|
256
|
+
: Promise.resolve(switchResponse),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const result = await oxy.switchToAccount('acc1');
|
|
260
|
+
|
|
261
|
+
// The in-session switch survives: the switch token stays planted and the
|
|
262
|
+
// cache is still swept. The switched account just won't survive a reload
|
|
263
|
+
// until the cookie is next established (no authuser resolved).
|
|
264
|
+
expect(setTokensSpy).toHaveBeenCalledWith('access_switch');
|
|
265
|
+
expect(oxy.getAccessToken()).toBe('access_switch');
|
|
266
|
+
expect(clearCacheSpy).toHaveBeenCalledTimes(1);
|
|
267
|
+
expect(result.authuser).toBeUndefined();
|
|
268
|
+
|
|
269
|
+
setTokensSpy.mockRestore();
|
|
270
|
+
clearCacheSpy.mockRestore();
|
|
271
|
+
});
|
|
272
|
+
|
|
230
273
|
it('does NOT plant or sweep when the operator is not authorized (403 surfaces via handleError)', async () => {
|
|
231
274
|
const setTokensSpy = jest.spyOn(oxy, 'setTokens');
|
|
232
275
|
const clearCacheSpy = jest.spyOn(oxy, 'clearCache');
|
|
@@ -235,9 +278,16 @@ describe('OxyServices.accounts', () => {
|
|
|
235
278
|
);
|
|
236
279
|
|
|
237
280
|
await expect(oxy.switchToAccount('acc1')).rejects.toThrow();
|
|
238
|
-
// A failed switch must NOT mutate session state
|
|
281
|
+
// A failed switch must NOT mutate session state, and must NOT attempt the
|
|
282
|
+
// /auth/session establishment.
|
|
239
283
|
expect(setTokensSpy).not.toHaveBeenCalled();
|
|
240
284
|
expect(clearCacheSpy).not.toHaveBeenCalled();
|
|
285
|
+
expect(makeRequestSpy).not.toHaveBeenCalledWith(
|
|
286
|
+
'POST',
|
|
287
|
+
'/auth/session',
|
|
288
|
+
undefined,
|
|
289
|
+
expect.anything(),
|
|
290
|
+
);
|
|
241
291
|
|
|
242
292
|
setTokensSpy.mockRestore();
|
|
243
293
|
clearCacheSpy.mockRestore();
|
|
@@ -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
|
+
});
|
package/src/models/interfaces.ts
CHANGED
|
@@ -514,6 +514,25 @@ export interface AssetUpdateVisibilityResponse {
|
|
|
514
514
|
};
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
+
/**
|
|
518
|
+
* Minimal, service-token-scoped asset metadata returned by
|
|
519
|
+
* `POST /assets/service/by-ids`.
|
|
520
|
+
*
|
|
521
|
+
* Resolves an Oxy asset `id` to its content-addressed identity (`sha256`),
|
|
522
|
+
* MIME type, byte `size`, and storage `status`. Used by server-to-server
|
|
523
|
+
* callers (e.g. Mention's MTN Protocol blob-ref resolution) that hold a
|
|
524
|
+
* `files:read`-scoped service token rather than a user session. Unknown or
|
|
525
|
+
* deleted ids are omitted from the response (never error the whole batch),
|
|
526
|
+
* so the result may be shorter than the requested id list.
|
|
527
|
+
*/
|
|
528
|
+
export interface ServiceAssetMetadata {
|
|
529
|
+
id: string;
|
|
530
|
+
sha256: string;
|
|
531
|
+
mime: string;
|
|
532
|
+
size: number;
|
|
533
|
+
status: 'active' | 'trash';
|
|
534
|
+
}
|
|
535
|
+
|
|
517
536
|
/**
|
|
518
537
|
* Account storage usage (server-side usage, not local AsyncStorage)
|
|
519
538
|
*/
|