@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.
Files changed (60) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +4 -1
  3. package/dist/cjs/OxyServices.errors.js +42 -1
  4. package/dist/cjs/OxyServices.js +2 -1
  5. package/dist/cjs/crypto/keyManager.js +50 -0
  6. package/dist/cjs/i18n/locales/en-US.json +7 -0
  7. package/dist/cjs/i18n/locales/es-ES.json +7 -0
  8. package/dist/cjs/i18n/locales/locales/en-US.json +7 -0
  9. package/dist/cjs/i18n/locales/locales/es-ES.json +7 -0
  10. package/dist/cjs/index.js +5 -4
  11. package/dist/cjs/mixins/OxyServices.assets.js +175 -25
  12. package/dist/cjs/mixins/OxyServices.deviceTransfer.js +319 -0
  13. package/dist/cjs/mixins/index.js +4 -0
  14. package/dist/cjs/session/SessionClient.js +57 -8
  15. package/dist/cjs/utils/redactUrl.js +29 -0
  16. package/dist/esm/.tsbuildinfo +1 -1
  17. package/dist/esm/HttpService.js +4 -1
  18. package/dist/esm/OxyServices.errors.js +40 -0
  19. package/dist/esm/OxyServices.js +2 -2
  20. package/dist/esm/crypto/keyManager.js +50 -0
  21. package/dist/esm/i18n/locales/en-US.json +7 -0
  22. package/dist/esm/i18n/locales/es-ES.json +7 -0
  23. package/dist/esm/i18n/locales/locales/en-US.json +7 -0
  24. package/dist/esm/i18n/locales/locales/es-ES.json +7 -0
  25. package/dist/esm/index.js +1 -1
  26. package/dist/esm/mixins/OxyServices.assets.js +175 -25
  27. package/dist/esm/mixins/OxyServices.deviceTransfer.js +317 -0
  28. package/dist/esm/mixins/index.js +4 -0
  29. package/dist/esm/session/SessionClient.js +57 -8
  30. package/dist/esm/utils/redactUrl.js +26 -0
  31. package/dist/types/.tsbuildinfo +1 -1
  32. package/dist/types/OxyServices.d.ts +2 -2
  33. package/dist/types/OxyServices.errors.d.ts +40 -0
  34. package/dist/types/crypto/keyManager.d.ts +20 -0
  35. package/dist/types/index.d.ts +3 -2
  36. package/dist/types/mixins/OxyServices.assets.d.ts +103 -13
  37. package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +149 -0
  38. package/dist/types/mixins/index.d.ts +2 -1
  39. package/dist/types/models/interfaces.d.ts +18 -0
  40. package/dist/types/session/SessionClient.d.ts +19 -2
  41. package/dist/types/utils/redactUrl.d.ts +17 -0
  42. package/package.json +1 -1
  43. package/src/HttpService.ts +4 -1
  44. package/src/OxyServices.errors.ts +51 -0
  45. package/src/OxyServices.ts +2 -2
  46. package/src/crypto/__tests__/scopedSeed.test.ts +126 -0
  47. package/src/crypto/keyManager.ts +55 -0
  48. package/src/i18n/locales/en-US.json +7 -0
  49. package/src/i18n/locales/es-ES.json +7 -0
  50. package/src/index.ts +7 -1
  51. package/src/mixins/OxyServices.assets.ts +192 -28
  52. package/src/mixins/OxyServices.deviceTransfer.ts +397 -0
  53. package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +270 -0
  54. package/src/mixins/__tests__/getFileDownloadUrl.test.ts +265 -1
  55. package/src/mixins/index.ts +6 -0
  56. package/src/models/interfaces.ts +20 -0
  57. package/src/session/SessionClient.ts +59 -8
  58. package/src/session/__tests__/SessionClient.switchTokenOrder.test.ts +170 -0
  59. package/src/utils/__tests__/redactUrl.test.ts +33 -0
  60. package/src/utils/redactUrl.ts +28 -0
@@ -0,0 +1,126 @@
1
+ import { setPlatformOS } from '../../utils/platform';
2
+
3
+ jest.mock(
4
+ 'expo-secure-store',
5
+ () => {
6
+ const store = new Map<string, string>();
7
+ return {
8
+ __esModule: true,
9
+ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
10
+ WHEN_UNLOCKED: 'WHEN_UNLOCKED',
11
+ setItemAsync: jest.fn(async (k: string, v: string) => { store.set(k, v); }),
12
+ getItemAsync: jest.fn(async (k: string) => store.get(k) ?? null),
13
+ deleteItemAsync: jest.fn(async (k: string) => { store.delete(k); }),
14
+ __resetStore__: () => store.clear(),
15
+ };
16
+ },
17
+ { virtual: true },
18
+ );
19
+
20
+ jest.mock(
21
+ 'expo-crypto',
22
+ () => ({
23
+ __esModule: true,
24
+ getRandomBytes: (length: number) => {
25
+ const out = new Uint8Array(length);
26
+ for (let i = 0; i < length; i++) out[i] = (Math.random() * 256) & 0xff;
27
+ return out;
28
+ },
29
+ digestStringAsync: async () => '0'.repeat(64),
30
+ CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
31
+ }),
32
+ { virtual: true },
33
+ );
34
+
35
+ jest.mock('@oxyhq/protocol', () => ({
36
+ __esModule: true,
37
+ ...jest.requireActual('@oxyhq/protocol'),
38
+ loadExpoCrypto: async () => require('expo-crypto'),
39
+ loadSecureStore: async () => require('expo-secure-store'),
40
+ loadNodeCrypto: async () => require('crypto'),
41
+ loadSharedIdentityBridge: async () => null,
42
+ }));
43
+
44
+ const FIXED_PRIV = 'aa'.repeat(32);
45
+ const EXPECTED_FAIR = '4b90d900a11b0a1737ed643db3446e5f28035d86f1a4fda92474ea8ab152adf5';
46
+ const EXPECTED_OTHER = 'cdedf1f076b0f4766c769e55bc1e90c5bf44d8630f6e5fb147615ee7c330c905';
47
+ const toHex = (b: Uint8Array): string => Buffer.from(b).toString('hex');
48
+
49
+ describe('KeyManager.deriveScopedSeed', () => {
50
+ let KeyManager: typeof import('../keyManager').KeyManager;
51
+
52
+ beforeEach(async () => {
53
+ jest.resetModules();
54
+ setPlatformOS('ios');
55
+ const secureStore = (await import('expo-secure-store' as string)) as unknown as {
56
+ __resetStore__: () => void;
57
+ };
58
+ secureStore.__resetStore__();
59
+ const km = await import('../keyManager');
60
+ KeyManager = km.KeyManager;
61
+ // Store a known shared identity key so derivation is deterministic.
62
+ await KeyManager.importSharedIdentity(FIXED_PRIV);
63
+ });
64
+
65
+ it('derives the pinned 32-byte seed for a fixed identity + info', async () => {
66
+ const seed = await KeyManager.deriveScopedSeed('oxypay/faircoin/v1');
67
+ if (!seed) throw new Error('expected a seed');
68
+ expect(seed).toHaveLength(32);
69
+ expect(toHex(seed)).toBe(EXPECTED_FAIR);
70
+ });
71
+
72
+ it('is deterministic (same identity + info → identical seed)', async () => {
73
+ const a = await KeyManager.deriveScopedSeed('oxypay/faircoin/v1');
74
+ const b = await KeyManager.deriveScopedSeed('oxypay/faircoin/v1');
75
+ if (!a || !b) throw new Error('expected seeds');
76
+ expect(toHex(a)).toBe(toHex(b));
77
+ });
78
+
79
+ it('domain-separates by info (different info → different seed)', async () => {
80
+ const fair = await KeyManager.deriveScopedSeed('oxypay/faircoin/v1');
81
+ const other = await KeyManager.deriveScopedSeed('oxypay/other/v1');
82
+ if (!fair || !other) throw new Error('expected seeds');
83
+ expect(toHex(other)).toBe(EXPECTED_OTHER);
84
+ expect(toHex(fair)).not.toBe(toHex(other));
85
+ });
86
+
87
+ it('never returns the raw private key (no leak)', async () => {
88
+ const seed = await KeyManager.deriveScopedSeed('oxypay/faircoin/v1');
89
+ if (!seed) throw new Error('expected a seed');
90
+ expect(toHex(seed)).not.toBe(FIXED_PRIV);
91
+ });
92
+
93
+ it('returns null on web (no identity key available)', async () => {
94
+ // `jest.resetModules()` gives KeyManager its own bound instance of
95
+ // `utils/platform` (its cached OS locks in as soon as `beforeEach`'s
96
+ // `importSharedIdentity` first checks it). The file-level `setPlatformOS`
97
+ // import above is bound to a different, earlier instance, so mutating it
98
+ // here would not be observed by the already-imported `KeyManager`. Reset
99
+ // again and re-import both from the same fresh module graph so the
100
+ // platform flip actually reaches the KeyManager instance under test.
101
+ //
102
+ // `jest.resetModules()` also re-runs the virtual `expo-secure-store`
103
+ // mock factory, handing this fresh KeyManager a brand-new empty
104
+ // in-memory store — the identity `beforeEach` imported lives only in the
105
+ // pre-reset store instance. Without re-storing an identity here, a
106
+ // `null` result would be ambiguous between the web gate and the
107
+ // no-identity fallback. Re-import the identity on THIS fresh instance
108
+ // (while still native — `importSharedIdentity` itself is native-gated
109
+ // and throws on web) BEFORE flipping to web, so the later `null` can
110
+ // only be explained by the web gate, not a missing identity.
111
+ jest.resetModules();
112
+ const platform = await import('../../utils/platform');
113
+ const km = await import('../keyManager');
114
+ await km.KeyManager.importSharedIdentity(FIXED_PRIV);
115
+ platform.setPlatformOS('web');
116
+ expect(await km.KeyManager.deriveScopedSeed('oxypay/faircoin/v1')).toBeNull();
117
+ });
118
+
119
+ it('returns null when no identity exists on the device', async () => {
120
+ const secureStore = (await import('expo-secure-store' as string)) as unknown as {
121
+ __resetStore__: () => void;
122
+ };
123
+ secureStore.__resetStore__();
124
+ expect(await KeyManager.deriveScopedSeed('oxypay/faircoin/v1')).toBeNull();
125
+ });
126
+ });
@@ -10,6 +10,7 @@ import type { ECKeyPair } from 'elliptic';
10
10
  import { isWeb, isIOS, isAndroid } from '../utils/platform';
11
11
  import { type ExpoCryptoLike, type ExpoSecureStoreLike, isReactNative, isNodeJS, loadExpoCrypto, loadNodeCrypto, loadSecureStore, loadSharedIdentityBridge } from '@oxyhq/protocol';
12
12
  import { isDev, logger } from '../logger';
13
+ import { hkdfSha256 } from './kdf';
13
14
 
14
15
  /**
15
16
  * Options for expo-secure-store calls made by KeyManager.
@@ -79,6 +80,28 @@ export class IdentityPersistError extends Error {
79
80
 
80
81
  const ec = new EC('secp256k1');
81
82
 
83
+ /**
84
+ * HKDF salt that domain-separates every identity-scoped seed produced by
85
+ * {@link KeyManager.deriveScopedSeed}. Versioned so a future scheme change is a
86
+ * new, non-colliding tag. The per-app domain (e.g. Oxy Pay's FairCoin wallet)
87
+ * is carried by the caller's `info` string, not this salt.
88
+ */
89
+ const SCOPED_SEED_KDF_SALT = 'oxy-identity-scoped-seed-v1';
90
+
91
+ /** UTF-8 encode an ASCII label to bytes (HKDF salt/info). */
92
+ function utf8ToBytes(label: string): Uint8Array {
93
+ return new TextEncoder().encode(label);
94
+ }
95
+
96
+ /** Decode a hex string to bytes. Inverse of {@link uint8ArrayToHex}. */
97
+ function hexToBytes(hex: string): Uint8Array {
98
+ const out = new Uint8Array(hex.length / 2);
99
+ for (let i = 0; i < out.length; i++) {
100
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
101
+ }
102
+ return out;
103
+ }
104
+
82
105
  const STORAGE_KEYS = {
83
106
  PRIVATE_KEY: 'oxy_identity_private_key',
84
107
  PUBLIC_KEY: 'oxy_identity_public_key',
@@ -1559,6 +1582,38 @@ export class KeyManager {
1559
1582
  }
1560
1583
  }
1561
1584
 
1585
+ /**
1586
+ * Derive a 32-byte, domain-separated seed from the on-device Oxy identity
1587
+ * private key via HKDF-SHA256, WITHOUT ever exposing the raw private key.
1588
+ *
1589
+ * The domain separation is carried by `info` (e.g. `"oxypay/faircoin/v1"`),
1590
+ * so distinct apps/purposes get independent seeds from the same identity.
1591
+ * The output is HKDF keying material, never the private key itself — a
1592
+ * consumer (e.g. Oxy Pay's FairCoin HD wallet) can feed it straight into
1593
+ * `HDKey.fromMasterSeed` and never touches the identity key.
1594
+ *
1595
+ * Key source (native only): prefers the shared ecosystem identity written to
1596
+ * `group.so.oxy.shared` (what a Relying Party like Oxy Pay reads), then falls
1597
+ * back to this device's primary identity (Commons/Accounts). Both reproduce
1598
+ * from the user's Oxy recovery phrase, so the derived seed is recoverable.
1599
+ *
1600
+ * @param info Context/domain-binding label (distinct labels → independent seeds).
1601
+ * @returns 32 bytes of derived keying material, or `null` on web / when no
1602
+ * identity key is available on this device.
1603
+ */
1604
+ static async deriveScopedSeed(info: string): Promise<Uint8Array | null> {
1605
+ if (isWebPlatform()) {
1606
+ return null;
1607
+ }
1608
+ const privateKey =
1609
+ (await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey());
1610
+ if (!privateKey) {
1611
+ return null;
1612
+ }
1613
+ const ikm = hexToBytes(KeyManager.canonicalPrivateKey(privateKey));
1614
+ return hkdfSha256(ikm, utf8ToBytes(SCOPED_SEED_KDF_SALT), utf8ToBytes(info), 32);
1615
+ }
1616
+
1562
1617
  /**
1563
1618
  * Get a shortened version of the public key for display
1564
1619
  * 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",
package/src/index.ts CHANGED
@@ -23,7 +23,7 @@ import './crypto/polyfill';
23
23
  // ---------------------------------------------------------------------------
24
24
  // API client
25
25
  // ---------------------------------------------------------------------------
26
- export { OxyServices, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices';
26
+ export { OxyServices, AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices';
27
27
  export { OXY_CLOUD_URL, oxyClient } from './OxyServices';
28
28
  export type { LinkedHttpClient } from './OxyServices.base';
29
29
  // Auth-refresh handler surface — consumed by `@oxyhq/services`'s OxyContext to
@@ -48,6 +48,10 @@ export type {
48
48
  ContactDiscoveryMatch,
49
49
  ContactDiscoveryResponse,
50
50
  } from './mixins/OxyServices.contacts';
51
+ export type {
52
+ InitDeviceTransferResult,
53
+ DeviceTransferOutcome,
54
+ } from './mixins/OxyServices.deviceTransfer';
51
55
  export type {
52
56
  BulkFollowEntry,
53
57
  BulkFollowResult,
@@ -311,6 +315,8 @@ export type {
311
315
  AssetLinkRequest,
312
316
  AssetUnlinkRequest,
313
317
  AssetUrlResponse,
318
+ BatchFileAccessEntry,
319
+ BatchFileAccessResponse,
314
320
  AssetDeleteSummary,
315
321
  AssetUpdateVisibilityRequest,
316
322
  AssetUpdateVisibilityResponse,
@@ -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 file URL from an Oxy asset id.
100
+ * Build a synchronous, `<img src>`-ready URL for a **PUBLIC** Oxy asset.
101
+ *
102
+ * ## Contract — read before calling
65
103
  *
66
- * This method must never embed the caller's general access token in the
67
- * returned URL. The URL is commonly rendered into DOM attributes, browser
68
- * network panels, caches, and logs. Public asset URLs use the clean CDN
69
- * origin; callers that need authorized/private access should use
70
- * {@link getFileDownloadUrlAsync}, which asks the API for a scoped download
71
- * URL instead of exposing the in-memory bearer token in a query string.
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
- * Get file download URL asynchronously (returns signed URL directly from CDN)
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
- const url = await this.fetchAssetDownloadUrl(
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
- return url || this.getFileDownloadUrl(fileId, variant, expiresIn);
106
- } catch (error) {
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
- * Get batch access to multiple files
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(fileIds: string[], context?: string): Promise<Record<string, any>> {
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
- fileIds,
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
- * Get download URLs for multiple files efficiently
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(fileIds: string[], context?: string): Promise<Record<string, string>> {
200
- const response: any = await this.getBatchFileAccess(fileIds, context);
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 results = response.results || {};
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: 10 * 60 * 1000,
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
- public getAssetUrlCacheTTL(expiresIn?: number) {
618
- const desiredTtlMs = (expiresIn ?? 3600) * 1000;
619
- return Math.min(desiredTtlMs, 10 * 60 * 1000);
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
- cacheTTL: cacheTTL ?? 10 * 60 * 1000,
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