@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
@@ -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
@@ -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 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>;
@@ -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
- /** 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.7.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.