@oxyhq/core 12.5.4 → 12.7.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/HttpService.js +4 -1
- package/dist/cjs/OxyServices.errors.js +42 -1
- package/dist/cjs/OxyServices.js +2 -1
- package/dist/cjs/crypto/keyManager.js +50 -0
- package/dist/cjs/i18n/locales/en-US.json +7 -0
- package/dist/cjs/i18n/locales/es-ES.json +7 -0
- package/dist/cjs/i18n/locales/locales/en-US.json +7 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +7 -0
- package/dist/cjs/index.js +5 -4
- package/dist/cjs/mixins/OxyServices.assets.js +175 -25
- package/dist/cjs/mixins/OxyServices.deviceTransfer.js +319 -0
- package/dist/cjs/mixins/index.js +4 -0
- package/dist/cjs/session/SessionClient.js +57 -8
- package/dist/cjs/utils/redactUrl.js +29 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +4 -1
- package/dist/esm/OxyServices.errors.js +40 -0
- package/dist/esm/OxyServices.js +2 -2
- package/dist/esm/crypto/keyManager.js +50 -0
- package/dist/esm/i18n/locales/en-US.json +7 -0
- package/dist/esm/i18n/locales/es-ES.json +7 -0
- package/dist/esm/i18n/locales/locales/en-US.json +7 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +7 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/mixins/OxyServices.assets.js +175 -25
- package/dist/esm/mixins/OxyServices.deviceTransfer.js +317 -0
- package/dist/esm/mixins/index.js +4 -0
- package/dist/esm/session/SessionClient.js +57 -8
- package/dist/esm/utils/redactUrl.js +26 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/OxyServices.d.ts +2 -2
- package/dist/types/OxyServices.errors.d.ts +40 -0
- package/dist/types/crypto/keyManager.d.ts +20 -0
- package/dist/types/index.d.ts +3 -2
- package/dist/types/mixins/OxyServices.assets.d.ts +103 -13
- package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +149 -0
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/models/interfaces.d.ts +18 -0
- package/dist/types/session/SessionClient.d.ts +19 -2
- package/dist/types/utils/redactUrl.d.ts +17 -0
- package/package.json +1 -1
- package/src/HttpService.ts +4 -1
- package/src/OxyServices.errors.ts +51 -0
- package/src/OxyServices.ts +2 -2
- package/src/crypto/__tests__/scopedSeed.test.ts +126 -0
- package/src/crypto/keyManager.ts +55 -0
- package/src/i18n/locales/en-US.json +7 -0
- package/src/i18n/locales/es-ES.json +7 -0
- package/src/index.ts +7 -1
- package/src/mixins/OxyServices.assets.ts +192 -28
- package/src/mixins/OxyServices.deviceTransfer.ts +397 -0
- package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +270 -0
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +265 -1
- package/src/mixins/index.ts +6 -0
- package/src/models/interfaces.ts +20 -0
- package/src/session/SessionClient.ts +59 -8
- package/src/session/__tests__/SessionClient.switchTokenOrder.test.ts +170 -0
- package/src/utils/__tests__/redactUrl.test.ts +33 -0
- package/src/utils/redactUrl.ts +28 -0
|
@@ -12,7 +12,35 @@
|
|
|
12
12
|
* private access should use `getFileDownloadUrlAsync()` for a scoped URL.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { OxyServices } from '../../OxyServices';
|
|
15
|
+
import { AssetUrlResolutionError, OxyServices } from '../../OxyServices';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build a non-verified JWT whose payload decodes to the given claims.
|
|
19
|
+
* `jwtDecode` only base64url-decodes the middle segment (no signature check),
|
|
20
|
+
* so this is enough to give the HTTP cache a distinct per-user identity tag.
|
|
21
|
+
*/
|
|
22
|
+
function makeJwt(payload: Record<string, unknown>): string {
|
|
23
|
+
const b64url = (obj: Record<string, unknown>): string =>
|
|
24
|
+
Buffer.from(JSON.stringify(obj)).toString('base64url');
|
|
25
|
+
const fullPayload = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload };
|
|
26
|
+
return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(fullPayload)}.sig`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A JSON `Response` mimicking the API's `{ data: ... }` success envelope. */
|
|
30
|
+
function jsonResponse(data: unknown, status = 200): Response {
|
|
31
|
+
return new Response(JSON.stringify({ data }), {
|
|
32
|
+
status,
|
|
33
|
+
headers: { 'content-type': 'application/json' },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** An error `Response` mimicking the API's error body. */
|
|
38
|
+
function errorResponse(status: number, code: string): Response {
|
|
39
|
+
return new Response(JSON.stringify({ error: code, message: code }), {
|
|
40
|
+
status,
|
|
41
|
+
headers: { 'content-type': 'application/json' },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
16
44
|
|
|
17
45
|
describe('OxyServices.getFileDownloadUrl', () => {
|
|
18
46
|
describe('public assets (no token, no expiresIn) → CDN', () => {
|
|
@@ -80,3 +108,239 @@ describe('OxyServices.getFileDownloadUrl', () => {
|
|
|
80
108
|
});
|
|
81
109
|
});
|
|
82
110
|
});
|
|
111
|
+
|
|
112
|
+
describe('OxyServices.getFileDownloadUrlAsync', () => {
|
|
113
|
+
let originalFetch: typeof globalThis.fetch;
|
|
114
|
+
let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
|
|
115
|
+
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
originalFetch = globalThis.fetch;
|
|
118
|
+
fetchMock = jest.fn();
|
|
119
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
globalThis.fetch = originalFetch;
|
|
124
|
+
jest.clearAllMocks();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('passes the API-scoped private stream URL through UNCHANGED', async () => {
|
|
128
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
129
|
+
oxy.setTokens(makeJwt({ userId: 'viewer-A' }));
|
|
130
|
+
|
|
131
|
+
const scopedUrl =
|
|
132
|
+
'https://api.oxy.so/assets/priv1/stream?variant=thumb&mt=SCOPED-MEDIA-TOKEN';
|
|
133
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ url: scopedUrl, variant: 'thumb', expiresIn: 600 }));
|
|
134
|
+
|
|
135
|
+
const resolved = await oxy.getFileDownloadUrlAsync('priv1', 'thumb');
|
|
136
|
+
|
|
137
|
+
// Returned exactly as the API produced it — never rewritten to the CDN.
|
|
138
|
+
expect(resolved).toBe(scopedUrl);
|
|
139
|
+
expect(resolved).not.toContain('cloud.oxy.so');
|
|
140
|
+
// It hit the authorized resolution endpoint, not the public CDN builder.
|
|
141
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
142
|
+
expect(url).toContain('/assets/priv1/url');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('resolves a public asset to the CDN URL the API returns', async () => {
|
|
146
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
147
|
+
oxy.setTokens(makeJwt({ userId: 'viewer-A' }));
|
|
148
|
+
|
|
149
|
+
fetchMock.mockResolvedValueOnce(
|
|
150
|
+
jsonResponse({ url: 'https://cloud.oxy.so/pub1?variant=thumb', variant: 'thumb', expiresIn: 3600 }),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const resolved = await oxy.getFileDownloadUrlAsync('pub1', 'thumb');
|
|
154
|
+
expect(resolved).toBe('https://cloud.oxy.so/pub1?variant=thumb');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('THROWS rather than returning a known-404 CDN URL when the API denies access', async () => {
|
|
158
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
159
|
+
oxy.setTokens(makeJwt({ userId: 'viewer-A' }));
|
|
160
|
+
|
|
161
|
+
fetchMock.mockResolvedValue(errorResponse(403, 'Access denied'));
|
|
162
|
+
|
|
163
|
+
await expect(oxy.getFileDownloadUrlAsync('priv1', 'thumb')).rejects.toBeInstanceOf(
|
|
164
|
+
AssetUrlResolutionError,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// Prove the failure was surfaced instead of a silent public-CDN fallback.
|
|
168
|
+
const err = await oxy
|
|
169
|
+
.getFileDownloadUrlAsync('priv1', 'thumb')
|
|
170
|
+
.catch((e: unknown) => e as AssetUrlResolutionError);
|
|
171
|
+
expect(err).toBeInstanceOf(AssetUrlResolutionError);
|
|
172
|
+
expect(err.fileId).toBe('priv1');
|
|
173
|
+
expect(err.variant).toBe('thumb');
|
|
174
|
+
expect(err.status).toBe(403);
|
|
175
|
+
// The error must not leak the scoped media token.
|
|
176
|
+
expect(err.message).not.toContain('mt=');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('THROWS when the API returns an empty URL body', async () => {
|
|
180
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
181
|
+
oxy.setTokens(makeJwt({ userId: 'viewer-A' }));
|
|
182
|
+
|
|
183
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ url: '', variant: undefined, expiresIn: 600 }));
|
|
184
|
+
|
|
185
|
+
await expect(oxy.getFileDownloadUrlAsync('priv1')).rejects.toBeInstanceOf(
|
|
186
|
+
AssetUrlResolutionError,
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('OxyServices asset URL cache TTL', () => {
|
|
192
|
+
const MEDIA_TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
193
|
+
|
|
194
|
+
it('never caches a resolved URL for as long as the media-token TTL', () => {
|
|
195
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
196
|
+
|
|
197
|
+
// Default (no explicit expiry) and an over-long explicit expiry both stay
|
|
198
|
+
// comfortably under the token lifetime.
|
|
199
|
+
expect(oxy.getAssetUrlCacheTTL()).toBeLessThan(MEDIA_TOKEN_TTL_MS);
|
|
200
|
+
expect(oxy.getAssetUrlCacheTTL(3600)).toBeLessThan(MEDIA_TOKEN_TTL_MS);
|
|
201
|
+
// Half of the 10-min bound.
|
|
202
|
+
expect(oxy.getAssetUrlCacheTTL(3600)).toBe(5 * 60 * 1000);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('scales a short requested expiry down proportionally', () => {
|
|
206
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
207
|
+
expect(oxy.getAssetUrlCacheTTL(60)).toBe(30 * 1000);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('OxyServices.getFileDownloadUrls (variant-aware batch)', () => {
|
|
212
|
+
let originalFetch: typeof globalThis.fetch;
|
|
213
|
+
let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
|
|
214
|
+
|
|
215
|
+
beforeEach(() => {
|
|
216
|
+
originalFetch = globalThis.fetch;
|
|
217
|
+
fetchMock = jest.fn();
|
|
218
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
afterEach(() => {
|
|
222
|
+
globalThis.fetch = originalFetch;
|
|
223
|
+
jest.clearAllMocks();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('sends a per-file {fileId, variant} list plus expiresIn and keeps only usable URLs', async () => {
|
|
227
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
228
|
+
oxy.setTokens(makeJwt({ userId: 'viewer-A' }));
|
|
229
|
+
|
|
230
|
+
fetchMock.mockResolvedValueOnce(
|
|
231
|
+
jsonResponse({
|
|
232
|
+
results: {
|
|
233
|
+
img1: {
|
|
234
|
+
allowed: true,
|
|
235
|
+
url: 'https://api.oxy.so/assets/img1/stream?variant=thumb&mt=TKN',
|
|
236
|
+
visibility: 'private',
|
|
237
|
+
mime: 'image/jpeg',
|
|
238
|
+
},
|
|
239
|
+
vid1: {
|
|
240
|
+
allowed: true,
|
|
241
|
+
url: 'https://cloud.oxy.so/vid1?variant=poster',
|
|
242
|
+
visibility: 'public',
|
|
243
|
+
},
|
|
244
|
+
gone: { allowed: false, error: 'Access denied' },
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const urls = await oxy.getFileDownloadUrls(
|
|
250
|
+
[
|
|
251
|
+
{ fileId: 'img1', variant: 'thumb' },
|
|
252
|
+
{ fileId: 'vid1', variant: 'poster' },
|
|
253
|
+
{ fileId: 'gone' },
|
|
254
|
+
],
|
|
255
|
+
{ expiresIn: 600, context: 'file-manager' },
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// Denied/missing ids are OMITTED (never an empty-string value).
|
|
259
|
+
expect(urls).toEqual({
|
|
260
|
+
img1: 'https://api.oxy.so/assets/img1/stream?variant=thumb&mt=TKN',
|
|
261
|
+
vid1: 'https://cloud.oxy.so/vid1?variant=poster',
|
|
262
|
+
});
|
|
263
|
+
expect('gone' in urls).toBe(false);
|
|
264
|
+
|
|
265
|
+
// The request carried the per-file variants + expiresIn on the POST body.
|
|
266
|
+
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
267
|
+
const body = JSON.parse(String(init.body));
|
|
268
|
+
expect(body.files).toEqual([
|
|
269
|
+
{ fileId: 'img1', variant: 'thumb' },
|
|
270
|
+
{ fileId: 'vid1', variant: 'poster' },
|
|
271
|
+
{ fileId: 'gone' },
|
|
272
|
+
]);
|
|
273
|
+
expect(body.expiresIn).toBe(600);
|
|
274
|
+
expect(body.context).toBe('file-manager');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('drops blank ids and collapses exact (fileId, variant) duplicates before sending', async () => {
|
|
278
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
279
|
+
oxy.setTokens(makeJwt({ userId: 'viewer-A' }));
|
|
280
|
+
|
|
281
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ results: {} }));
|
|
282
|
+
|
|
283
|
+
await oxy.getFileDownloadUrls([
|
|
284
|
+
{ fileId: 'img1', variant: 'thumb' },
|
|
285
|
+
{ fileId: 'img1', variant: 'thumb' },
|
|
286
|
+
{ fileId: ' ' },
|
|
287
|
+
{ fileId: 'img1' },
|
|
288
|
+
]);
|
|
289
|
+
|
|
290
|
+
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
291
|
+
const body = JSON.parse(String(init.body));
|
|
292
|
+
// Dedup on (fileId, variant): the two thumb entries collapse; the
|
|
293
|
+
// variant-less img1 is a DIFFERENT request and survives; blank id dropped.
|
|
294
|
+
expect(body.files).toEqual([
|
|
295
|
+
{ fileId: 'img1', variant: 'thumb' },
|
|
296
|
+
{ fileId: 'img1' },
|
|
297
|
+
]);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('makes NO network call for an all-empty request list', async () => {
|
|
301
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
302
|
+
oxy.setTokens(makeJwt({ userId: 'viewer-A' }));
|
|
303
|
+
|
|
304
|
+
const urls = await oxy.getFileDownloadUrls([{ fileId: '' }, { fileId: ' ' }]);
|
|
305
|
+
expect(urls).toEqual({});
|
|
306
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe('OxyServices asset URL cache isolation across accounts', () => {
|
|
311
|
+
let originalFetch: typeof globalThis.fetch;
|
|
312
|
+
let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
|
|
313
|
+
|
|
314
|
+
beforeEach(() => {
|
|
315
|
+
originalFetch = globalThis.fetch;
|
|
316
|
+
fetchMock = jest.fn();
|
|
317
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
afterEach(() => {
|
|
321
|
+
globalThis.fetch = originalFetch;
|
|
322
|
+
jest.clearAllMocks();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('never serves account A’s scoped URL to account B after a switch', async () => {
|
|
326
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
327
|
+
|
|
328
|
+
// Account A resolves the asset; response is cached under A's identity.
|
|
329
|
+
oxy.setTokens(makeJwt({ userId: 'account-A' }));
|
|
330
|
+
const urlForA = 'https://api.oxy.so/assets/priv1/stream?mt=TOKEN-FOR-A';
|
|
331
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ url: urlForA, expiresIn: 600 }));
|
|
332
|
+
expect(await oxy.getFileDownloadUrlAsync('priv1')).toBe(urlForA);
|
|
333
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
334
|
+
|
|
335
|
+
// Switching to account B mints a new access token → new identity tag. B's
|
|
336
|
+
// read must MISS A's cache entry and hit the network for its own scoped URL.
|
|
337
|
+
oxy.setTokens(makeJwt({ userId: 'account-B' }));
|
|
338
|
+
const urlForB = 'https://api.oxy.so/assets/priv1/stream?mt=TOKEN-FOR-B';
|
|
339
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ url: urlForB, expiresIn: 600 }));
|
|
340
|
+
|
|
341
|
+
const resolvedForB = await oxy.getFileDownloadUrlAsync('priv1');
|
|
342
|
+
expect(resolvedForB).toBe(urlForB);
|
|
343
|
+
expect(resolvedForB).not.toBe(urlForA);
|
|
344
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
345
|
+
});
|
|
346
|
+
});
|
package/src/mixins/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { OxyServicesCivicMixin } from './OxyServices.civic';
|
|
|
30
30
|
import { OxyServicesNodesMixin } from './OxyServices.nodes';
|
|
31
31
|
import { OxyServicesLinksMixin } from './OxyServices.links';
|
|
32
32
|
import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
|
|
33
|
+
import { OxyServicesDeviceTransferMixin } from './OxyServices.deviceTransfer';
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
36
|
* Instance shape of every mixin in the pipeline, intersected. The runtime
|
|
@@ -64,6 +65,7 @@ type AllMixinInstances =
|
|
|
64
65
|
& InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>>
|
|
65
66
|
& InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>>
|
|
66
67
|
& InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>>
|
|
68
|
+
& InstanceType<ReturnType<typeof OxyServicesDeviceTransferMixin<typeof OxyServicesBase>>>
|
|
67
69
|
& InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
68
70
|
|
|
69
71
|
/**
|
|
@@ -138,6 +140,10 @@ const MIXIN_PIPELINE: MixinFunction[] = [
|
|
|
138
140
|
// (`mintFromDeviceSecret` → `POST /session/device/token`).
|
|
139
141
|
OxyServicesDeviceBootMixin,
|
|
140
142
|
|
|
143
|
+
// Device-to-device identity transfer ("add a device"): E2E-encrypted key
|
|
144
|
+
// clone over a short-lived relay (b3 Feature 2).
|
|
145
|
+
OxyServicesDeviceTransferMixin,
|
|
146
|
+
|
|
141
147
|
// Utility (last, can use all above)
|
|
142
148
|
OxyServicesUtilityMixin,
|
|
143
149
|
];
|
package/src/models/interfaces.ts
CHANGED
|
@@ -517,6 +517,26 @@ export interface AssetUrlResponse {
|
|
|
517
517
|
expiresIn: number;
|
|
518
518
|
}
|
|
519
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Per-file result of `POST /assets/batch-access`. `allowed` is authoritative:
|
|
522
|
+
* when `false` the entry carries an `error` string (e.g. `'Access denied'`,
|
|
523
|
+
* `'File not found'`) and no `url`. When `true`, `url` is a caller-scoped,
|
|
524
|
+
* `<img src>`-ready URL — the public CDN form for a public asset or an
|
|
525
|
+
* API-origin stream URL carrying a short-lived media token for a private one.
|
|
526
|
+
*/
|
|
527
|
+
export interface BatchFileAccessEntry {
|
|
528
|
+
allowed: boolean;
|
|
529
|
+
url?: string;
|
|
530
|
+
visibility?: FileVisibility;
|
|
531
|
+
mime?: string;
|
|
532
|
+
error?: string;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Envelope returned by `POST /assets/batch-access`, keyed by file id. */
|
|
536
|
+
export interface BatchFileAccessResponse {
|
|
537
|
+
results: Record<string, BatchFileAccessEntry>;
|
|
538
|
+
}
|
|
539
|
+
|
|
520
540
|
export interface AssetDeleteSummary {
|
|
521
541
|
fileId: string;
|
|
522
542
|
wouldDelete: boolean;
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type DeviceSessionState,
|
|
8
8
|
} from '@oxyhq/contracts';
|
|
9
9
|
import { logger } from '../logger';
|
|
10
|
+
import { computeIdentityTag } from '../utils/cacheKey';
|
|
10
11
|
import { getSocketIO } from './socketLoader';
|
|
11
12
|
import type { MinimalSocket, SocketIOFactory } from './socketLoader';
|
|
12
13
|
|
|
@@ -172,8 +173,25 @@ export class SessionClient {
|
|
|
172
173
|
}
|
|
173
174
|
}
|
|
174
175
|
|
|
175
|
-
/**
|
|
176
|
-
|
|
176
|
+
/**
|
|
177
|
+
* Validate + last-writer-wins by revision. Returns true if applied.
|
|
178
|
+
*
|
|
179
|
+
* `activeToken` (sync path only) is the server-issued access token for
|
|
180
|
+
* `raw.activeAccountId`. When present and the state is applied, it is planted
|
|
181
|
+
* BEFORE any subscriber is notified so the bearer already belongs to the new
|
|
182
|
+
* active account — the local switch/bootstrap path then needs no redundant
|
|
183
|
+
* device-secret mint. Push-origin applies carry no token and rely on the
|
|
184
|
+
* mint-before-notify gate below.
|
|
185
|
+
*
|
|
186
|
+
* ORDERING INVARIANT: a subscriber must NEVER observe a newly-active account
|
|
187
|
+
* while the planted bearer still identifies the PREVIOUS one — otherwise a
|
|
188
|
+
* `useCurrentUser`-style refetch fires under the wrong account's token (the
|
|
189
|
+
* account-switch 404 race). So when a transport is available and the planted
|
|
190
|
+
* bearer does not already belong to `next.activeAccountId`, minting is awaited
|
|
191
|
+
* BEFORE `notify()`. This covers EVERY notify source (a switch push, a
|
|
192
|
+
* cross-device push, a cold mint), not just the initial "no bearer yet" case.
|
|
193
|
+
*/
|
|
194
|
+
protected applyState(raw: unknown, origin: SessionStateOrigin = 'push', activeToken?: string): boolean {
|
|
177
195
|
const next = safeParseContract(deviceSessionStateSchema, raw);
|
|
178
196
|
if (!next) {
|
|
179
197
|
logger.warn('[SessionClient] discarded invalid session state');
|
|
@@ -192,10 +210,27 @@ export class SessionClient {
|
|
|
192
210
|
) {
|
|
193
211
|
return false;
|
|
194
212
|
}
|
|
213
|
+
const previousState = this.state;
|
|
195
214
|
this.state = next;
|
|
215
|
+
// Plant the sync-supplied active token (it is for `next.activeAccountId`)
|
|
216
|
+
// now — before the notify below — so the bearer matches the new active
|
|
217
|
+
// account when subscribers observe it. Guarded on difference to avoid a
|
|
218
|
+
// redundant token-change notification on an unchanged token (bootstrap
|
|
219
|
+
// restate).
|
|
220
|
+
if (activeToken && next.activeAccountId !== null && activeToken !== this.host.getAccessToken()) {
|
|
221
|
+
this.host.setTokens(activeToken);
|
|
222
|
+
}
|
|
196
223
|
const transport = this.options.transport;
|
|
224
|
+
const activeAccountId = next.activeAccountId;
|
|
225
|
+
// Mint before notifying when the bearer does not already belong to the new
|
|
226
|
+
// active account: no bearer at all, an opaque bearer, OR a bearer for a
|
|
227
|
+
// DIFFERENT account. `computeIdentityTag` yields the token's `userId`/`id`
|
|
228
|
+
// for a real JWT (comparable to the account id) and a non-account sentinel
|
|
229
|
+
// otherwise, so a mismatch always resolves to "mint".
|
|
197
230
|
const needsMintBeforeNotify =
|
|
198
|
-
transport != null &&
|
|
231
|
+
transport != null &&
|
|
232
|
+
next.accounts.length > 0 &&
|
|
233
|
+
(activeAccountId === null || computeIdentityTag(this.host.getAccessToken()) !== activeAccountId);
|
|
199
234
|
|
|
200
235
|
const finishApply = (): void => {
|
|
201
236
|
this.notify();
|
|
@@ -210,8 +245,10 @@ export class SessionClient {
|
|
|
210
245
|
|
|
211
246
|
if (needsMintBeforeNotify) {
|
|
212
247
|
void transport.ensureActiveToken(next).then(finishApply).catch((error) => {
|
|
213
|
-
logger.warn('[SessionClient] ensureActiveToken failed', { component: 'SessionClient' }, error);
|
|
214
|
-
|
|
248
|
+
logger.warn('[SessionClient] ensureActiveToken failed — reverting session state', { component: 'SessionClient' }, error);
|
|
249
|
+
// Do NOT notify under a mismatched bearer. Revert to the last applied
|
|
250
|
+
// state so subscribers keep observing the account whose token is planted.
|
|
251
|
+
this.state = previousState ?? null;
|
|
215
252
|
});
|
|
216
253
|
} else {
|
|
217
254
|
if (transport) {
|
|
@@ -251,9 +288,23 @@ export class SessionClient {
|
|
|
251
288
|
}
|
|
252
289
|
// A `sync` is always the response to a direct REST call this client made
|
|
253
290
|
// (bootstrap / switch / signOut / add) → a `request`-origin, authoritative
|
|
254
|
-
// verdict.
|
|
255
|
-
|
|
256
|
-
|
|
291
|
+
// verdict. Hand the active token to `applyState`: in the applied path it is
|
|
292
|
+
// planted BEFORE notify (bearer matches the new active account when
|
|
293
|
+
// subscribers observe it, and no redundant device-secret mint is triggered).
|
|
294
|
+
const applied = this.applyState(sync.state, 'request', sync.activeToken?.accessToken);
|
|
295
|
+
// Equal-revision restate (this revision was already applied by a preceding
|
|
296
|
+
// socket push): `applyState` no-ops without planting, but the token still
|
|
297
|
+
// needs planting. Guard on the sync's active account STILL being the current
|
|
298
|
+
// active account so a stale response cannot adopt a token for an account a
|
|
299
|
+
// newer state already switched away from.
|
|
300
|
+
if (
|
|
301
|
+
!applied &&
|
|
302
|
+
sync.activeToken &&
|
|
303
|
+
this.state &&
|
|
304
|
+
sync.state.activeAccountId !== null &&
|
|
305
|
+
sync.state.activeAccountId === this.state.activeAccountId &&
|
|
306
|
+
sync.activeToken.accessToken !== this.host.getAccessToken()
|
|
307
|
+
) {
|
|
257
308
|
this.host.setTokens(sync.activeToken.accessToken);
|
|
258
309
|
}
|
|
259
310
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
2
|
+
import { SessionClient, type SessionClientHost, type TokenTransport } from '../SessionClient';
|
|
3
|
+
import { computeIdentityTag } from '../../utils/cacheKey';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The account-switch 404 race (regression guard).
|
|
7
|
+
*
|
|
8
|
+
* When a `session_state` push re-elects the active account from A to B, the
|
|
9
|
+
* push carries NO token — the app still holds A's bearer. If a subscriber were
|
|
10
|
+
* notified before B's bearer is planted, a `useCurrentUser`-style refetch would
|
|
11
|
+
* fire under A's token against B's session and 404.
|
|
12
|
+
*
|
|
13
|
+
* INVARIANT: no subscriber is ever notified while the planted bearer identifies
|
|
14
|
+
* an account OTHER than the observed active account. This test records, at every
|
|
15
|
+
* notify, the observed active account alongside the account the CURRENT bearer
|
|
16
|
+
* belongs to (via the same `computeIdentityTag` derivation `applyState` uses)
|
|
17
|
+
* and asserts they always match — and that the switch notify is DEFERRED until
|
|
18
|
+
* the mint lands B's token.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** A minimal jwt-decode-able token whose `userId` claim is `accountId`. */
|
|
22
|
+
function jwtFor(accountId: string): string {
|
|
23
|
+
const payload = Buffer.from(JSON.stringify({ userId: accountId })).toString('base64url');
|
|
24
|
+
return `h.${payload}.s`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const stateWith = (rev: number, active: string): DeviceSessionState => ({
|
|
28
|
+
deviceId: 'd1',
|
|
29
|
+
accounts: [
|
|
30
|
+
{ accountId: 'a1', sessionId: 's-a1', authuser: 0 },
|
|
31
|
+
{ accountId: 'b1', sessionId: 's-b1', authuser: 1 },
|
|
32
|
+
],
|
|
33
|
+
activeAccountId: active,
|
|
34
|
+
revision: rev,
|
|
35
|
+
updatedAt: 1720000000000,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
class TestClient extends SessionClient {
|
|
39
|
+
public apply(raw: unknown): boolean {
|
|
40
|
+
return this.applyState(raw);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('SessionClient — no notify under a mismatched bearer on an account switch', () => {
|
|
45
|
+
it('defers the switch notify until the mint lands the new active account bearer', async () => {
|
|
46
|
+
// Mutable planted bearer, starting on account A.
|
|
47
|
+
let planted: string | null = jwtFor('a1');
|
|
48
|
+
const host: SessionClientHost = {
|
|
49
|
+
makeRequest: jest.fn(),
|
|
50
|
+
getBaseURL: () => 'http://test.invalid',
|
|
51
|
+
getAccessToken: () => planted,
|
|
52
|
+
getDeviceCredential: () => null,
|
|
53
|
+
onTokensChanged: () => () => undefined,
|
|
54
|
+
setTokens: (token) => {
|
|
55
|
+
planted = token;
|
|
56
|
+
},
|
|
57
|
+
getCurrentAccountId: () => null,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// The mint lands the ACTIVE account's bearer, asynchronously (models the
|
|
61
|
+
// real device-secret mint round trip).
|
|
62
|
+
const transport: TokenTransport = {
|
|
63
|
+
ensureActiveToken: jest.fn(async (state: DeviceSessionState) => {
|
|
64
|
+
await Promise.resolve();
|
|
65
|
+
if (state.activeAccountId) {
|
|
66
|
+
planted = jwtFor(state.activeAccountId);
|
|
67
|
+
}
|
|
68
|
+
}),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const c = new TestClient(host, { transport });
|
|
72
|
+
|
|
73
|
+
const observations: Array<{ active: string | null; bearer: string }> = [];
|
|
74
|
+
c.subscribe((s) => {
|
|
75
|
+
observations.push({
|
|
76
|
+
active: s?.activeAccountId ?? null,
|
|
77
|
+
bearer: computeIdentityTag(host.getAccessToken()),
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Apply A (bearer already A's) → matches → synchronous notify.
|
|
82
|
+
c.apply(stateWith(1, 'a1'));
|
|
83
|
+
expect(observations).toEqual([{ active: 'a1', bearer: 'a1' }]);
|
|
84
|
+
|
|
85
|
+
// A `session_state` push re-elects B while the bearer is still A's.
|
|
86
|
+
c.apply(stateWith(2, 'b1'));
|
|
87
|
+
// The switch notify MUST NOT have fired yet — the bearer is still A's, so a
|
|
88
|
+
// synchronous notify would let a subscriber observe B under A's token.
|
|
89
|
+
expect(observations).toEqual([{ active: 'a1', bearer: 'a1' }]);
|
|
90
|
+
|
|
91
|
+
// Flush the mint + deferred notify.
|
|
92
|
+
for (let i = 0; i < 5; i++) {
|
|
93
|
+
await Promise.resolve();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The mint ran, and the switch notify fired only AFTER B's bearer was planted.
|
|
97
|
+
expect(transport.ensureActiveToken).toHaveBeenCalledWith(
|
|
98
|
+
expect.objectContaining({ activeAccountId: 'b1' }),
|
|
99
|
+
);
|
|
100
|
+
expect(observations).toContainEqual({ active: 'b1', bearer: 'b1' });
|
|
101
|
+
|
|
102
|
+
// At NO notify did the observed active account differ from the bearer's account.
|
|
103
|
+
for (const o of observations) {
|
|
104
|
+
expect(o.bearer).toBe(o.active);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('does not defer when the bearer already belongs to the new active account', () => {
|
|
109
|
+
let planted: string | null = jwtFor('b1');
|
|
110
|
+
const host: SessionClientHost = {
|
|
111
|
+
makeRequest: jest.fn(),
|
|
112
|
+
getBaseURL: () => 'http://test.invalid',
|
|
113
|
+
getAccessToken: () => planted,
|
|
114
|
+
getDeviceCredential: () => null,
|
|
115
|
+
onTokensChanged: () => () => undefined,
|
|
116
|
+
setTokens: (token) => {
|
|
117
|
+
planted = token;
|
|
118
|
+
},
|
|
119
|
+
getCurrentAccountId: () => null,
|
|
120
|
+
};
|
|
121
|
+
const transport: TokenTransport = { ensureActiveToken: jest.fn().mockResolvedValue(undefined) };
|
|
122
|
+
const c = new TestClient(host, { transport });
|
|
123
|
+
|
|
124
|
+
const seen: Array<string | null> = [];
|
|
125
|
+
c.subscribe((s) => seen.push(s?.activeAccountId ?? null));
|
|
126
|
+
|
|
127
|
+
// Bearer is already B's → the notify is synchronous (no mint-before-notify).
|
|
128
|
+
c.apply(stateWith(3, 'b1'));
|
|
129
|
+
expect(seen).toEqual(['b1']);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('reverts state and does not notify when minting fails on an account switch', async () => {
|
|
133
|
+
let planted: string | null = jwtFor('a1');
|
|
134
|
+
const host: SessionClientHost = {
|
|
135
|
+
makeRequest: jest.fn(),
|
|
136
|
+
getBaseURL: () => 'http://test.invalid',
|
|
137
|
+
getAccessToken: () => planted,
|
|
138
|
+
getDeviceCredential: () => null,
|
|
139
|
+
onTokensChanged: () => () => undefined,
|
|
140
|
+
setTokens: (token) => {
|
|
141
|
+
planted = token;
|
|
142
|
+
},
|
|
143
|
+
getCurrentAccountId: () => null,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const transport: TokenTransport = {
|
|
147
|
+
ensureActiveToken: jest.fn(async () => {
|
|
148
|
+
await Promise.resolve();
|
|
149
|
+
throw new Error('mint failed');
|
|
150
|
+
}),
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const c = new TestClient(host, { transport });
|
|
154
|
+
const seen: Array<string | null> = [];
|
|
155
|
+
c.subscribe((s) => seen.push(s?.activeAccountId ?? null));
|
|
156
|
+
|
|
157
|
+
c.apply(stateWith(1, 'a1'));
|
|
158
|
+
expect(seen).toEqual(['a1']);
|
|
159
|
+
|
|
160
|
+
c.apply(stateWith(2, 'b1'));
|
|
161
|
+
for (let i = 0; i < 5; i++) {
|
|
162
|
+
await Promise.resolve();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Still on A — no notify under A's bearer for B's active account.
|
|
166
|
+
expect(seen).toEqual(['a1']);
|
|
167
|
+
expect(c.getState()?.activeAccountId).toBe('a1');
|
|
168
|
+
expect(computeIdentityTag(host.getAccessToken())).toBe('a1');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `redactUrlQuery` tests — the query-string scrubber used before any asset URL
|
|
3
|
+
* reaches a log sink. Asset stream URLs carry a scoped `mt=` media token that
|
|
4
|
+
* is a bearer credential; it must never be logged.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { redactUrlQuery } from '../redactUrl';
|
|
8
|
+
|
|
9
|
+
describe('redactUrlQuery', () => {
|
|
10
|
+
it('strips the query string (including a media token) from an absolute URL', () => {
|
|
11
|
+
const redacted = redactUrlQuery(
|
|
12
|
+
'https://api.oxy.so/assets/priv1/stream?variant=thumb&mt=SECRET-TOKEN',
|
|
13
|
+
);
|
|
14
|
+
expect(redacted).toBe('https://api.oxy.so/assets/priv1/stream?<redacted>');
|
|
15
|
+
expect(redacted).not.toContain('mt=');
|
|
16
|
+
expect(redacted).not.toContain('SECRET-TOKEN');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('strips the query string from a relative path too', () => {
|
|
20
|
+
expect(redactUrlQuery('/assets/priv1/url?expiresIn=600&mt=SECRET')).toBe(
|
|
21
|
+
'/assets/priv1/url?<redacted>',
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('returns a URL without a query string unchanged', () => {
|
|
26
|
+
expect(redactUrlQuery('https://cloud.oxy.so/pub1')).toBe('https://cloud.oxy.so/pub1');
|
|
27
|
+
expect(redactUrlQuery('/assets')).toBe('/assets');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('passes through empty input', () => {
|
|
31
|
+
expect(redactUrlQuery('')).toBe('');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL redaction for logging.
|
|
3
|
+
*
|
|
4
|
+
* Asset URLs the API hands back for private assets carry a scoped, short-lived
|
|
5
|
+
* media token (`mt=…`) in their query string. That token is a bearer credential
|
|
6
|
+
* for the underlying object, so it must never land in a log line, breadcrumb,
|
|
7
|
+
* or metric — a captured log would otherwise grant read access until the token
|
|
8
|
+
* expires. Query strings on API URLs can also carry other sensitive params, so
|
|
9
|
+
* we redact the whole query rather than allow-listing one key.
|
|
10
|
+
*
|
|
11
|
+
* `redactUrlQuery` returns the URL's path portion with a `?<redacted>` marker
|
|
12
|
+
* when a query string is present, and the input unchanged otherwise. It is
|
|
13
|
+
* defensive: any input that does not parse as a URL is passed through as-is,
|
|
14
|
+
* except that a bare `?query` tail is still stripped so a relative path with a
|
|
15
|
+
* query never leaks.
|
|
16
|
+
*/
|
|
17
|
+
export function redactUrlQuery(url: string): string {
|
|
18
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
19
|
+
return url;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const queryIndex = url.indexOf('?');
|
|
23
|
+
if (queryIndex === -1) {
|
|
24
|
+
return url;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return `${url.slice(0, queryIndex)}?<redacted>`;
|
|
28
|
+
}
|