@oxyhq/core 15.0.1 → 16.0.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.
@@ -49,6 +49,37 @@ export class AssetUrlResolutionError extends Error {
49
49
  this.cause = cause;
50
50
  }
51
51
  }
52
+ /**
53
+ * Thrown when one or more chunks of `getServiceAssetMetadataByIds` could not be
54
+ * resolved.
55
+ *
56
+ * Exists because for this endpoint a FAILED request and an ABSENT asset produce
57
+ * the same observable result. The server legitimately omits unknown/deleted ids,
58
+ * so callers are documented to map the response by `id` and treat a missing
59
+ * entry as "no such asset" — which means a chunk that 429s, times out or 5xxs
60
+ * reads as authoritative absence unless it is raised.
61
+ *
62
+ * That was not hypothetical: a metadata backfill counted every throttled asset
63
+ * as needing no update and exited 0, and the MTN signed-record builder embedded
64
+ * media with no content hash into records that are immutable once signed. Both
65
+ * paths reported success and wrote nothing.
66
+ *
67
+ * `unresolvedIds` carries every id in a failed chunk — not the subset the server
68
+ * would have omitted anyway, which is unknowable when the request never landed.
69
+ * A caller that wants best-effort passes `{ partial: true }` and never sees this.
70
+ */
71
+ export class ServiceAssetMetadataError extends Error {
72
+ constructor(unresolvedIds, statuses, cause) {
73
+ const uniqueStatuses = Array.from(new Set(statuses)).sort((a, b) => a - b);
74
+ const statusSuffix = uniqueStatuses.length > 0 ? ` — status ${uniqueStatuses.join(', ')}` : '';
75
+ super(`Could not resolve asset metadata for ${unresolvedIds.length} id(s)${statusSuffix}. Treat this as unknown, not as absent; pass { partial: true } for best-effort.`);
76
+ this.code = 'SERVICE_ASSET_METADATA_UNRESOLVED';
77
+ this.name = 'ServiceAssetMetadataError';
78
+ this.unresolvedIds = unresolvedIds;
79
+ this.statuses = uniqueStatuses;
80
+ this.cause = cause;
81
+ }
82
+ }
52
83
  export class OxyAuthenticationTimeoutError extends OxyAuthenticationError {
53
84
  constructor(operationName, timeoutMs) {
54
85
  super(`Authentication timeout (${timeoutMs}ms): ${operationName} requires user authentication. Please ensure the user is logged in before calling this method.`, 'AUTH_TIMEOUT', 408);
@@ -1,4 +1,4 @@
1
- import { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors.js';
1
+ import { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError, ServiceAssetMetadataError } from './OxyServices.errors.js';
2
2
  // Import mixin composition helper
3
3
  import { composeOxyServices } from './mixins/index.js';
4
4
  /**
@@ -50,7 +50,7 @@ export class OxyServices extends OxyServicesComposed {
50
50
  }
51
51
  }
52
52
  // Re-export error classes for convenience
53
- export { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError };
53
+ export { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError, ServiceAssetMetadataError };
54
54
  /**
55
55
  * Default Oxy Cloud URL — used when no `cloudURL` is provided to OxyServices.
56
56
  */
package/dist/esm/index.js CHANGED
@@ -21,7 +21,7 @@ import './crypto/polyfill.js';
21
21
  // ---------------------------------------------------------------------------
22
22
  // API client
23
23
  // ---------------------------------------------------------------------------
24
- export { OxyServices, AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.js';
24
+ export { OxyServices, AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError, ServiceAssetMetadataError } from './OxyServices.js';
25
25
  export { OXY_CLOUD_URL, oxyClient } from './OxyServices.js';
26
26
  // ---------------------------------------------------------------------------
27
27
  // Authentication
@@ -1,6 +1,6 @@
1
1
  import { isReactNative } from '@oxyhq/protocol';
2
2
  import { logger } from '../logger/index.js';
3
- import { AssetUrlResolutionError } from '../OxyServices.errors.js';
3
+ import { AssetUrlResolutionError, ServiceAssetMetadataError } from '../OxyServices.errors.js';
4
4
  import { extractErrorStatus } from '../utils/errorUtils.js';
5
5
  /**
6
6
  * Maximum number of ids sent per `POST /assets/service/by-ids` request. Matches
@@ -300,15 +300,25 @@ export function OxyServicesAssetsMixin(Base) {
300
300
  * throws because no credentials are available. A plain user-session request
301
301
  * is rejected by the route's service-auth guard.
302
302
  *
303
- * Resilience: chunks are independent. A failed chunk is logged and skipped —
304
- * the method returns every entry that resolved successfully rather than
305
- * discarding the whole call on one chunk's failure. An empty/whitespace-only
306
- * input resolves immediately with `[]` and performs no network call.
303
+ * FAILURE IS NOT ABSENCE. The server legitimately omits unknown/deleted ids,
304
+ * so a short result is normal — which means a chunk that FAILED (a 429, a
305
+ * timeout, a 5xx) is indistinguishable from "those assets don't exist" if it
306
+ * simply contributes nothing. This method used to swallow a failed chunk and
307
+ * return the rest, so every caller silently read a throttled request as "no
308
+ * metadata": a backfill counted the asset as needing no update, and the
309
+ * signed-record builder embedded a media item with no hash. Both reported
310
+ * success while writing nothing, and the MTN chain's records are immutable.
311
+ *
312
+ * So a failed chunk THROWS {@link ServiceAssetMetadataError} by default,
313
+ * carrying the ids it could not resolve. A caller that genuinely wants
314
+ * best-effort opts in with `{ partial: true }` and gets the old behaviour
315
+ * explicitly. An empty/whitespace-only input resolves immediately with `[]`
316
+ * and performs no network call.
307
317
  *
308
318
  * Not cached at the SDK layer: it's a POST keyed on a multi-id body (low hit
309
319
  * rate), mirroring the sibling service/POST methods which never cache.
310
320
  */
311
- async getServiceAssetMetadataByIds(ids) {
321
+ async getServiceAssetMetadataByIds(ids, options = {}) {
312
322
  const uniqueIds = Array.from(new Set(ids.filter((id) => typeof id === 'string' && id.trim().length > 0)));
313
323
  if (uniqueIds.length === 0) {
314
324
  return [];
@@ -317,22 +327,35 @@ export function OxyServicesAssetsMixin(Base) {
317
327
  for (let i = 0; i < uniqueIds.length; i += SERVICE_ASSET_METADATA_CHUNK_SIZE) {
318
328
  chunks.push(uniqueIds.slice(i, i + SERVICE_ASSET_METADATA_CHUNK_SIZE));
319
329
  }
320
- // Run chunks concurrently; a single chunk failure must not sink the rest.
330
+ // Chunks stay independent so one failure never cancels work already in
331
+ // flight; the failures are collected and re-raised together below.
332
+ const unresolvedIds = [];
333
+ const statuses = [];
334
+ let firstError;
321
335
  const settled = await Promise.all(chunks.map(async (chunk) => {
322
336
  try {
323
337
  const entries = await this.makeServiceRequest('POST', '/assets/service/by-ids', { ids: chunk });
324
338
  return Array.isArray(entries) ? entries : [];
325
339
  }
326
340
  catch (error) {
327
- logger.warn('getServiceAssetMetadataByIds: chunk failed, continuing with remaining chunks', {
341
+ const status = extractErrorStatus(error);
342
+ logger.warn('getServiceAssetMetadataByIds: chunk failed', {
328
343
  method: 'getServiceAssetMetadataByIds',
329
344
  chunkSize: chunk.length,
330
- status: extractErrorStatus(error),
345
+ status,
346
+ partial: options.partial === true,
331
347
  error: error instanceof Error ? error.message : String(error),
332
348
  });
349
+ unresolvedIds.push(...chunk);
350
+ if (typeof status === 'number')
351
+ statuses.push(status);
352
+ firstError ?? (firstError = error);
333
353
  return [];
334
354
  }
335
355
  }));
356
+ if (unresolvedIds.length > 0 && options.partial !== true) {
357
+ throw new ServiceAssetMetadataError(unresolvedIds, statuses, firstError);
358
+ }
336
359
  return settled.flat();
337
360
  }
338
361
  /**
@@ -151,11 +151,11 @@ export function OxyServicesDeviceBootMixin(Base) {
151
151
  *
152
152
  * That paragraph is LOAD-BEARING, not belt-and-braces: the route sits above
153
153
  * oxy-api's router-wide origin guard (deliberately, so a native client with
154
- * no `Origin` is not rejected), so as of this writing NOTHING server-side
155
- * refuses a browser caller that presents a valid bearer. Until a server-side
156
- * check lands, caller discipline is the only control which is also why a
157
- * doc note cannot be the whole answer to browser XSS minting a long-lived
158
- * credential with the victim's bearer.
154
+ * no `Origin` is not rejected). oxy-api additionally refuses callers that
155
+ * carry browser context signals (`Origin` or `Sec-Fetch-Site`) with
156
+ * `403 browser_not_allowed` native HTTP clients send neither. Gate the
157
+ * caller by platform on the client as well; do not widen the 404 degrade to
158
+ * 403, which would also swallow a genuine origin misconfiguration.
159
159
  *
160
160
  * @returns the provisioned credential, or `null` when the endpoint is absent
161
161
  * (404). The API deploy leads the SDK release, so a client on a newer SDK
@@ -3,6 +3,7 @@
3
3
  * Used by the @oxyhq/services account stores (Expo/RN and RN-Web).
4
4
  */
5
5
  import { translate } from '../i18n/index.js';
6
+ import { getNormalizedUserHandle } from './userHandle.js';
6
7
  /**
7
8
  * Truncate a long public key for display, e.g. `0x12345678…`.
8
9
  * Falls back to the raw key if it's too short to truncate.
@@ -96,7 +97,11 @@ export const buildAccountsArray = (accounts, order) => {
96
97
  * @param getFileDownloadUrl - Function to generate avatar download URL from file ID
97
98
  */
98
99
  export const createQuickAccount = (sessionId, userData, existingAccount, getFileDownloadUrl) => {
99
- const displayName = getAccountDisplayName(userData);
100
+ const nameObj = userData.name && typeof userData.name === 'object' ? userData.name : undefined;
101
+ const apiDisplayName = typeof nameObj?.displayName === 'string' ? nameObj.displayName.trim() : '';
102
+ const displayName = apiDisplayName ||
103
+ getNormalizedUserHandle(userData) ||
104
+ getAccountDisplayName(null);
100
105
  const userId = userData.id || (typeof userData._id === 'string' ? userData._id : userData._id?.toString());
101
106
  // Preserve existing avatarUrl if avatar hasn't changed (prevents image reload)
102
107
  let avatarUrl;