@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
|
@@ -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
|
}
|
|
@@ -321,6 +321,26 @@ export declare class KeyManager {
|
|
|
321
321
|
* "not-hex" pass through as a valid (but compromised, near-zero) key.
|
|
322
322
|
*/
|
|
323
323
|
static isValidPrivateKey(privateKey: string): boolean;
|
|
324
|
+
/**
|
|
325
|
+
* Derive a 32-byte, domain-separated seed from the on-device Oxy identity
|
|
326
|
+
* private key via HKDF-SHA256, WITHOUT ever exposing the raw private key.
|
|
327
|
+
*
|
|
328
|
+
* The domain separation is carried by `info` (e.g. `"oxypay/faircoin/v1"`),
|
|
329
|
+
* so distinct apps/purposes get independent seeds from the same identity.
|
|
330
|
+
* The output is HKDF keying material, never the private key itself — a
|
|
331
|
+
* consumer (e.g. Oxy Pay's FairCoin HD wallet) can feed it straight into
|
|
332
|
+
* `HDKey.fromMasterSeed` and never touches the identity key.
|
|
333
|
+
*
|
|
334
|
+
* Key source (native only): prefers the shared ecosystem identity written to
|
|
335
|
+
* `group.so.oxy.shared` (what a Relying Party like Oxy Pay reads), then falls
|
|
336
|
+
* back to this device's primary identity (Commons/Accounts). Both reproduce
|
|
337
|
+
* from the user's Oxy recovery phrase, so the derived seed is recoverable.
|
|
338
|
+
*
|
|
339
|
+
* @param info Context/domain-binding label (distinct labels → independent seeds).
|
|
340
|
+
* @returns 32 bytes of derived keying material, or `null` on web / when no
|
|
341
|
+
* identity key is available on this device.
|
|
342
|
+
*/
|
|
343
|
+
static deriveScopedSeed(info: string): Promise<Uint8Array | null>;
|
|
324
344
|
/**
|
|
325
345
|
* Get a shortened version of the public key for display
|
|
326
346
|
* Format: first 8 chars...last 8 chars
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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';
|
|
@@ -26,6 +26,7 @@ export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
|
|
|
26
26
|
export type { CommonsSignInHandle, CommonsSignInStatus, CommonsApprovalInfo, CommonsSignInActionResult, } from './mixins/OxyServices.auth';
|
|
27
27
|
export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
|
|
28
28
|
export type { ContactDiscoveryMatch, ContactDiscoveryResponse, } from './mixins/OxyServices.contacts';
|
|
29
|
+
export type { InitDeviceTransferResult, DeviceTransferOutcome, } from './mixins/OxyServices.deviceTransfer';
|
|
29
30
|
export type { BulkFollowEntry, BulkFollowResult, BulkUnfollowEntry, BulkUnfollowResult, FollowMutationResult, ViewerGraph, } from './mixins/OxyServices.user';
|
|
30
31
|
export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData';
|
|
31
32
|
export { getNormalizedUserId, normalizeUserIdentity, normalizeUserIdentityOrNull, } from './utils/userIdentity';
|
|
@@ -58,7 +59,7 @@ export type { AeadResult } from './crypto/aead';
|
|
|
58
59
|
export { deriveSharedSecret } from './crypto/ecdh';
|
|
59
60
|
export { DeviceManager } from './utils/deviceManager';
|
|
60
61
|
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';
|
|
62
|
+
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
63
|
export { SECURITY_EVENT_SEVERITY_MAP } from './models/interfaces';
|
|
63
64
|
export { TopicType, TopicSource } from './models/Topic';
|
|
64
65
|
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
|
|
20
|
+
* Build a synchronous, `<img src>`-ready URL for a **PUBLIC** Oxy asset.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* {
|
|
27
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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(
|
|
109
|
+
getBatchFileAccess(requests: Array<{
|
|
110
|
+
fileId: string;
|
|
111
|
+
variant?: string;
|
|
112
|
+
}>, options?: {
|
|
113
|
+
expiresIn?: number;
|
|
114
|
+
context?: string;
|
|
115
|
+
}): Promise<BatchFileAccessResponse>;
|
|
58
116
|
/**
|
|
59
|
-
*
|
|
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(
|
|
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>;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-to-device Identity Transfer Mixin (b3 Feature 2 — "add a device")
|
|
3
|
+
*
|
|
4
|
+
* Clones an existing device's secp256k1 identity onto a fresh device over a
|
|
5
|
+
* short-lived, unauthenticated relay, WITHOUT the server ever holding a
|
|
6
|
+
* decryption key. Both devices end up holding the SAME private key.
|
|
7
|
+
*
|
|
8
|
+
* The two devices agree on a symmetric key via an ephemeral secp256k1 ECDH
|
|
9
|
+
* handshake (Phase-0 crypto): `deriveSharedSecret` → `hkdfSha256` → a per-pairing
|
|
10
|
+
* transfer key, used with `encryptAead`/`decryptAead` (XChaCha20-Poly1305) to
|
|
11
|
+
* seal `{ privateKey, publicKey }`. The relay carries only ephemeral public keys
|
|
12
|
+
* plus opaque ciphertext — a passive/at-rest-compromised backend cannot decrypt.
|
|
13
|
+
*
|
|
14
|
+
* Roles:
|
|
15
|
+
* - NEW device (no identity): {@link initDeviceTransfer} (generate ephemeral
|
|
16
|
+
* pair, register the pairing, render `pairingId` as a QR) then
|
|
17
|
+
* {@link subscribeDeviceTransfer} (await approval over the `/device-pair`
|
|
18
|
+
* socket with a poll fallback, decrypt, and import the key).
|
|
19
|
+
* - OLD device (has identity): {@link getDeviceTransferInfo} (resolve the
|
|
20
|
+
* scanned `pairingId` server-side — the QR is NOT self-contained) then
|
|
21
|
+
* {@link approveDeviceTransfer} (biometric-gate in the UI, seal the key
|
|
22
|
+
* material, and post it with a fresh signature over the CURRENT identity key).
|
|
23
|
+
*
|
|
24
|
+
* SECURITY: E2E against a passive relay only. Explicitly NOT hardened against an
|
|
25
|
+
* actively-malicious backend MITM'ing the ephemeral keys (same trust boundary as
|
|
26
|
+
* the existing QR sign-in; SAS compare deferred per owner decision). Approve
|
|
27
|
+
* requires BOTH a bearer token AND a fresh identity-key signature.
|
|
28
|
+
*/
|
|
29
|
+
import type { OxyServicesBase } from '../OxyServices.base';
|
|
30
|
+
import type { DeviceTransferInfoResponse, DeviceTransferApproveResponse, DeviceTransferDenyResponse } from '@oxyhq/contracts';
|
|
31
|
+
/** Result of {@link OxyServicesDeviceTransferMixin.initDeviceTransfer}. */
|
|
32
|
+
export interface InitDeviceTransferResult {
|
|
33
|
+
/** 128-bit single-use handle to render in the QR (also the HKDF salt). */
|
|
34
|
+
pairingId: string;
|
|
35
|
+
/** ISO-8601 expiry (3 minutes). */
|
|
36
|
+
expiresAt: string;
|
|
37
|
+
/** The new device's ephemeral public key registered with the relay. */
|
|
38
|
+
newEphemeralPublicKey: string;
|
|
39
|
+
}
|
|
40
|
+
/** Terminal outcome delivered to {@link subscribeDeviceTransfer}'s callback. */
|
|
41
|
+
export type DeviceTransferOutcome = {
|
|
42
|
+
status: 'approved';
|
|
43
|
+
publicKey: string;
|
|
44
|
+
} | {
|
|
45
|
+
status: 'denied';
|
|
46
|
+
} | {
|
|
47
|
+
status: 'expired';
|
|
48
|
+
};
|
|
49
|
+
export declare function OxyServicesDeviceTransferMixin<T extends typeof OxyServicesBase>(Base: T): {
|
|
50
|
+
new (...args: any[]): {
|
|
51
|
+
/**
|
|
52
|
+
* NEW device — begin an "add a device" transfer. Generates a single-use
|
|
53
|
+
* ephemeral secp256k1 pair, registers the pairing, and returns the
|
|
54
|
+
* `pairingId` to render as a QR. The ephemeral private key is held in memory
|
|
55
|
+
* (keyed by `pairingId`) for the subsequent {@link subscribeDeviceTransfer}.
|
|
56
|
+
*
|
|
57
|
+
* @param label - Optional human-readable label for this new device.
|
|
58
|
+
*/
|
|
59
|
+
initDeviceTransfer(label?: string): Promise<InitDeviceTransferResult>;
|
|
60
|
+
/**
|
|
61
|
+
* Resolve a pairing server-side (the QR carries only `pairingId`). The OLD
|
|
62
|
+
* device calls this after scanning to read the new device's ephemeral public
|
|
63
|
+
* key + label; the NEW device polls it to fetch the sealed material once
|
|
64
|
+
* approved. Public — no auth required.
|
|
65
|
+
*/
|
|
66
|
+
getDeviceTransferInfo(pairingId: string): Promise<DeviceTransferInfoResponse>;
|
|
67
|
+
/**
|
|
68
|
+
* OLD device — approve a scanned transfer. Reads the new device's ephemeral
|
|
69
|
+
* public key, derives the shared transfer key, AEAD-seals
|
|
70
|
+
* `{ privateKey, publicKey }`, and posts it PLUS a fresh signature over
|
|
71
|
+
* `{ action:'approve_device_transfer', pairingId, timestamp }` made with the
|
|
72
|
+
* CURRENT identity key (dual-proof alongside the bearer token).
|
|
73
|
+
*
|
|
74
|
+
* NATIVE-ONLY: requires a stored identity (throws otherwise). The UI must
|
|
75
|
+
* biometric-gate before calling this — a key clone leaves the device.
|
|
76
|
+
*/
|
|
77
|
+
approveDeviceTransfer(pairingId: string): Promise<DeviceTransferApproveResponse>;
|
|
78
|
+
/**
|
|
79
|
+
* OLD device — deny (cancel) a scanned transfer so the waiting new device
|
|
80
|
+
* stops. Public — no auth required.
|
|
81
|
+
*/
|
|
82
|
+
denyDeviceTransfer(pairingId: string): Promise<DeviceTransferDenyResponse>;
|
|
83
|
+
/**
|
|
84
|
+
* NEW device — await approval for a pairing started with
|
|
85
|
+
* {@link initDeviceTransfer}, then decrypt and import the transferred
|
|
86
|
+
* identity key. Primary path is an instant `device_pair_update` push over the
|
|
87
|
+
* `/device-pair` socket; a poll backstops a socket that can't connect.
|
|
88
|
+
*
|
|
89
|
+
* On `approved`: re-derives the shared transfer key from the old device's
|
|
90
|
+
* ephemeral public key, decrypts `{ privateKey, publicKey }`, imports it via
|
|
91
|
+
* `KeyManager.importKeyPair(privateKey, { overwrite: false })`, and invokes
|
|
92
|
+
* `onOutcome({ status:'approved', publicKey })`. The caller then runs the
|
|
93
|
+
* NORMAL challenge/verify sign-in — this method does not mint a session.
|
|
94
|
+
*
|
|
95
|
+
* @returns An unsubscribe function; call it to stop waiting (also called
|
|
96
|
+
* automatically once the transfer settles).
|
|
97
|
+
*/
|
|
98
|
+
subscribeDeviceTransfer(pairingId: string, onOutcome: (outcome: DeviceTransferOutcome) => void): () => void;
|
|
99
|
+
httpService: import("../HttpService").HttpService;
|
|
100
|
+
cloudURL: string;
|
|
101
|
+
config: import("../OxyServices.base").OxyConfig;
|
|
102
|
+
__resetTokensForTests(): void;
|
|
103
|
+
makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
|
|
104
|
+
getBaseURL(): string;
|
|
105
|
+
getClient(): import("../HttpService").HttpService;
|
|
106
|
+
createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
|
|
107
|
+
getMetrics(): {
|
|
108
|
+
totalRequests: number;
|
|
109
|
+
successfulRequests: number;
|
|
110
|
+
failedRequests: number;
|
|
111
|
+
cacheHits: number;
|
|
112
|
+
cacheMisses: number;
|
|
113
|
+
averageResponseTime: number;
|
|
114
|
+
};
|
|
115
|
+
clearCache(): void;
|
|
116
|
+
clearCacheEntry(key: string): void;
|
|
117
|
+
clearCacheByPrefix(prefix: string): number;
|
|
118
|
+
getCacheStats(): {
|
|
119
|
+
size: number;
|
|
120
|
+
hits: number;
|
|
121
|
+
misses: number;
|
|
122
|
+
hitRate: number;
|
|
123
|
+
};
|
|
124
|
+
getCloudURL(): string;
|
|
125
|
+
setTokens(accessToken: string): void;
|
|
126
|
+
clearTokens(): void;
|
|
127
|
+
onTokensChanged(listener: (accessToken: string | null) => void): () => void;
|
|
128
|
+
_cachedUserId: string | null | undefined;
|
|
129
|
+
_cachedAccessToken: string | null;
|
|
130
|
+
getCurrentUserId(): string | null;
|
|
131
|
+
hasValidToken(): boolean;
|
|
132
|
+
getAccessToken(): string | null;
|
|
133
|
+
getAccessTokenExpiry(): number | null;
|
|
134
|
+
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
135
|
+
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
136
|
+
maxRetries?: number;
|
|
137
|
+
retryDelay?: number;
|
|
138
|
+
authTimeoutMs?: number;
|
|
139
|
+
}): Promise<T_1>;
|
|
140
|
+
validate(): Promise<boolean>;
|
|
141
|
+
handleError(error: unknown): Error;
|
|
142
|
+
healthCheck(): Promise<{
|
|
143
|
+
status: string;
|
|
144
|
+
users?: number;
|
|
145
|
+
timestamp?: string;
|
|
146
|
+
[key: string]: any;
|
|
147
|
+
}>;
|
|
148
|
+
};
|
|
149
|
+
} & T;
|
|
@@ -29,6 +29,7 @@ import { OxyServicesCivicMixin } from './OxyServices.civic';
|
|
|
29
29
|
import { OxyServicesNodesMixin } from './OxyServices.nodes';
|
|
30
30
|
import { OxyServicesLinksMixin } from './OxyServices.links';
|
|
31
31
|
import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
|
|
32
|
+
import { OxyServicesDeviceTransferMixin } from './OxyServices.deviceTransfer';
|
|
32
33
|
/**
|
|
33
34
|
* Instance shape of every mixin in the pipeline, intersected. The runtime
|
|
34
35
|
* `composeOxyServices()` produces a class whose instances expose all of
|
|
@@ -38,7 +39,7 @@ import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
|
|
|
38
39
|
* If you add a new mixin to `MIXIN_PIPELINE`, add it here too so its methods
|
|
39
40
|
* are visible without a cast.
|
|
40
41
|
*/
|
|
41
|
-
type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityBackupMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
42
|
+
type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityBackupMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceTransferMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
42
43
|
/**
|
|
43
44
|
* Constructor type for the fully composed mixin pipeline. Each mixin returns
|
|
44
45
|
* a new constructor that augments its input; reducing across the pipeline
|
|
@@ -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
|
-
/**
|
|
97
|
-
|
|
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
package/src/HttpService.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
package/src/OxyServices.ts
CHANGED
|
@@ -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.
|