@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.
Files changed (36) 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/index.js +5 -4
  6. package/dist/cjs/mixins/OxyServices.assets.js +175 -25
  7. package/dist/cjs/session/SessionClient.js +57 -8
  8. package/dist/cjs/utils/redactUrl.js +29 -0
  9. package/dist/esm/.tsbuildinfo +1 -1
  10. package/dist/esm/HttpService.js +4 -1
  11. package/dist/esm/OxyServices.errors.js +40 -0
  12. package/dist/esm/OxyServices.js +2 -2
  13. package/dist/esm/index.js +1 -1
  14. package/dist/esm/mixins/OxyServices.assets.js +175 -25
  15. package/dist/esm/session/SessionClient.js +57 -8
  16. package/dist/esm/utils/redactUrl.js +26 -0
  17. package/dist/types/.tsbuildinfo +1 -1
  18. package/dist/types/OxyServices.d.ts +2 -2
  19. package/dist/types/OxyServices.errors.d.ts +40 -0
  20. package/dist/types/index.d.ts +2 -2
  21. package/dist/types/mixins/OxyServices.assets.d.ts +103 -13
  22. package/dist/types/models/interfaces.d.ts +18 -0
  23. package/dist/types/session/SessionClient.d.ts +19 -2
  24. package/dist/types/utils/redactUrl.d.ts +17 -0
  25. package/package.json +1 -1
  26. package/src/HttpService.ts +4 -1
  27. package/src/OxyServices.errors.ts +51 -0
  28. package/src/OxyServices.ts +2 -2
  29. package/src/index.ts +3 -1
  30. package/src/mixins/OxyServices.assets.ts +192 -28
  31. package/src/mixins/__tests__/getFileDownloadUrl.test.ts +265 -1
  32. package/src/models/interfaces.ts +20 -0
  33. package/src/session/SessionClient.ts +59 -8
  34. package/src/session/__tests__/SessionClient.switchTokenOrder.test.ts +170 -0
  35. package/src/utils/__tests__/redactUrl.test.ts +33 -0
  36. package/src/utils/redactUrl.ts +28 -0
@@ -57,7 +57,7 @@
57
57
  * See method JSDoc for more details and options.
58
58
  */
59
59
  import { type LinkedHttpClient, type OxyConfig } from './OxyServices.base';
60
- import { OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors';
60
+ import { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors';
61
61
  import { composeOxyServices } from './mixins';
62
62
  /**
63
63
  * OxyServices - Unified client library for interacting with the Oxy API
@@ -121,7 +121,7 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
121
121
  requireScope(scope: string): (req: unknown, res: unknown, next: (err?: unknown) => void) => void;
122
122
  assetUpdateVisibility(fileId: string, visibility: 'private' | 'public' | 'unlisted'): Promise<unknown>;
123
123
  }
124
- export { OxyAuthenticationError, OxyAuthenticationTimeoutError };
124
+ export { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError };
125
125
  /**
126
126
  * Default Oxy Cloud URL — used when no `cloudURL` is provided to OxyServices.
127
127
  */
@@ -6,6 +6,46 @@ export declare class OxyAuthenticationError extends Error {
6
6
  readonly status: number;
7
7
  constructor(message: string, code?: string, status?: number);
8
8
  }
9
+ /**
10
+ * Thrown when an asset's authorized download URL cannot be resolved.
11
+ *
12
+ * `getFileDownloadUrlAsync` asks the API for a URL that is valid for the
13
+ * CALLER and the asset's actual visibility. When that resolution fails there is
14
+ * no honest fallback: the public CDN origin only serves `public` assets, so
15
+ * handing back `https://cloud.oxy.so/<id>` for an unresolved asset produces a
16
+ * hard 404 at render time and hides the real failure from the caller. This
17
+ * error surfaces the failure instead.
18
+ *
19
+ * The message and fields deliberately carry only the asset id, the requested
20
+ * variant and the HTTP status — never the resolved URL, which embeds a scoped
21
+ * media token.
22
+ *
23
+ * ## `status` lets a caller decide whether a CDN fallback is safe
24
+ *
25
+ * Core itself never falls back to the public CDN builder, because it has no
26
+ * knowledge of the asset's visibility and that URL is a guaranteed 404 for a
27
+ * private asset. A CALLER that knows an asset is public MAY choose to fall back
28
+ * to `getFileDownloadUrl(id, variant)` — but only for a TRANSIENT failure, not
29
+ * a definitive denial:
30
+ * - `status` 401/403/404 → definitive: the asset is private/denied/missing.
31
+ * Never CDN-fall-back — it will 404.
32
+ * - `status` undefined (network error) or 5xx → transient: resolution itself
33
+ * failed. If the caller independently knows the asset is public, a CDN
34
+ * fallback is defensible best-effort.
35
+ */
36
+ export declare class AssetUrlResolutionError extends Error {
37
+ readonly code = "ASSET_URL_UNRESOLVED";
38
+ readonly fileId: string;
39
+ readonly variant?: string;
40
+ readonly status?: number;
41
+ /**
42
+ * The underlying transport/API failure, when there was one. Declared on the
43
+ * class rather than relying on `Error.cause` because this package targets
44
+ * ES2020, where `cause` is not part of the `Error` type.
45
+ */
46
+ readonly cause?: unknown;
47
+ constructor(fileId: string, variant: string | undefined, status: number | undefined, cause?: unknown);
48
+ }
9
49
  export declare class OxyAuthenticationTimeoutError extends OxyAuthenticationError {
10
50
  constructor(operationName: string, timeoutMs: number);
11
51
  }
@@ -17,7 +17,7 @@
17
17
  * If a symbol does not appear here, it is NOT part of the public API.
18
18
  */
19
19
  import './crypto/polyfill';
20
- export { OxyServices, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices';
20
+ export { OxyServices, AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices';
21
21
  export { OXY_CLOUD_URL, oxyClient } from './OxyServices';
22
22
  export type { LinkedHttpClient } from './OxyServices.base';
23
23
  export type { AuthRefreshReason, AuthRefreshHandler } from './HttpService';
@@ -58,7 +58,7 @@ export type { AeadResult } from './crypto/aead';
58
58
  export { deriveSharedSecret } from './crypto/ecdh';
59
59
  export { DeviceManager } from './utils/deviceManager';
60
60
  export type { DeviceFingerprint, StoredDeviceInfo } from './utils/deviceManager';
61
- export type { OxyConfig, PrivacySettings, NotificationPreferences, UserPreferences, User, LoginResponse, Notification, Wallet, Transaction, BlockedUser, RestrictedUser, TransferFundsRequest, PurchaseRequest, WithdrawalRequest, TransactionResponse, PaginationInfo, SearchProfilesResponse, ApiError, PaymentMethod, PaymentRequest, PaymentResponse, AnalyticsData, FollowerDetails, ContentViewer, FileMetadata, FileUploadResponse, FileListResponse, FileUpdateRequest, FileDeleteResponse, RNFileDescriptor, AssetUploadInput, FileVisibility, AssetLink, AssetMetadata, AssetVariant, Asset, AssetInitRequest, AssetInitResponse, AssetCompleteRequest, AssetLinkRequest, AssetUnlinkRequest, AssetUrlResponse, AssetDeleteSummary, AssetUpdateVisibilityRequest, AssetUpdateVisibilityResponse, ServiceAssetMetadata, ServiceAssetMetadataBySha, AccountStorageCategoryUsage, AccountStorageUsageResponse, SecurityEventType, SecurityEventSeverity, SecurityActivity, SecurityActivityResponse, AssetUploadProgress, DeviceLinkedSession, DeviceLinkedSessionsResponse, DeviceLinkedSessionLogoutResponse, UpdateDeviceNameResponse, } from './models/interfaces';
61
+ export type { OxyConfig, PrivacySettings, NotificationPreferences, UserPreferences, User, LoginResponse, Notification, Wallet, Transaction, BlockedUser, RestrictedUser, TransferFundsRequest, PurchaseRequest, WithdrawalRequest, TransactionResponse, PaginationInfo, SearchProfilesResponse, ApiError, PaymentMethod, PaymentRequest, PaymentResponse, AnalyticsData, FollowerDetails, ContentViewer, FileMetadata, FileUploadResponse, FileListResponse, FileUpdateRequest, FileDeleteResponse, RNFileDescriptor, AssetUploadInput, FileVisibility, AssetLink, AssetMetadata, AssetVariant, Asset, AssetInitRequest, AssetInitResponse, AssetCompleteRequest, AssetLinkRequest, AssetUnlinkRequest, AssetUrlResponse, BatchFileAccessEntry, BatchFileAccessResponse, AssetDeleteSummary, AssetUpdateVisibilityRequest, AssetUpdateVisibilityResponse, ServiceAssetMetadata, ServiceAssetMetadataBySha, AccountStorageCategoryUsage, AccountStorageUsageResponse, SecurityEventType, SecurityEventSeverity, SecurityActivity, SecurityActivityResponse, AssetUploadProgress, DeviceLinkedSession, DeviceLinkedSessionsResponse, DeviceLinkedSessionLogoutResponse, UpdateDeviceNameResponse, } from './models/interfaces';
62
62
  export { SECURITY_EVENT_SEVERITY_MAP } from './models/interfaces';
63
63
  export { TopicType, TopicSource } from './models/Topic';
64
64
  export type { TopicData, TopicTranslation, TopicListResult } from './models/Topic';
@@ -1,4 +1,4 @@
1
- import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, ServiceAssetMetadata, ServiceAssetMetadataBySha } from '../models/interfaces';
1
+ import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, BatchFileAccessResponse, ServiceAssetMetadata, ServiceAssetMetadataBySha } from '../models/interfaces';
2
2
  import type { OxyServicesBase } from '../OxyServices.base';
3
3
  export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T): {
4
4
  new (...args: any[]): {
@@ -17,18 +17,57 @@ export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>
17
17
  */
18
18
  deleteFile(fileId: string): Promise<any>;
19
19
  /**
20
- * Build a synchronous, `<img src>`-ready file URL from an Oxy asset id.
20
+ * Build a synchronous, `<img src>`-ready URL for a **PUBLIC** Oxy asset.
21
21
  *
22
- * This method must never embed the caller's general access token in the
23
- * returned URL. The URL is commonly rendered into DOM attributes, browser
24
- * network panels, caches, and logs. Public asset URLs use the clean CDN
25
- * origin; callers that need authorized/private access should use
26
- * {@link getFileDownloadUrlAsync}, which asks the API for a scoped download
27
- * URL instead of exposing the in-memory bearer token in a query string.
22
+ * ## Contract read before calling
23
+ *
24
+ * This is a pure string builder. It performs no network call and therefore
25
+ * has **no knowledge of the asset's visibility**. It always produces the
26
+ * public form: `${cloudURL}/<id>[?variant=…]`, which the CDN serves from
27
+ * the public media origin only.
28
+ *
29
+ * Consequently:
30
+ * - Call it ONLY when the asset is known to be `public` — e.g. avatars and
31
+ * profile banners, which {@link uploadAvatar} / {@link uploadProfileBanner}
32
+ * upload with `visibility: 'public'`.
33
+ * - For an asset that may be `private` or `unlisted` — anything uploaded
34
+ * through the generic {@link assetUpload} path, whose server-side default
35
+ * is private — this URL resolves to a hard **404**. Use
36
+ * {@link getFileDownloadUrlAsync}, which asks the API for a URL scoped to
37
+ * the current caller.
38
+ * - It must never guess visibility, and must never embed the caller's
39
+ * bearer token: the returned string is rendered into DOM attributes,
40
+ * browser network panels, HTTP caches, and logs.
41
+ *
42
+ * Passing `expiresIn` switches to the API-origin stream form
43
+ * (`${baseURL}/assets/<id>/stream?…`) WITHOUT any credential. That form
44
+ * still only serves what an unauthenticated request may see — it is not a
45
+ * synchronous private-asset path, and none exists: authorization for a
46
+ * private asset requires the round-trip in {@link getFileDownloadUrlAsync}.
28
47
  */
29
48
  getFileDownloadUrl(fileId: string, variant?: string, expiresIn?: number): string;
30
49
  /**
31
- * Get file download URL asynchronously (returns signed URL directly from CDN)
50
+ * Resolve an asset id to a URL that is valid for the CURRENT caller,
51
+ * whatever the asset's visibility.
52
+ *
53
+ * Asks the API (`GET /assets/:id/url`) rather than guessing: a `public`
54
+ * asset resolves to the CDN form, while a `private`/`unlisted` asset the
55
+ * caller may read resolves to an API-origin stream URL carrying a scoped,
56
+ * short-lived media token. The returned URL is passed through **unchanged**
57
+ * — the SDK never rewrites, re-signs, or strips it.
58
+ *
59
+ * ## Failure behaviour — no CDN fallback
60
+ *
61
+ * Throws {@link AssetUrlResolutionError} when the API returns no URL or the
62
+ * request fails (including 401/403/404). It deliberately does NOT fall back
63
+ * to {@link getFileDownloadUrl}: that builder only produces the public CDN
64
+ * form, so falling back would hand the caller a URL that renders as a hard
65
+ * 404 for every private asset and would silently swallow the real failure.
66
+ * A caller that knows an asset is public should call the synchronous
67
+ * builder directly instead of relying on a fallback here.
68
+ *
69
+ * The resolved URL is cached per identity for well under the media token's
70
+ * lifetime — see {@link getAssetUrlCacheTTL}.
32
71
  */
33
72
  getFileDownloadUrlAsync(fileId: string, variant?: string, expiresIn?: number): Promise<string>;
34
73
  /**
@@ -52,13 +91,53 @@ export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>
52
91
  */
53
92
  getFileContentAsBlob(fileId: string, variant?: string): Promise<Blob>;
54
93
  /**
55
- * Get batch access to multiple files
94
+ * Resolve access + a caller-scoped URL for many assets — each with its OWN
95
+ * requested variant — in ONE round trip via `POST /assets/batch-access`.
96
+ *
97
+ * `requests` is a per-file `{ fileId, variant? }` list (a `variant` of
98
+ * `undefined` asks for the original). `options.expiresIn` sets the requested
99
+ * media-token / signed-URL lifetime (seconds); `options.context` is the
100
+ * server-side access-check context. Entries with a blank `fileId` are
101
+ * dropped and exact `(fileId, variant)` duplicates are collapsed before the
102
+ * request; an empty effective list performs no network call.
103
+ *
104
+ * The server caps the batch at 100 entries — callers that page beyond that
105
+ * must chunk. Returns the raw per-file envelope (see
106
+ * {@link BatchFileAccessResponse}); most callers want {@link getFileDownloadUrls},
107
+ * which flattens it to just the usable URLs.
56
108
  */
57
- getBatchFileAccess(fileIds: string[], context?: string): Promise<Record<string, any>>;
109
+ getBatchFileAccess(requests: Array<{
110
+ fileId: string;
111
+ variant?: string;
112
+ }>, options?: {
113
+ expiresIn?: number;
114
+ context?: string;
115
+ }): Promise<BatchFileAccessResponse>;
58
116
  /**
59
- * Get download URLs for multiple files efficiently
117
+ * Resolve many assets each with its OWN variant — to caller-scoped,
118
+ * `<img src>`-ready URLs in one round trip. The batch counterpart of
119
+ * {@link getFileDownloadUrlAsync}, built to resolve a whole grid page at once.
120
+ *
121
+ * `requests` is a per-file `{ fileId, variant? }` list (e.g. `poster` for a
122
+ * video, `thumb` for an image); the per-file variant RULE lives in the
123
+ * caller — core just forwards what it is given. `options.expiresIn` /
124
+ * `options.context` are passed through to the endpoint.
125
+ *
126
+ * Each returned URL is the API's own scoped form, passed through unchanged:
127
+ * the public CDN URL for a public asset, or an API-origin
128
+ * `/assets/:id/stream?…&mt=<media token>` URL for a private asset the caller
129
+ * may read. Ids the caller cannot access (or that do not exist) are simply
130
+ * OMITTED from the returned map — there is NO public-CDN fallback, so a grid
131
+ * never renders a known-404 URL. Callers detect a miss by the absent key
132
+ * (the map never contains an empty-string value). Keyed by `fileId`.
60
133
  */
61
- getFileDownloadUrls(fileIds: string[], context?: string): Promise<Record<string, string>>;
134
+ getFileDownloadUrls(requests: Array<{
135
+ fileId: string;
136
+ variant?: string;
137
+ }>, options?: {
138
+ expiresIn?: number;
139
+ context?: string;
140
+ }): Promise<Record<string, string>>;
62
141
  /**
63
142
  * Resolve many Oxy asset ids to their content-addressed metadata in one
64
143
  * round-trip per chunk via `POST /assets/service/by-ids` (body `{ ids }`).
@@ -170,6 +249,17 @@ export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>
170
249
  assetUpdateVisibility(fileId: string, visibility: "private" | "public" | "unlisted"): Promise<any>;
171
250
  uploadAvatar(file: AssetUploadInput, userId: string, app?: string): Promise<any>;
172
251
  uploadProfileBanner(file: AssetUploadInput, userId: string, app?: string): Promise<any>;
252
+ /**
253
+ * How long a resolved asset URL may stay in the SDK's GET cache, in ms.
254
+ *
255
+ * A resolved private-asset URL dies the instant its scoped media token
256
+ * expires (~{@link ASSET_MEDIA_TOKEN_TTL_MS}). Caching it for its full
257
+ * nominal lifetime would leave a window where the cache serves an
258
+ * already-dead URL (clock skew, render-pipeline latency, an image request
259
+ * queued behind others). So the TTL is (a) never longer than the token's
260
+ * lifetime and (b) discounted to {@link ASSET_URL_CACHE_LIFETIME_FRACTION}
261
+ * of that bound — comfortably below the token TTL by construction.
262
+ */
173
263
  getAssetUrlCacheTTL(expiresIn?: number): number;
174
264
  fetchAssetDownloadUrl(fileId: string, variant?: string, cacheTTL?: number, expiresIn?: number): Promise<string | null>;
175
265
  fetchAssetContent(url: string, type: "text"): Promise<string>;
@@ -448,6 +448,24 @@ export interface AssetUrlResponse {
448
448
  variant?: string;
449
449
  expiresIn: number;
450
450
  }
451
+ /**
452
+ * Per-file result of `POST /assets/batch-access`. `allowed` is authoritative:
453
+ * when `false` the entry carries an `error` string (e.g. `'Access denied'`,
454
+ * `'File not found'`) and no `url`. When `true`, `url` is a caller-scoped,
455
+ * `<img src>`-ready URL — the public CDN form for a public asset or an
456
+ * API-origin stream URL carrying a short-lived media token for a private one.
457
+ */
458
+ export interface BatchFileAccessEntry {
459
+ allowed: boolean;
460
+ url?: string;
461
+ visibility?: FileVisibility;
462
+ mime?: string;
463
+ error?: string;
464
+ }
465
+ /** Envelope returned by `POST /assets/batch-access`, keyed by file id. */
466
+ export interface BatchFileAccessResponse {
467
+ results: Record<string, BatchFileAccessEntry>;
468
+ }
451
469
  export interface AssetDeleteSummary {
452
470
  fileId: string;
453
471
  wouldDelete: boolean;
@@ -93,8 +93,25 @@ export declare class SessionClient {
93
93
  onServerEvent(event: string, listener: (payload: unknown) => void): () => void;
94
94
  private bindServerEvent;
95
95
  protected notify(): void;
96
- /** Validate + last-writer-wins by revision. Returns true if applied. */
97
- protected applyState(raw: unknown, origin?: SessionStateOrigin): boolean;
96
+ /**
97
+ * Validate + last-writer-wins by revision. Returns true if applied.
98
+ *
99
+ * `activeToken` (sync path only) is the server-issued access token for
100
+ * `raw.activeAccountId`. When present and the state is applied, it is planted
101
+ * BEFORE any subscriber is notified so the bearer already belongs to the new
102
+ * active account — the local switch/bootstrap path then needs no redundant
103
+ * device-secret mint. Push-origin applies carry no token and rely on the
104
+ * mint-before-notify gate below.
105
+ *
106
+ * ORDERING INVARIANT: a subscriber must NEVER observe a newly-active account
107
+ * while the planted bearer still identifies the PREVIOUS one — otherwise a
108
+ * `useCurrentUser`-style refetch fires under the wrong account's token (the
109
+ * account-switch 404 race). So when a transport is available and the planted
110
+ * bearer does not already belong to `next.activeAccountId`, minting is awaited
111
+ * BEFORE `notify()`. This covers EVERY notify source (a switch push, a
112
+ * cross-device push, a cold mint), not just the initial "no bearer yet" case.
113
+ */
114
+ protected applyState(raw: unknown, origin?: SessionStateOrigin, activeToken?: string): boolean;
98
115
  /**
99
116
  * Validate `{ state, activeToken }`, apply the state, and plant the active token host-side.
100
117
  * Token-planting is decoupled from whether `applyState` advanced the revision: a socket push
@@ -0,0 +1,17 @@
1
+ /**
2
+ * URL redaction for logging.
3
+ *
4
+ * Asset URLs the API hands back for private assets carry a scoped, short-lived
5
+ * media token (`mt=…`) in their query string. That token is a bearer credential
6
+ * for the underlying object, so it must never land in a log line, breadcrumb,
7
+ * or metric — a captured log would otherwise grant read access until the token
8
+ * expires. Query strings on API URLs can also carry other sensitive params, so
9
+ * we redact the whole query rather than allow-listing one key.
10
+ *
11
+ * `redactUrlQuery` returns the URL's path portion with a `?<redacted>` marker
12
+ * when a query string is present, and the input unchanged otherwise. It is
13
+ * defensive: any input that does not parse as a URL is passed through as-is,
14
+ * except that a bare `?query` tail is still stripped so a relative path with a
15
+ * query never leaks.
16
+ */
17
+ export declare function redactUrlQuery(url: string): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "12.5.4",
3
+ "version": "12.6.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -21,6 +21,7 @@ import { jwtDecode } from 'jwt-decode';
21
21
  import { isNative, getPlatformOS } from './utils/platform';
22
22
  import { isReactNative } from '@oxyhq/protocol';
23
23
  import { computeIdentityTag, fnv1a32 } from './utils/cacheKey';
24
+ import { redactUrlQuery } from './utils/redactUrl';
24
25
  import type { OxyConfig } from './models/interfaces';
25
26
  import type { DeviceSecretMintOutcome } from './session/refresh';
26
27
 
@@ -405,7 +406,9 @@ export class HttpService {
405
406
  const cached = this.cache.get(cacheKey) as T | null;
406
407
  if (cached !== null) {
407
408
  this.requestMetrics.cacheHits++;
408
- this.logger.debug('Cache hit:', url);
409
+ // Redact the query string: an asset stream URL passed here carries a
410
+ // scoped `mt=` media token that must never reach a log sink.
411
+ this.logger.debug('Cache hit:', redactUrlQuery(url));
409
412
  return cached;
410
413
  }
411
414
  this.requestMetrics.cacheMisses++;
@@ -13,6 +13,57 @@ export class OxyAuthenticationError extends Error {
13
13
  }
14
14
  }
15
15
 
16
+ /**
17
+ * Thrown when an asset's authorized download URL cannot be resolved.
18
+ *
19
+ * `getFileDownloadUrlAsync` asks the API for a URL that is valid for the
20
+ * CALLER and the asset's actual visibility. When that resolution fails there is
21
+ * no honest fallback: the public CDN origin only serves `public` assets, so
22
+ * handing back `https://cloud.oxy.so/<id>` for an unresolved asset produces a
23
+ * hard 404 at render time and hides the real failure from the caller. This
24
+ * error surfaces the failure instead.
25
+ *
26
+ * The message and fields deliberately carry only the asset id, the requested
27
+ * variant and the HTTP status — never the resolved URL, which embeds a scoped
28
+ * media token.
29
+ *
30
+ * ## `status` lets a caller decide whether a CDN fallback is safe
31
+ *
32
+ * Core itself never falls back to the public CDN builder, because it has no
33
+ * knowledge of the asset's visibility and that URL is a guaranteed 404 for a
34
+ * private asset. A CALLER that knows an asset is public MAY choose to fall back
35
+ * to `getFileDownloadUrl(id, variant)` — but only for a TRANSIENT failure, not
36
+ * a definitive denial:
37
+ * - `status` 401/403/404 → definitive: the asset is private/denied/missing.
38
+ * Never CDN-fall-back — it will 404.
39
+ * - `status` undefined (network error) or 5xx → transient: resolution itself
40
+ * failed. If the caller independently knows the asset is public, a CDN
41
+ * fallback is defensible best-effort.
42
+ */
43
+ export class AssetUrlResolutionError extends Error {
44
+ public readonly code = 'ASSET_URL_UNRESOLVED';
45
+ public readonly fileId: string;
46
+ public readonly variant?: string;
47
+ public readonly status?: number;
48
+ /**
49
+ * The underlying transport/API failure, when there was one. Declared on the
50
+ * class rather than relying on `Error.cause` because this package targets
51
+ * ES2020, where `cause` is not part of the `Error` type.
52
+ */
53
+ public readonly cause?: unknown;
54
+
55
+ constructor(fileId: string, variant: string | undefined, status: number | undefined, cause?: unknown) {
56
+ const variantSuffix = variant ? ` (variant "${variant}")` : '';
57
+ const statusSuffix = typeof status === 'number' ? ` — status ${status}` : '';
58
+ super(`Could not resolve a download URL for asset "${fileId}"${variantSuffix}${statusSuffix}`);
59
+ this.name = 'AssetUrlResolutionError';
60
+ this.fileId = fileId;
61
+ this.variant = variant;
62
+ this.status = status;
63
+ this.cause = cause;
64
+ }
65
+ }
66
+
16
67
  export class OxyAuthenticationTimeoutError extends OxyAuthenticationError {
17
68
  constructor(operationName: string, timeoutMs: number) {
18
69
  super(
@@ -57,7 +57,7 @@
57
57
  * See method JSDoc for more details and options.
58
58
  */
59
59
  import { OxyServicesBase, type LinkedHttpClient, type OxyConfig } from './OxyServices.base';
60
- import { OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors';
60
+ import { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors';
61
61
 
62
62
  // Import mixin composition helper
63
63
  import { composeOxyServices } from './mixins';
@@ -151,7 +151,7 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
151
151
  }
152
152
 
153
153
  // Re-export error classes for convenience
154
- export { OxyAuthenticationError, OxyAuthenticationTimeoutError };
154
+ export { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError };
155
155
 
156
156
  /**
157
157
  * Default Oxy Cloud URL — used when no `cloudURL` is provided to OxyServices.
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
@@ -311,6 +311,8 @@ export type {
311
311
  AssetLinkRequest,
312
312
  AssetUnlinkRequest,
313
313
  AssetUrlResponse,
314
+ BatchFileAccessEntry,
315
+ BatchFileAccessResponse,
314
316
  AssetDeleteSummary,
315
317
  AssetUpdateVisibilityRequest,
316
318
  AssetUpdateVisibilityResponse,