@oxyhq/core 12.5.4 → 12.6.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/index.js +5 -4
- package/dist/cjs/mixins/OxyServices.assets.js +175 -25
- 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/index.js +1 -1
- package/dist/esm/mixins/OxyServices.assets.js +175 -25
- 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/index.d.ts +2 -2
- package/dist/types/mixins/OxyServices.assets.d.ts +103 -13
- 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/index.ts +3 -1
- package/src/mixins/OxyServices.assets.ts +192 -28
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +265 -1
- 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
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, RNFileDescriptor, ServiceAssetMetadata, ServiceAssetMetadataBySha } from '../models/interfaces';
|
|
1
|
+
import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, BatchFileAccessResponse, RNFileDescriptor, ServiceAssetMetadata, ServiceAssetMetadataBySha } from '../models/interfaces';
|
|
2
2
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
3
3
|
import { isReactNative } from '@oxyhq/protocol';
|
|
4
4
|
import { logger } from '../logger';
|
|
5
|
+
import { AssetUrlResolutionError } from '../OxyServices.errors';
|
|
5
6
|
import { extractErrorStatus } from '../utils/errorUtils';
|
|
7
|
+
import { redactUrlQuery } from '../utils/redactUrl';
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Maximum number of ids sent per `POST /assets/service/by-ids` request. Matches
|
|
@@ -27,6 +29,40 @@ const SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE = 100;
|
|
|
27
29
|
*/
|
|
28
30
|
const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/;
|
|
29
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Conservative lower bound (10 min) the SDK assumes for the lifetime of the
|
|
34
|
+
* scoped media token (`mt`) the API embeds in the stream URL it returns for a
|
|
35
|
+
* private asset. The SDK never mints or inspects that token; this constant
|
|
36
|
+
* exists only so the SDK's own URL cache can be sized safely BELOW the token's
|
|
37
|
+
* real lifetime.
|
|
38
|
+
*
|
|
39
|
+
* The API currently mints tokens for 900s (`MEDIA_TOKEN_TTL_SECONDS` in
|
|
40
|
+
* `packages/api/src/utils/mediaToken.ts`). Core deliberately assumes a shorter
|
|
41
|
+
* 10-min floor rather than copying 15: core and the API are separate packages,
|
|
42
|
+
* so core cannot observe a server-side TTL change at runtime. Under-assuming the
|
|
43
|
+
* lifetime only ever shortens the cache (more refetches, never a dead URL), so
|
|
44
|
+
* it stays correct even if the server lowers its TTL toward this floor. The
|
|
45
|
+
* {@link ASSET_URL_CACHE_LIFETIME_FRACTION} discount is applied on top.
|
|
46
|
+
*/
|
|
47
|
+
const ASSET_MEDIA_TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fraction of a resolved URL's remaining lifetime the SDK is willing to keep it
|
|
51
|
+
* cached for. A resolved URL stops working the moment its media token expires,
|
|
52
|
+
* so caching it for its full nominal lifetime guarantees a window in which the
|
|
53
|
+
* cache hands out an already-dead URL (clock skew, time spent in the render
|
|
54
|
+
* pipeline, an image request queued behind others). Half the lifetime leaves a
|
|
55
|
+
* margin at least as large as the entry's own age.
|
|
56
|
+
*/
|
|
57
|
+
const ASSET_URL_CACHE_LIFETIME_FRACTION = 0.5;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Fallback URL lifetime, in seconds, assumed when the caller does not request
|
|
61
|
+
* an explicit `expiresIn`. Mirrors the API's default signed-URL expiry; the
|
|
62
|
+
* effective value is still clamped by {@link ASSET_MEDIA_TOKEN_TTL_MS}.
|
|
63
|
+
*/
|
|
64
|
+
const DEFAULT_ASSET_URL_EXPIRES_IN_SECONDS = 3600;
|
|
65
|
+
|
|
30
66
|
export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
31
67
|
return class extends Base {
|
|
32
68
|
constructor(...args: any[]) {
|
|
@@ -61,14 +97,33 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
61
97
|
}
|
|
62
98
|
|
|
63
99
|
/**
|
|
64
|
-
* Build a synchronous, `<img src>`-ready
|
|
100
|
+
* Build a synchronous, `<img src>`-ready URL for a **PUBLIC** Oxy asset.
|
|
101
|
+
*
|
|
102
|
+
* ## Contract — read before calling
|
|
65
103
|
*
|
|
66
|
-
* This
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
104
|
+
* This is a pure string builder. It performs no network call and therefore
|
|
105
|
+
* has **no knowledge of the asset's visibility**. It always produces the
|
|
106
|
+
* public form: `${cloudURL}/<id>[?variant=…]`, which the CDN serves from
|
|
107
|
+
* the public media origin only.
|
|
108
|
+
*
|
|
109
|
+
* Consequently:
|
|
110
|
+
* - Call it ONLY when the asset is known to be `public` — e.g. avatars and
|
|
111
|
+
* profile banners, which {@link uploadAvatar} / {@link uploadProfileBanner}
|
|
112
|
+
* upload with `visibility: 'public'`.
|
|
113
|
+
* - For an asset that may be `private` or `unlisted` — anything uploaded
|
|
114
|
+
* through the generic {@link assetUpload} path, whose server-side default
|
|
115
|
+
* is private — this URL resolves to a hard **404**. Use
|
|
116
|
+
* {@link getFileDownloadUrlAsync}, which asks the API for a URL scoped to
|
|
117
|
+
* the current caller.
|
|
118
|
+
* - It must never guess visibility, and must never embed the caller's
|
|
119
|
+
* bearer token: the returned string is rendered into DOM attributes,
|
|
120
|
+
* browser network panels, HTTP caches, and logs.
|
|
121
|
+
*
|
|
122
|
+
* Passing `expiresIn` switches to the API-origin stream form
|
|
123
|
+
* (`${baseURL}/assets/<id>/stream?…`) WITHOUT any credential. That form
|
|
124
|
+
* still only serves what an unauthenticated request may see — it is not a
|
|
125
|
+
* synchronous private-asset path, and none exists: authorization for a
|
|
126
|
+
* private asset requires the round-trip in {@link getFileDownloadUrlAsync}.
|
|
72
127
|
*/
|
|
73
128
|
getFileDownloadUrl(fileId: string, variant?: string, expiresIn?: number): string {
|
|
74
129
|
// Never embed the in-memory bearer token: this URL is rendered into DOM
|
|
@@ -91,21 +146,46 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
91
146
|
}
|
|
92
147
|
|
|
93
148
|
/**
|
|
94
|
-
*
|
|
149
|
+
* Resolve an asset id to a URL that is valid for the CURRENT caller,
|
|
150
|
+
* whatever the asset's visibility.
|
|
151
|
+
*
|
|
152
|
+
* Asks the API (`GET /assets/:id/url`) rather than guessing: a `public`
|
|
153
|
+
* asset resolves to the CDN form, while a `private`/`unlisted` asset the
|
|
154
|
+
* caller may read resolves to an API-origin stream URL carrying a scoped,
|
|
155
|
+
* short-lived media token. The returned URL is passed through **unchanged**
|
|
156
|
+
* — the SDK never rewrites, re-signs, or strips it.
|
|
157
|
+
*
|
|
158
|
+
* ## Failure behaviour — no CDN fallback
|
|
159
|
+
*
|
|
160
|
+
* Throws {@link AssetUrlResolutionError} when the API returns no URL or the
|
|
161
|
+
* request fails (including 401/403/404). It deliberately does NOT fall back
|
|
162
|
+
* to {@link getFileDownloadUrl}: that builder only produces the public CDN
|
|
163
|
+
* form, so falling back would hand the caller a URL that renders as a hard
|
|
164
|
+
* 404 for every private asset and would silently swallow the real failure.
|
|
165
|
+
* A caller that knows an asset is public should call the synchronous
|
|
166
|
+
* builder directly instead of relying on a fallback here.
|
|
167
|
+
*
|
|
168
|
+
* The resolved URL is cached per identity for well under the media token's
|
|
169
|
+
* lifetime — see {@link getAssetUrlCacheTTL}.
|
|
95
170
|
*/
|
|
96
171
|
async getFileDownloadUrlAsync(fileId: string, variant?: string, expiresIn?: number): Promise<string> {
|
|
172
|
+
let url: string | null;
|
|
97
173
|
try {
|
|
98
|
-
|
|
174
|
+
url = await this.fetchAssetDownloadUrl(
|
|
99
175
|
fileId,
|
|
100
176
|
variant,
|
|
101
177
|
this.getAssetUrlCacheTTL(expiresIn),
|
|
102
178
|
expiresIn
|
|
103
179
|
);
|
|
180
|
+
} catch (error: unknown) {
|
|
181
|
+
throw new AssetUrlResolutionError(fileId, variant, extractErrorStatus(error), error);
|
|
182
|
+
}
|
|
104
183
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return this.getFileDownloadUrl(fileId, variant, expiresIn);
|
|
184
|
+
if (!url) {
|
|
185
|
+
throw new AssetUrlResolutionError(fileId, variant, undefined);
|
|
108
186
|
}
|
|
187
|
+
|
|
188
|
+
return url;
|
|
109
189
|
}
|
|
110
190
|
|
|
111
191
|
/**
|
|
@@ -180,13 +260,39 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
180
260
|
}
|
|
181
261
|
|
|
182
262
|
/**
|
|
183
|
-
*
|
|
263
|
+
* Resolve access + a caller-scoped URL for many assets — each with its OWN
|
|
264
|
+
* requested variant — in ONE round trip via `POST /assets/batch-access`.
|
|
265
|
+
*
|
|
266
|
+
* `requests` is a per-file `{ fileId, variant? }` list (a `variant` of
|
|
267
|
+
* `undefined` asks for the original). `options.expiresIn` sets the requested
|
|
268
|
+
* media-token / signed-URL lifetime (seconds); `options.context` is the
|
|
269
|
+
* server-side access-check context. Entries with a blank `fileId` are
|
|
270
|
+
* dropped and exact `(fileId, variant)` duplicates are collapsed before the
|
|
271
|
+
* request; an empty effective list performs no network call.
|
|
272
|
+
*
|
|
273
|
+
* The server caps the batch at 100 entries — callers that page beyond that
|
|
274
|
+
* must chunk. Returns the raw per-file envelope (see
|
|
275
|
+
* {@link BatchFileAccessResponse}); most callers want {@link getFileDownloadUrls},
|
|
276
|
+
* which flattens it to just the usable URLs.
|
|
184
277
|
*/
|
|
185
|
-
async getBatchFileAccess(
|
|
278
|
+
async getBatchFileAccess(
|
|
279
|
+
requests: Array<{ fileId: string; variant?: string }>,
|
|
280
|
+
options?: { expiresIn?: number; context?: string },
|
|
281
|
+
): Promise<BatchFileAccessResponse> {
|
|
282
|
+
const files = dedupeFileAccessRequests(requests);
|
|
283
|
+
if (files.length === 0) {
|
|
284
|
+
return { results: {} };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const body: { files: Array<{ fileId: string; variant?: string }>; expiresIn?: number; context?: string } = {
|
|
288
|
+
files,
|
|
289
|
+
};
|
|
290
|
+
if (typeof options?.expiresIn === 'number') body.expiresIn = options.expiresIn;
|
|
291
|
+
if (typeof options?.context === 'string') body.context = options.context;
|
|
292
|
+
|
|
186
293
|
try {
|
|
187
|
-
return await this.makeRequest('POST', '/assets/batch-access', {
|
|
188
|
-
|
|
189
|
-
context
|
|
294
|
+
return await this.makeRequest<BatchFileAccessResponse>('POST', '/assets/batch-access', body, {
|
|
295
|
+
cache: false,
|
|
190
296
|
});
|
|
191
297
|
} catch (error) {
|
|
192
298
|
throw this.handleError(error);
|
|
@@ -194,13 +300,30 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
194
300
|
}
|
|
195
301
|
|
|
196
302
|
/**
|
|
197
|
-
*
|
|
303
|
+
* Resolve many assets — each with its OWN variant — to caller-scoped,
|
|
304
|
+
* `<img src>`-ready URLs in one round trip. The batch counterpart of
|
|
305
|
+
* {@link getFileDownloadUrlAsync}, built to resolve a whole grid page at once.
|
|
306
|
+
*
|
|
307
|
+
* `requests` is a per-file `{ fileId, variant? }` list (e.g. `poster` for a
|
|
308
|
+
* video, `thumb` for an image); the per-file variant RULE lives in the
|
|
309
|
+
* caller — core just forwards what it is given. `options.expiresIn` /
|
|
310
|
+
* `options.context` are passed through to the endpoint.
|
|
311
|
+
*
|
|
312
|
+
* Each returned URL is the API's own scoped form, passed through unchanged:
|
|
313
|
+
* the public CDN URL for a public asset, or an API-origin
|
|
314
|
+
* `/assets/:id/stream?…&mt=<media token>` URL for a private asset the caller
|
|
315
|
+
* may read. Ids the caller cannot access (or that do not exist) are simply
|
|
316
|
+
* OMITTED from the returned map — there is NO public-CDN fallback, so a grid
|
|
317
|
+
* never renders a known-404 URL. Callers detect a miss by the absent key
|
|
318
|
+
* (the map never contains an empty-string value). Keyed by `fileId`.
|
|
198
319
|
*/
|
|
199
|
-
async getFileDownloadUrls(
|
|
200
|
-
|
|
320
|
+
async getFileDownloadUrls(
|
|
321
|
+
requests: Array<{ fileId: string; variant?: string }>,
|
|
322
|
+
options?: { expiresIn?: number; context?: string },
|
|
323
|
+
): Promise<Record<string, string>> {
|
|
324
|
+
const response = await this.getBatchFileAccess(requests, options);
|
|
201
325
|
const urls: Record<string, string> = {};
|
|
202
|
-
const
|
|
203
|
-
for (const [id, result] of Object.entries(results as Record<string, any>)) {
|
|
326
|
+
for (const [id, result] of Object.entries(response.results ?? {})) {
|
|
204
327
|
if (result.allowed && result.url) {
|
|
205
328
|
urls[id] = result.url;
|
|
206
329
|
}
|
|
@@ -526,7 +649,7 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
526
649
|
|
|
527
650
|
return await this.makeRequest<AssetUrlResponse>('GET', `/assets/${fileId}/url`, params, {
|
|
528
651
|
cache: true,
|
|
529
|
-
cacheTTL:
|
|
652
|
+
cacheTTL: this.getAssetUrlCacheTTL(expiresIn),
|
|
530
653
|
});
|
|
531
654
|
} catch (error) {
|
|
532
655
|
throw this.handleError(error);
|
|
@@ -614,9 +737,21 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
614
737
|
}
|
|
615
738
|
}
|
|
616
739
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
740
|
+
/**
|
|
741
|
+
* How long a resolved asset URL may stay in the SDK's GET cache, in ms.
|
|
742
|
+
*
|
|
743
|
+
* A resolved private-asset URL dies the instant its scoped media token
|
|
744
|
+
* expires (~{@link ASSET_MEDIA_TOKEN_TTL_MS}). Caching it for its full
|
|
745
|
+
* nominal lifetime would leave a window where the cache serves an
|
|
746
|
+
* already-dead URL (clock skew, render-pipeline latency, an image request
|
|
747
|
+
* queued behind others). So the TTL is (a) never longer than the token's
|
|
748
|
+
* lifetime and (b) discounted to {@link ASSET_URL_CACHE_LIFETIME_FRACTION}
|
|
749
|
+
* of that bound — comfortably below the token TTL by construction.
|
|
750
|
+
*/
|
|
751
|
+
public getAssetUrlCacheTTL(expiresIn?: number): number {
|
|
752
|
+
const requestedLifetimeMs = (expiresIn ?? DEFAULT_ASSET_URL_EXPIRES_IN_SECONDS) * 1000;
|
|
753
|
+
const boundedLifetimeMs = Math.min(requestedLifetimeMs, ASSET_MEDIA_TOKEN_TTL_MS);
|
|
754
|
+
return Math.floor(boundedLifetimeMs * ASSET_URL_CACHE_LIFETIME_FRACTION);
|
|
620
755
|
}
|
|
621
756
|
|
|
622
757
|
public async fetchAssetDownloadUrl(
|
|
@@ -635,7 +770,10 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
635
770
|
Object.keys(params).length ? params : undefined,
|
|
636
771
|
{
|
|
637
772
|
cache: true,
|
|
638
|
-
|
|
773
|
+
// Cap the cached URL well below the media token's lifetime. The
|
|
774
|
+
// response body is a scoped, expiring URL; over-caching it serves a
|
|
775
|
+
// dead URL after the token expires (see getAssetUrlCacheTTL).
|
|
776
|
+
cacheTTL: cacheTTL ?? this.getAssetUrlCacheTTL(expiresIn),
|
|
639
777
|
}
|
|
640
778
|
);
|
|
641
779
|
|
|
@@ -656,6 +794,32 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
656
794
|
};
|
|
657
795
|
}
|
|
658
796
|
|
|
797
|
+
/**
|
|
798
|
+
* Normalize the per-file batch-access request list: drop entries with a blank
|
|
799
|
+
* `fileId` and collapse exact `(fileId, variant)` duplicates (first occurrence
|
|
800
|
+
* wins, preserving order). Two entries for the SAME `fileId` with DIFFERENT
|
|
801
|
+
* variants are intentionally kept — but note the response is keyed by `fileId`,
|
|
802
|
+
* so a caller that needs two variants of one file must issue separate calls.
|
|
803
|
+
*/
|
|
804
|
+
function dedupeFileAccessRequests(
|
|
805
|
+
requests: Array<{ fileId: string; variant?: string }>,
|
|
806
|
+
): Array<{ fileId: string; variant?: string }> {
|
|
807
|
+
const seen = new Set<string>();
|
|
808
|
+
const out: Array<{ fileId: string; variant?: string }> = [];
|
|
809
|
+
for (const req of requests) {
|
|
810
|
+
if (typeof req?.fileId !== 'string' || req.fileId.trim().length === 0) {
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
const key = `${req.fileId}\u0000${req.variant ?? ''}`;
|
|
814
|
+
if (seen.has(key)) {
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
seen.add(key);
|
|
818
|
+
out.push(req.variant === undefined ? { fileId: req.fileId } : { fileId: req.fileId, variant: req.variant });
|
|
819
|
+
}
|
|
820
|
+
return out;
|
|
821
|
+
}
|
|
822
|
+
|
|
659
823
|
/**
|
|
660
824
|
* Only send ambient credentials (cookies) when the asset URL is same-origin with
|
|
661
825
|
* the configured API base. Caller-supplied cross-origin asset URLs must not leak
|
|
@@ -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/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;
|