@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
package/dist/esm/HttpService.js
CHANGED
|
@@ -20,6 +20,7 @@ import { jwtDecode } from 'jwt-decode';
|
|
|
20
20
|
import { isNative, getPlatformOS } from './utils/platform.js';
|
|
21
21
|
import { isReactNative } from '@oxyhq/protocol';
|
|
22
22
|
import { computeIdentityTag, fnv1a32 } from './utils/cacheKey.js';
|
|
23
|
+
import { redactUrlQuery } from './utils/redactUrl.js';
|
|
23
24
|
/**
|
|
24
25
|
* Check if we're running in a native app environment (React Native, not web)
|
|
25
26
|
* This is used to determine CSRF handling mode
|
|
@@ -260,7 +261,9 @@ export class HttpService {
|
|
|
260
261
|
const cached = this.cache.get(cacheKey);
|
|
261
262
|
if (cached !== null) {
|
|
262
263
|
this.requestMetrics.cacheHits++;
|
|
263
|
-
|
|
264
|
+
// Redact the query string: an asset stream URL passed here carries a
|
|
265
|
+
// scoped `mt=` media token that must never reach a log sink.
|
|
266
|
+
this.logger.debug('Cache hit:', redactUrlQuery(url));
|
|
264
267
|
return cached;
|
|
265
268
|
}
|
|
266
269
|
this.requestMetrics.cacheMisses++;
|
|
@@ -9,6 +9,46 @@ export class OxyAuthenticationError extends Error {
|
|
|
9
9
|
this.status = status;
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Thrown when an asset's authorized download URL cannot be resolved.
|
|
14
|
+
*
|
|
15
|
+
* `getFileDownloadUrlAsync` asks the API for a URL that is valid for the
|
|
16
|
+
* CALLER and the asset's actual visibility. When that resolution fails there is
|
|
17
|
+
* no honest fallback: the public CDN origin only serves `public` assets, so
|
|
18
|
+
* handing back `https://cloud.oxy.so/<id>` for an unresolved asset produces a
|
|
19
|
+
* hard 404 at render time and hides the real failure from the caller. This
|
|
20
|
+
* error surfaces the failure instead.
|
|
21
|
+
*
|
|
22
|
+
* The message and fields deliberately carry only the asset id, the requested
|
|
23
|
+
* variant and the HTTP status — never the resolved URL, which embeds a scoped
|
|
24
|
+
* media token.
|
|
25
|
+
*
|
|
26
|
+
* ## `status` lets a caller decide whether a CDN fallback is safe
|
|
27
|
+
*
|
|
28
|
+
* Core itself never falls back to the public CDN builder, because it has no
|
|
29
|
+
* knowledge of the asset's visibility and that URL is a guaranteed 404 for a
|
|
30
|
+
* private asset. A CALLER that knows an asset is public MAY choose to fall back
|
|
31
|
+
* to `getFileDownloadUrl(id, variant)` — but only for a TRANSIENT failure, not
|
|
32
|
+
* a definitive denial:
|
|
33
|
+
* - `status` 401/403/404 → definitive: the asset is private/denied/missing.
|
|
34
|
+
* Never CDN-fall-back — it will 404.
|
|
35
|
+
* - `status` undefined (network error) or 5xx → transient: resolution itself
|
|
36
|
+
* failed. If the caller independently knows the asset is public, a CDN
|
|
37
|
+
* fallback is defensible best-effort.
|
|
38
|
+
*/
|
|
39
|
+
export class AssetUrlResolutionError extends Error {
|
|
40
|
+
constructor(fileId, variant, status, cause) {
|
|
41
|
+
const variantSuffix = variant ? ` (variant "${variant}")` : '';
|
|
42
|
+
const statusSuffix = typeof status === 'number' ? ` — status ${status}` : '';
|
|
43
|
+
super(`Could not resolve a download URL for asset "${fileId}"${variantSuffix}${statusSuffix}`);
|
|
44
|
+
this.code = 'ASSET_URL_UNRESOLVED';
|
|
45
|
+
this.name = 'AssetUrlResolutionError';
|
|
46
|
+
this.fileId = fileId;
|
|
47
|
+
this.variant = variant;
|
|
48
|
+
this.status = status;
|
|
49
|
+
this.cause = cause;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
12
52
|
export class OxyAuthenticationTimeoutError extends OxyAuthenticationError {
|
|
13
53
|
constructor(operationName, timeoutMs) {
|
|
14
54
|
super(`Authentication timeout (${timeoutMs}ms): ${operationName} requires user authentication. Please ensure the user is logged in before calling this method.`, 'AUTH_TIMEOUT', 408);
|
package/dist/esm/OxyServices.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors.js';
|
|
1
|
+
import { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors.js';
|
|
2
2
|
// Import mixin composition helper
|
|
3
3
|
import { composeOxyServices } from './mixins/index.js';
|
|
4
4
|
/**
|
|
@@ -50,7 +50,7 @@ export class OxyServices extends OxyServicesComposed {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
// Re-export error classes for convenience
|
|
53
|
-
export { OxyAuthenticationError, OxyAuthenticationTimeoutError };
|
|
53
|
+
export { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError };
|
|
54
54
|
/**
|
|
55
55
|
* Default Oxy Cloud URL — used when no `cloudURL` is provided to OxyServices.
|
|
56
56
|
*/
|
|
@@ -9,6 +9,7 @@ const { ec: EC } = _cjs_elliptic;
|
|
|
9
9
|
import { isWeb, isIOS, isAndroid } from '../utils/platform.js';
|
|
10
10
|
import { isReactNative, isNodeJS, loadExpoCrypto, loadNodeCrypto, loadSecureStore, loadSharedIdentityBridge } from '@oxyhq/protocol';
|
|
11
11
|
import { isDev, logger } from '../logger/index.js';
|
|
12
|
+
import { hkdfSha256 } from './kdf.js';
|
|
12
13
|
/**
|
|
13
14
|
* Thrown when an identity-mutating operation (createIdentity / importKeyPair)
|
|
14
15
|
* is invoked while a valid identity already exists on the device.
|
|
@@ -41,6 +42,25 @@ export class IdentityPersistError extends Error {
|
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
const ec = new EC('secp256k1');
|
|
45
|
+
/**
|
|
46
|
+
* HKDF salt that domain-separates every identity-scoped seed produced by
|
|
47
|
+
* {@link KeyManager.deriveScopedSeed}. Versioned so a future scheme change is a
|
|
48
|
+
* new, non-colliding tag. The per-app domain (e.g. Oxy Pay's FairCoin wallet)
|
|
49
|
+
* is carried by the caller's `info` string, not this salt.
|
|
50
|
+
*/
|
|
51
|
+
const SCOPED_SEED_KDF_SALT = 'oxy-identity-scoped-seed-v1';
|
|
52
|
+
/** UTF-8 encode an ASCII label to bytes (HKDF salt/info). */
|
|
53
|
+
function utf8ToBytes(label) {
|
|
54
|
+
return new TextEncoder().encode(label);
|
|
55
|
+
}
|
|
56
|
+
/** Decode a hex string to bytes. Inverse of {@link uint8ArrayToHex}. */
|
|
57
|
+
function hexToBytes(hex) {
|
|
58
|
+
const out = new Uint8Array(hex.length / 2);
|
|
59
|
+
for (let i = 0; i < out.length; i++) {
|
|
60
|
+
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
44
64
|
const STORAGE_KEYS = {
|
|
45
65
|
PRIVATE_KEY: 'oxy_identity_private_key',
|
|
46
66
|
PUBLIC_KEY: 'oxy_identity_public_key',
|
|
@@ -1385,6 +1405,36 @@ export class KeyManager {
|
|
|
1385
1405
|
return false;
|
|
1386
1406
|
}
|
|
1387
1407
|
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Derive a 32-byte, domain-separated seed from the on-device Oxy identity
|
|
1410
|
+
* private key via HKDF-SHA256, WITHOUT ever exposing the raw private key.
|
|
1411
|
+
*
|
|
1412
|
+
* The domain separation is carried by `info` (e.g. `"oxypay/faircoin/v1"`),
|
|
1413
|
+
* so distinct apps/purposes get independent seeds from the same identity.
|
|
1414
|
+
* The output is HKDF keying material, never the private key itself — a
|
|
1415
|
+
* consumer (e.g. Oxy Pay's FairCoin HD wallet) can feed it straight into
|
|
1416
|
+
* `HDKey.fromMasterSeed` and never touches the identity key.
|
|
1417
|
+
*
|
|
1418
|
+
* Key source (native only): prefers the shared ecosystem identity written to
|
|
1419
|
+
* `group.so.oxy.shared` (what a Relying Party like Oxy Pay reads), then falls
|
|
1420
|
+
* back to this device's primary identity (Commons/Accounts). Both reproduce
|
|
1421
|
+
* from the user's Oxy recovery phrase, so the derived seed is recoverable.
|
|
1422
|
+
*
|
|
1423
|
+
* @param info Context/domain-binding label (distinct labels → independent seeds).
|
|
1424
|
+
* @returns 32 bytes of derived keying material, or `null` on web / when no
|
|
1425
|
+
* identity key is available on this device.
|
|
1426
|
+
*/
|
|
1427
|
+
static async deriveScopedSeed(info) {
|
|
1428
|
+
if (isWebPlatform()) {
|
|
1429
|
+
return null;
|
|
1430
|
+
}
|
|
1431
|
+
const privateKey = (await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey());
|
|
1432
|
+
if (!privateKey) {
|
|
1433
|
+
return null;
|
|
1434
|
+
}
|
|
1435
|
+
const ikm = hexToBytes(KeyManager.canonicalPrivateKey(privateKey));
|
|
1436
|
+
return hkdfSha256(ikm, utf8ToBytes(SCOPED_SEED_KDF_SALT), utf8ToBytes(info), 32);
|
|
1437
|
+
}
|
|
1388
1438
|
/**
|
|
1389
1439
|
* Get a shortened version of the public key for display
|
|
1390
1440
|
* Format: first 8 chars...last 8 chars
|
|
@@ -1449,6 +1449,13 @@
|
|
|
1449
1449
|
"emptyTitle": "No photos yet",
|
|
1450
1450
|
"emptySubtitle": "Upload from your device to get started"
|
|
1451
1451
|
},
|
|
1452
|
+
"details": {
|
|
1453
|
+
"title": "File Details",
|
|
1454
|
+
"download": "Download",
|
|
1455
|
+
"type": "Type",
|
|
1456
|
+
"uploaded": "Uploaded",
|
|
1457
|
+
"description": "Description"
|
|
1458
|
+
},
|
|
1452
1459
|
"a11y": {
|
|
1453
1460
|
"viewAll": "Show all files",
|
|
1454
1461
|
"viewPhotos": "Show photos only",
|
|
@@ -1449,6 +1449,13 @@
|
|
|
1449
1449
|
"emptyTitle": "Aún no hay fotos",
|
|
1450
1450
|
"emptySubtitle": "Sube desde tu dispositivo para empezar"
|
|
1451
1451
|
},
|
|
1452
|
+
"details": {
|
|
1453
|
+
"title": "Detalles del archivo",
|
|
1454
|
+
"download": "Descargar",
|
|
1455
|
+
"type": "Tipo",
|
|
1456
|
+
"uploaded": "Subido",
|
|
1457
|
+
"description": "Descripción"
|
|
1458
|
+
},
|
|
1452
1459
|
"a11y": {
|
|
1453
1460
|
"viewAll": "Mostrar todos los archivos",
|
|
1454
1461
|
"viewPhotos": "Mostrar solo fotos",
|
|
@@ -1449,6 +1449,13 @@
|
|
|
1449
1449
|
"emptyTitle": "No photos yet",
|
|
1450
1450
|
"emptySubtitle": "Upload from your device to get started"
|
|
1451
1451
|
},
|
|
1452
|
+
"details": {
|
|
1453
|
+
"title": "File Details",
|
|
1454
|
+
"download": "Download",
|
|
1455
|
+
"type": "Type",
|
|
1456
|
+
"uploaded": "Uploaded",
|
|
1457
|
+
"description": "Description"
|
|
1458
|
+
},
|
|
1452
1459
|
"a11y": {
|
|
1453
1460
|
"viewAll": "Show all files",
|
|
1454
1461
|
"viewPhotos": "Show photos only",
|
|
@@ -1449,6 +1449,13 @@
|
|
|
1449
1449
|
"emptyTitle": "Aún no hay fotos",
|
|
1450
1450
|
"emptySubtitle": "Sube desde tu dispositivo para empezar"
|
|
1451
1451
|
},
|
|
1452
|
+
"details": {
|
|
1453
|
+
"title": "Detalles del archivo",
|
|
1454
|
+
"download": "Descargar",
|
|
1455
|
+
"type": "Tipo",
|
|
1456
|
+
"uploaded": "Subido",
|
|
1457
|
+
"description": "Descripción"
|
|
1458
|
+
},
|
|
1452
1459
|
"a11y": {
|
|
1453
1460
|
"viewAll": "Mostrar todos los archivos",
|
|
1454
1461
|
"viewPhotos": "Mostrar solo fotos",
|
package/dist/esm/index.js
CHANGED
|
@@ -21,7 +21,7 @@ import './crypto/polyfill.js';
|
|
|
21
21
|
// ---------------------------------------------------------------------------
|
|
22
22
|
// API client
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
24
|
-
export { OxyServices, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.js';
|
|
24
|
+
export { OxyServices, AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.js';
|
|
25
25
|
export { OXY_CLOUD_URL, oxyClient } from './OxyServices.js';
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
// Authentication
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isReactNative } from '@oxyhq/protocol';
|
|
2
2
|
import { logger } from '../logger/index.js';
|
|
3
|
+
import { AssetUrlResolutionError } from '../OxyServices.errors.js';
|
|
3
4
|
import { extractErrorStatus } from '../utils/errorUtils.js';
|
|
4
5
|
/**
|
|
5
6
|
* Maximum number of ids sent per `POST /assets/service/by-ids` request. Matches
|
|
@@ -21,6 +22,37 @@ const SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE = 100;
|
|
|
21
22
|
* never 400s an otherwise-valid chunk on the server.
|
|
22
23
|
*/
|
|
23
24
|
const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/;
|
|
25
|
+
/**
|
|
26
|
+
* Conservative lower bound (10 min) the SDK assumes for the lifetime of the
|
|
27
|
+
* scoped media token (`mt`) the API embeds in the stream URL it returns for a
|
|
28
|
+
* private asset. The SDK never mints or inspects that token; this constant
|
|
29
|
+
* exists only so the SDK's own URL cache can be sized safely BELOW the token's
|
|
30
|
+
* real lifetime.
|
|
31
|
+
*
|
|
32
|
+
* The API currently mints tokens for 900s (`MEDIA_TOKEN_TTL_SECONDS` in
|
|
33
|
+
* `packages/api/src/utils/mediaToken.ts`). Core deliberately assumes a shorter
|
|
34
|
+
* 10-min floor rather than copying 15: core and the API are separate packages,
|
|
35
|
+
* so core cannot observe a server-side TTL change at runtime. Under-assuming the
|
|
36
|
+
* lifetime only ever shortens the cache (more refetches, never a dead URL), so
|
|
37
|
+
* it stays correct even if the server lowers its TTL toward this floor. The
|
|
38
|
+
* {@link ASSET_URL_CACHE_LIFETIME_FRACTION} discount is applied on top.
|
|
39
|
+
*/
|
|
40
|
+
const ASSET_MEDIA_TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
41
|
+
/**
|
|
42
|
+
* Fraction of a resolved URL's remaining lifetime the SDK is willing to keep it
|
|
43
|
+
* cached for. A resolved URL stops working the moment its media token expires,
|
|
44
|
+
* so caching it for its full nominal lifetime guarantees a window in which the
|
|
45
|
+
* cache hands out an already-dead URL (clock skew, time spent in the render
|
|
46
|
+
* pipeline, an image request queued behind others). Half the lifetime leaves a
|
|
47
|
+
* margin at least as large as the entry's own age.
|
|
48
|
+
*/
|
|
49
|
+
const ASSET_URL_CACHE_LIFETIME_FRACTION = 0.5;
|
|
50
|
+
/**
|
|
51
|
+
* Fallback URL lifetime, in seconds, assumed when the caller does not request
|
|
52
|
+
* an explicit `expiresIn`. Mirrors the API's default signed-URL expiry; the
|
|
53
|
+
* effective value is still clamped by {@link ASSET_MEDIA_TOKEN_TTL_MS}.
|
|
54
|
+
*/
|
|
55
|
+
const DEFAULT_ASSET_URL_EXPIRES_IN_SECONDS = 3600;
|
|
24
56
|
export function OxyServicesAssetsMixin(Base) {
|
|
25
57
|
return class extends Base {
|
|
26
58
|
constructor(...args) {
|
|
@@ -38,14 +70,33 @@ export function OxyServicesAssetsMixin(Base) {
|
|
|
38
70
|
}
|
|
39
71
|
}
|
|
40
72
|
/**
|
|
41
|
-
* Build a synchronous, `<img src>`-ready
|
|
73
|
+
* Build a synchronous, `<img src>`-ready URL for a **PUBLIC** Oxy asset.
|
|
74
|
+
*
|
|
75
|
+
* ## Contract — read before calling
|
|
42
76
|
*
|
|
43
|
-
* This
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
77
|
+
* This is a pure string builder. It performs no network call and therefore
|
|
78
|
+
* has **no knowledge of the asset's visibility**. It always produces the
|
|
79
|
+
* public form: `${cloudURL}/<id>[?variant=…]`, which the CDN serves from
|
|
80
|
+
* the public media origin only.
|
|
81
|
+
*
|
|
82
|
+
* Consequently:
|
|
83
|
+
* - Call it ONLY when the asset is known to be `public` — e.g. avatars and
|
|
84
|
+
* profile banners, which {@link uploadAvatar} / {@link uploadProfileBanner}
|
|
85
|
+
* upload with `visibility: 'public'`.
|
|
86
|
+
* - For an asset that may be `private` or `unlisted` — anything uploaded
|
|
87
|
+
* through the generic {@link assetUpload} path, whose server-side default
|
|
88
|
+
* is private — this URL resolves to a hard **404**. Use
|
|
89
|
+
* {@link getFileDownloadUrlAsync}, which asks the API for a URL scoped to
|
|
90
|
+
* the current caller.
|
|
91
|
+
* - It must never guess visibility, and must never embed the caller's
|
|
92
|
+
* bearer token: the returned string is rendered into DOM attributes,
|
|
93
|
+
* browser network panels, HTTP caches, and logs.
|
|
94
|
+
*
|
|
95
|
+
* Passing `expiresIn` switches to the API-origin stream form
|
|
96
|
+
* (`${baseURL}/assets/<id>/stream?…`) WITHOUT any credential. That form
|
|
97
|
+
* still only serves what an unauthenticated request may see — it is not a
|
|
98
|
+
* synchronous private-asset path, and none exists: authorization for a
|
|
99
|
+
* private asset requires the round-trip in {@link getFileDownloadUrlAsync}.
|
|
49
100
|
*/
|
|
50
101
|
getFileDownloadUrl(fileId, variant, expiresIn) {
|
|
51
102
|
// Never embed the in-memory bearer token: this URL is rendered into DOM
|
|
@@ -66,16 +117,40 @@ export function OxyServicesAssetsMixin(Base) {
|
|
|
66
117
|
return `${base}/assets/${encodeURIComponent(fileId)}/stream${qs ? `?${qs}` : ''}`;
|
|
67
118
|
}
|
|
68
119
|
/**
|
|
69
|
-
*
|
|
120
|
+
* Resolve an asset id to a URL that is valid for the CURRENT caller,
|
|
121
|
+
* whatever the asset's visibility.
|
|
122
|
+
*
|
|
123
|
+
* Asks the API (`GET /assets/:id/url`) rather than guessing: a `public`
|
|
124
|
+
* asset resolves to the CDN form, while a `private`/`unlisted` asset the
|
|
125
|
+
* caller may read resolves to an API-origin stream URL carrying a scoped,
|
|
126
|
+
* short-lived media token. The returned URL is passed through **unchanged**
|
|
127
|
+
* — the SDK never rewrites, re-signs, or strips it.
|
|
128
|
+
*
|
|
129
|
+
* ## Failure behaviour — no CDN fallback
|
|
130
|
+
*
|
|
131
|
+
* Throws {@link AssetUrlResolutionError} when the API returns no URL or the
|
|
132
|
+
* request fails (including 401/403/404). It deliberately does NOT fall back
|
|
133
|
+
* to {@link getFileDownloadUrl}: that builder only produces the public CDN
|
|
134
|
+
* form, so falling back would hand the caller a URL that renders as a hard
|
|
135
|
+
* 404 for every private asset and would silently swallow the real failure.
|
|
136
|
+
* A caller that knows an asset is public should call the synchronous
|
|
137
|
+
* builder directly instead of relying on a fallback here.
|
|
138
|
+
*
|
|
139
|
+
* The resolved URL is cached per identity for well under the media token's
|
|
140
|
+
* lifetime — see {@link getAssetUrlCacheTTL}.
|
|
70
141
|
*/
|
|
71
142
|
async getFileDownloadUrlAsync(fileId, variant, expiresIn) {
|
|
143
|
+
let url;
|
|
72
144
|
try {
|
|
73
|
-
|
|
74
|
-
return url || this.getFileDownloadUrl(fileId, variant, expiresIn);
|
|
145
|
+
url = await this.fetchAssetDownloadUrl(fileId, variant, this.getAssetUrlCacheTTL(expiresIn), expiresIn);
|
|
75
146
|
}
|
|
76
147
|
catch (error) {
|
|
77
|
-
|
|
148
|
+
throw new AssetUrlResolutionError(fileId, variant, extractErrorStatus(error), error);
|
|
149
|
+
}
|
|
150
|
+
if (!url) {
|
|
151
|
+
throw new AssetUrlResolutionError(fileId, variant, undefined);
|
|
78
152
|
}
|
|
153
|
+
return url;
|
|
79
154
|
}
|
|
80
155
|
/**
|
|
81
156
|
* List user files
|
|
@@ -139,13 +214,36 @@ export function OxyServicesAssetsMixin(Base) {
|
|
|
139
214
|
}
|
|
140
215
|
}
|
|
141
216
|
/**
|
|
142
|
-
*
|
|
217
|
+
* Resolve access + a caller-scoped URL for many assets — each with its OWN
|
|
218
|
+
* requested variant — in ONE round trip via `POST /assets/batch-access`.
|
|
219
|
+
*
|
|
220
|
+
* `requests` is a per-file `{ fileId, variant? }` list (a `variant` of
|
|
221
|
+
* `undefined` asks for the original). `options.expiresIn` sets the requested
|
|
222
|
+
* media-token / signed-URL lifetime (seconds); `options.context` is the
|
|
223
|
+
* server-side access-check context. Entries with a blank `fileId` are
|
|
224
|
+
* dropped and exact `(fileId, variant)` duplicates are collapsed before the
|
|
225
|
+
* request; an empty effective list performs no network call.
|
|
226
|
+
*
|
|
227
|
+
* The server caps the batch at 100 entries — callers that page beyond that
|
|
228
|
+
* must chunk. Returns the raw per-file envelope (see
|
|
229
|
+
* {@link BatchFileAccessResponse}); most callers want {@link getFileDownloadUrls},
|
|
230
|
+
* which flattens it to just the usable URLs.
|
|
143
231
|
*/
|
|
144
|
-
async getBatchFileAccess(
|
|
232
|
+
async getBatchFileAccess(requests, options) {
|
|
233
|
+
const files = dedupeFileAccessRequests(requests);
|
|
234
|
+
if (files.length === 0) {
|
|
235
|
+
return { results: {} };
|
|
236
|
+
}
|
|
237
|
+
const body = {
|
|
238
|
+
files,
|
|
239
|
+
};
|
|
240
|
+
if (typeof options?.expiresIn === 'number')
|
|
241
|
+
body.expiresIn = options.expiresIn;
|
|
242
|
+
if (typeof options?.context === 'string')
|
|
243
|
+
body.context = options.context;
|
|
145
244
|
try {
|
|
146
|
-
return await this.makeRequest('POST', '/assets/batch-access', {
|
|
147
|
-
|
|
148
|
-
context
|
|
245
|
+
return await this.makeRequest('POST', '/assets/batch-access', body, {
|
|
246
|
+
cache: false,
|
|
149
247
|
});
|
|
150
248
|
}
|
|
151
249
|
catch (error) {
|
|
@@ -153,13 +251,27 @@ export function OxyServicesAssetsMixin(Base) {
|
|
|
153
251
|
}
|
|
154
252
|
}
|
|
155
253
|
/**
|
|
156
|
-
*
|
|
254
|
+
* Resolve many assets — each with its OWN variant — to caller-scoped,
|
|
255
|
+
* `<img src>`-ready URLs in one round trip. The batch counterpart of
|
|
256
|
+
* {@link getFileDownloadUrlAsync}, built to resolve a whole grid page at once.
|
|
257
|
+
*
|
|
258
|
+
* `requests` is a per-file `{ fileId, variant? }` list (e.g. `poster` for a
|
|
259
|
+
* video, `thumb` for an image); the per-file variant RULE lives in the
|
|
260
|
+
* caller — core just forwards what it is given. `options.expiresIn` /
|
|
261
|
+
* `options.context` are passed through to the endpoint.
|
|
262
|
+
*
|
|
263
|
+
* Each returned URL is the API's own scoped form, passed through unchanged:
|
|
264
|
+
* the public CDN URL for a public asset, or an API-origin
|
|
265
|
+
* `/assets/:id/stream?…&mt=<media token>` URL for a private asset the caller
|
|
266
|
+
* may read. Ids the caller cannot access (or that do not exist) are simply
|
|
267
|
+
* OMITTED from the returned map — there is NO public-CDN fallback, so a grid
|
|
268
|
+
* never renders a known-404 URL. Callers detect a miss by the absent key
|
|
269
|
+
* (the map never contains an empty-string value). Keyed by `fileId`.
|
|
157
270
|
*/
|
|
158
|
-
async getFileDownloadUrls(
|
|
159
|
-
const response = await this.getBatchFileAccess(
|
|
271
|
+
async getFileDownloadUrls(requests, options) {
|
|
272
|
+
const response = await this.getBatchFileAccess(requests, options);
|
|
160
273
|
const urls = {};
|
|
161
|
-
const
|
|
162
|
-
for (const [id, result] of Object.entries(results)) {
|
|
274
|
+
for (const [id, result] of Object.entries(response.results ?? {})) {
|
|
163
275
|
if (result.allowed && result.url) {
|
|
164
276
|
urls[id] = result.url;
|
|
165
277
|
}
|
|
@@ -458,7 +570,7 @@ export function OxyServicesAssetsMixin(Base) {
|
|
|
458
570
|
params.expiresIn = expiresIn;
|
|
459
571
|
return await this.makeRequest('GET', `/assets/${fileId}/url`, params, {
|
|
460
572
|
cache: true,
|
|
461
|
-
cacheTTL:
|
|
573
|
+
cacheTTL: this.getAssetUrlCacheTTL(expiresIn),
|
|
462
574
|
});
|
|
463
575
|
}
|
|
464
576
|
catch (error) {
|
|
@@ -546,9 +658,21 @@ export function OxyServicesAssetsMixin(Base) {
|
|
|
546
658
|
throw this.handleError(error);
|
|
547
659
|
}
|
|
548
660
|
}
|
|
661
|
+
/**
|
|
662
|
+
* How long a resolved asset URL may stay in the SDK's GET cache, in ms.
|
|
663
|
+
*
|
|
664
|
+
* A resolved private-asset URL dies the instant its scoped media token
|
|
665
|
+
* expires (~{@link ASSET_MEDIA_TOKEN_TTL_MS}). Caching it for its full
|
|
666
|
+
* nominal lifetime would leave a window where the cache serves an
|
|
667
|
+
* already-dead URL (clock skew, render-pipeline latency, an image request
|
|
668
|
+
* queued behind others). So the TTL is (a) never longer than the token's
|
|
669
|
+
* lifetime and (b) discounted to {@link ASSET_URL_CACHE_LIFETIME_FRACTION}
|
|
670
|
+
* of that bound — comfortably below the token TTL by construction.
|
|
671
|
+
*/
|
|
549
672
|
getAssetUrlCacheTTL(expiresIn) {
|
|
550
|
-
const
|
|
551
|
-
|
|
673
|
+
const requestedLifetimeMs = (expiresIn ?? DEFAULT_ASSET_URL_EXPIRES_IN_SECONDS) * 1000;
|
|
674
|
+
const boundedLifetimeMs = Math.min(requestedLifetimeMs, ASSET_MEDIA_TOKEN_TTL_MS);
|
|
675
|
+
return Math.floor(boundedLifetimeMs * ASSET_URL_CACHE_LIFETIME_FRACTION);
|
|
552
676
|
}
|
|
553
677
|
async fetchAssetDownloadUrl(fileId, variant, cacheTTL, expiresIn) {
|
|
554
678
|
const params = {};
|
|
@@ -558,7 +682,10 @@ export function OxyServicesAssetsMixin(Base) {
|
|
|
558
682
|
params.expiresIn = expiresIn;
|
|
559
683
|
const urlRes = await this.makeRequest('GET', `/assets/${encodeURIComponent(fileId)}/url`, Object.keys(params).length ? params : undefined, {
|
|
560
684
|
cache: true,
|
|
561
|
-
|
|
685
|
+
// Cap the cached URL well below the media token's lifetime. The
|
|
686
|
+
// response body is a scoped, expiring URL; over-caching it serves a
|
|
687
|
+
// dead URL after the token expires (see getAssetUrlCacheTTL).
|
|
688
|
+
cacheTTL: cacheTTL ?? this.getAssetUrlCacheTTL(expiresIn),
|
|
562
689
|
});
|
|
563
690
|
return urlRes?.url || null;
|
|
564
691
|
}
|
|
@@ -573,6 +700,29 @@ export function OxyServicesAssetsMixin(Base) {
|
|
|
573
700
|
}
|
|
574
701
|
};
|
|
575
702
|
}
|
|
703
|
+
/**
|
|
704
|
+
* Normalize the per-file batch-access request list: drop entries with a blank
|
|
705
|
+
* `fileId` and collapse exact `(fileId, variant)` duplicates (first occurrence
|
|
706
|
+
* wins, preserving order). Two entries for the SAME `fileId` with DIFFERENT
|
|
707
|
+
* variants are intentionally kept — but note the response is keyed by `fileId`,
|
|
708
|
+
* so a caller that needs two variants of one file must issue separate calls.
|
|
709
|
+
*/
|
|
710
|
+
function dedupeFileAccessRequests(requests) {
|
|
711
|
+
const seen = new Set();
|
|
712
|
+
const out = [];
|
|
713
|
+
for (const req of requests) {
|
|
714
|
+
if (typeof req?.fileId !== 'string' || req.fileId.trim().length === 0) {
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
const key = `${req.fileId}\u0000${req.variant ?? ''}`;
|
|
718
|
+
if (seen.has(key)) {
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
seen.add(key);
|
|
722
|
+
out.push(req.variant === undefined ? { fileId: req.fileId } : { fileId: req.fileId, variant: req.variant });
|
|
723
|
+
}
|
|
724
|
+
return out;
|
|
725
|
+
}
|
|
576
726
|
/**
|
|
577
727
|
* Only send ambient credentials (cookies) when the asset URL is same-origin with
|
|
578
728
|
* the configured API base. Caller-supplied cross-origin asset URLs must not leak
|