@oxyhq/core 15.0.1 → 16.1.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 (39) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/OxyServices.errors.js +33 -1
  3. package/dist/cjs/OxyServices.js +2 -1
  4. package/dist/cjs/index.js +8 -5
  5. package/dist/cjs/mixins/OxyServices.assets.js +31 -8
  6. package/dist/cjs/mixins/OxyServices.deviceBoot.js +5 -5
  7. package/dist/cjs/utils/accountUtils.js +6 -1
  8. package/dist/cjs/utils/displayNamePolicyRanges.generated.js +28 -3
  9. package/dist/cjs/utils/validationUtils.js +110 -21
  10. package/dist/esm/.tsbuildinfo +1 -1
  11. package/dist/esm/OxyServices.errors.js +31 -0
  12. package/dist/esm/OxyServices.js +2 -2
  13. package/dist/esm/index.js +2 -2
  14. package/dist/esm/mixins/OxyServices.assets.js +32 -9
  15. package/dist/esm/mixins/OxyServices.deviceBoot.js +5 -5
  16. package/dist/esm/utils/accountUtils.js +6 -1
  17. package/dist/esm/utils/displayNamePolicyRanges.generated.js +27 -2
  18. package/dist/esm/utils/validationUtils.js +110 -21
  19. package/dist/types/.tsbuildinfo +1 -1
  20. package/dist/types/OxyServices.d.ts +2 -2
  21. package/dist/types/OxyServices.errors.d.ts +33 -0
  22. package/dist/types/index.d.ts +2 -2
  23. package/dist/types/mixins/OxyServices.assets.d.ts +17 -5
  24. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +5 -5
  25. package/dist/types/utils/displayNamePolicyRanges.generated.d.ts +27 -2
  26. package/dist/types/utils/validationUtils.d.ts +104 -20
  27. package/package.json +2 -2
  28. package/src/OxyServices.errors.ts +45 -0
  29. package/src/OxyServices.ts +2 -2
  30. package/src/index.ts +3 -1
  31. package/src/mixins/OxyServices.assets.ts +36 -9
  32. package/src/mixins/OxyServices.deviceBoot.ts +5 -5
  33. package/src/mixins/__tests__/OxyServices.serviceAssetMetadata.test.ts +52 -3
  34. package/src/utils/__tests__/accountUtils.test.ts +22 -0
  35. package/src/utils/__tests__/coldBoot.test.ts +9 -4
  36. package/src/utils/__tests__/validationUtils.test.ts +292 -1
  37. package/src/utils/accountUtils.ts +9 -1
  38. package/src/utils/displayNamePolicyRanges.generated.ts +31 -2
  39. package/src/utils/validationUtils.ts +113 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "15.0.1",
3
+ "version": "16.1.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",
@@ -131,7 +131,7 @@
131
131
  "expo-crypto": "*",
132
132
  "expo-secure-store": "*",
133
133
  "express": "^4.0.0",
134
- "express-rate-limit": "^7.0.0",
134
+ "express-rate-limit": "^8.0.0",
135
135
  "helmet": "^8.0.0"
136
136
  },
137
137
  "peerDependenciesMeta": {
@@ -64,6 +64,51 @@ export class AssetUrlResolutionError extends Error {
64
64
  }
65
65
  }
66
66
 
67
+ /**
68
+ * Thrown when one or more chunks of `getServiceAssetMetadataByIds` could not be
69
+ * resolved.
70
+ *
71
+ * Exists because for this endpoint a FAILED request and an ABSENT asset produce
72
+ * the same observable result. The server legitimately omits unknown/deleted ids,
73
+ * so callers are documented to map the response by `id` and treat a missing
74
+ * entry as "no such asset" — which means a chunk that 429s, times out or 5xxs
75
+ * reads as authoritative absence unless it is raised.
76
+ *
77
+ * That was not hypothetical: a metadata backfill counted every throttled asset
78
+ * as needing no update and exited 0, and the MTN signed-record builder embedded
79
+ * media with no content hash into records that are immutable once signed. Both
80
+ * paths reported success and wrote nothing.
81
+ *
82
+ * `unresolvedIds` carries every id in a failed chunk — not the subset the server
83
+ * would have omitted anyway, which is unknowable when the request never landed.
84
+ * A caller that wants best-effort passes `{ partial: true }` and never sees this.
85
+ */
86
+ export class ServiceAssetMetadataError extends Error {
87
+ public readonly code = 'SERVICE_ASSET_METADATA_UNRESOLVED';
88
+ /** Every id belonging to a chunk whose request failed. */
89
+ public readonly unresolvedIds: string[];
90
+ /** HTTP statuses observed across the failed chunks (deduped, ascending). */
91
+ public readonly statuses: number[];
92
+ /**
93
+ * The first underlying transport/API failure. Declared on the class rather
94
+ * than relying on `Error.cause` because this package targets ES2020, where
95
+ * `cause` is not part of the `Error` type.
96
+ */
97
+ public readonly cause?: unknown;
98
+
99
+ constructor(unresolvedIds: string[], statuses: number[], cause?: unknown) {
100
+ const uniqueStatuses = Array.from(new Set(statuses)).sort((a, b) => a - b);
101
+ const statusSuffix = uniqueStatuses.length > 0 ? ` — status ${uniqueStatuses.join(', ')}` : '';
102
+ super(
103
+ `Could not resolve asset metadata for ${unresolvedIds.length} id(s)${statusSuffix}. Treat this as unknown, not as absent; pass { partial: true } for best-effort.`,
104
+ );
105
+ this.name = 'ServiceAssetMetadataError';
106
+ this.unresolvedIds = unresolvedIds;
107
+ this.statuses = uniqueStatuses;
108
+ this.cause = cause;
109
+ }
110
+ }
111
+
67
112
  export class OxyAuthenticationTimeoutError extends OxyAuthenticationError {
68
113
  constructor(operationName: string, timeoutMs: number) {
69
114
  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 { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors';
60
+ import { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError, ServiceAssetMetadataError } 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 { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError };
154
+ export { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError, ServiceAssetMetadataError };
155
155
 
156
156
  /**
157
157
  * Default Oxy Cloud URL — used when no `cloudURL` is provided to OxyServices.
package/src/index.ts CHANGED
@@ -23,7 +23,7 @@ import './crypto/polyfill';
23
23
  // ---------------------------------------------------------------------------
24
24
  // API client
25
25
  // ---------------------------------------------------------------------------
26
- export { OxyServices, AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices';
26
+ export { OxyServices, AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError, ServiceAssetMetadataError } from './OxyServices';
27
27
  export { OXY_CLOUD_URL, oxyClient } from './OxyServices';
28
28
  export type { LinkedHttpClient } from './OxyServices.base';
29
29
  // Auth-refresh handler surface — consumed by `@oxyhq/services`'s OxyContext to
@@ -479,6 +479,7 @@ export {
479
479
  EMAIL_REGEX,
480
480
  USERNAME_REGEX,
481
481
  PASSWORD_REGEX,
482
+ MAX_DISPLAY_NAME_LENGTH,
482
483
  isValidEmail,
483
484
  isValidUsername,
484
485
  isValidPassword,
@@ -486,6 +487,7 @@ export {
486
487
  DISPLAY_NAME_ALLOWED_SCRIPTS,
487
488
  DISPLAY_NAME_DISALLOWED_SOURCE,
488
489
  DISPLAY_NAME_ORPHANED_MARK_SOURCE,
490
+ DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE,
489
491
  isRequiredString,
490
492
  isRequiredNumber,
491
493
  isRequiredBoolean,
@@ -2,7 +2,7 @@ import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, A
2
2
  import type { OxyServicesBase } from '../OxyServices.base';
3
3
  import { isReactNative } from '@oxyhq/protocol';
4
4
  import { logger } from '../logger';
5
- import { AssetUrlResolutionError } from '../OxyServices.errors';
5
+ import { AssetUrlResolutionError, ServiceAssetMetadataError } from '../OxyServices.errors';
6
6
  import { extractErrorStatus } from '../utils/errorUtils';
7
7
  import { redactUrlQuery } from '../utils/redactUrl';
8
8
 
@@ -353,15 +353,28 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
353
353
  * throws because no credentials are available. A plain user-session request
354
354
  * is rejected by the route's service-auth guard.
355
355
  *
356
- * Resilience: chunks are independent. A failed chunk is logged and skipped —
357
- * the method returns every entry that resolved successfully rather than
358
- * discarding the whole call on one chunk's failure. An empty/whitespace-only
359
- * input resolves immediately with `[]` and performs no network call.
356
+ * FAILURE IS NOT ABSENCE. The server legitimately omits unknown/deleted ids,
357
+ * so a short result is normal — which means a chunk that FAILED (a 429, a
358
+ * timeout, a 5xx) is indistinguishable from "those assets don't exist" if it
359
+ * simply contributes nothing. This method used to swallow a failed chunk and
360
+ * return the rest, so every caller silently read a throttled request as "no
361
+ * metadata": a backfill counted the asset as needing no update, and the
362
+ * signed-record builder embedded a media item with no hash. Both reported
363
+ * success while writing nothing, and the MTN chain's records are immutable.
364
+ *
365
+ * So a failed chunk THROWS {@link ServiceAssetMetadataError} by default,
366
+ * carrying the ids it could not resolve. A caller that genuinely wants
367
+ * best-effort opts in with `{ partial: true }` and gets the old behaviour
368
+ * explicitly. An empty/whitespace-only input resolves immediately with `[]`
369
+ * and performs no network call.
360
370
  *
361
371
  * Not cached at the SDK layer: it's a POST keyed on a multi-id body (low hit
362
372
  * rate), mirroring the sibling service/POST methods which never cache.
363
373
  */
364
- async getServiceAssetMetadataByIds(ids: string[]): Promise<ServiceAssetMetadata[]> {
374
+ async getServiceAssetMetadataByIds(
375
+ ids: string[],
376
+ options: { partial?: boolean } = {},
377
+ ): Promise<ServiceAssetMetadata[]> {
365
378
  const uniqueIds = Array.from(
366
379
  new Set(ids.filter((id): id is string => typeof id === 'string' && id.trim().length > 0)),
367
380
  );
@@ -374,7 +387,12 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
374
387
  chunks.push(uniqueIds.slice(i, i + SERVICE_ASSET_METADATA_CHUNK_SIZE));
375
388
  }
376
389
 
377
- // Run chunks concurrently; a single chunk failure must not sink the rest.
390
+ // Chunks stay independent so one failure never cancels work already in
391
+ // flight; the failures are collected and re-raised together below.
392
+ const unresolvedIds: string[] = [];
393
+ const statuses: number[] = [];
394
+ let firstError: unknown;
395
+
378
396
  const settled = await Promise.all(
379
397
  chunks.map(async (chunk): Promise<ServiceAssetMetadata[]> => {
380
398
  try {
@@ -385,17 +403,26 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
385
403
  );
386
404
  return Array.isArray(entries) ? entries : [];
387
405
  } catch (error: unknown) {
388
- logger.warn('getServiceAssetMetadataByIds: chunk failed, continuing with remaining chunks', {
406
+ const status = extractErrorStatus(error);
407
+ logger.warn('getServiceAssetMetadataByIds: chunk failed', {
389
408
  method: 'getServiceAssetMetadataByIds',
390
409
  chunkSize: chunk.length,
391
- status: extractErrorStatus(error),
410
+ status,
411
+ partial: options.partial === true,
392
412
  error: error instanceof Error ? error.message : String(error),
393
413
  });
414
+ unresolvedIds.push(...chunk);
415
+ if (typeof status === 'number') statuses.push(status);
416
+ firstError ??= error;
394
417
  return [];
395
418
  }
396
419
  }),
397
420
  );
398
421
 
422
+ if (unresolvedIds.length > 0 && options.partial !== true) {
423
+ throw new ServiceAssetMetadataError(unresolvedIds, statuses, firstError);
424
+ }
425
+
399
426
  return settled.flat();
400
427
  }
401
428
 
@@ -169,11 +169,11 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
169
169
  *
170
170
  * That paragraph is LOAD-BEARING, not belt-and-braces: the route sits above
171
171
  * oxy-api's router-wide origin guard (deliberately, so a native client with
172
- * no `Origin` is not rejected), so as of this writing NOTHING server-side
173
- * refuses a browser caller that presents a valid bearer. Until a server-side
174
- * check lands, caller discipline is the only control which is also why a
175
- * doc note cannot be the whole answer to browser XSS minting a long-lived
176
- * credential with the victim's bearer.
172
+ * no `Origin` is not rejected). oxy-api additionally refuses callers that
173
+ * carry browser context signals (`Origin` or `Sec-Fetch-Site`) with
174
+ * `403 browser_not_allowed` native HTTP clients send neither. Gate the
175
+ * caller by platform on the client as well; do not widen the 404 degrade to
176
+ * 403, which would also swallow a genuine origin misconfiguration.
177
177
  *
178
178
  * @returns the provisioned credential, or `null` when the endpoint is absent
179
179
  * (404). The API deploy leads the SDK release, so a client on a newer SDK
@@ -10,11 +10,14 @@
10
10
  * - the `{ data }` envelope is unwrapped to a bare `ServiceAssetMetadata[]`
11
11
  * (mirroring how `makeServiceRequest<T[]>` returns the inner array);
12
12
  * - inputs > 100 ids are chunked at 100/request and merged;
13
- * - a failed chunk is logged and skipped successful chunks still return.
13
+ * - a failed chunk THROWS `ServiceAssetMetadataError` naming its ids, so a
14
+ * throttled request can never be read as "those assets do not exist";
15
+ * - `{ partial: true }` opts back into best-effort explicitly.
14
16
  */
15
17
 
16
18
  import type { ServiceAssetMetadata } from '../../models/interfaces';
17
19
  import { OxyServices } from '../../OxyServices';
20
+ import { ServiceAssetMetadataError } from '../../OxyServices.errors';
18
21
 
19
22
  const sampleEntry: ServiceAssetMetadata = {
20
23
  id: 'asset-1',
@@ -101,16 +104,62 @@ describe('OxyServices.assets — getServiceAssetMetadataByIds', () => {
101
104
  expect(result[249]?.id).toBe('asset-249');
102
105
  });
103
106
 
104
- it('skips a failed chunk and returns the entries that resolved', async () => {
107
+ it('THROWS when a chunk fails, so a failed request is never read as absence', async () => {
108
+ // The load-bearing assertion. The server legitimately omits unknown/deleted
109
+ // ids, so a short result is normal — which is exactly why a chunk that 429s
110
+ // must not just contribute nothing. Swallowing it made a throttled request
111
+ // indistinguishable from "those assets do not exist".
105
112
  const ids = Array.from({ length: 150 }, (_, i) => `asset-${i}`);
106
113
 
107
114
  makeServiceRequestSpy
108
115
  .mockResolvedValueOnce([sampleEntry]) // first chunk (100 ids) succeeds
109
116
  .mockRejectedValueOnce(new Error('chunk failed')); // second chunk (50 ids) fails
110
117
 
111
- const result = await oxy.getServiceAssetMetadataByIds(ids);
118
+ await expect(oxy.getServiceAssetMetadataByIds(ids)).rejects.toThrow(ServiceAssetMetadataError);
119
+ expect(makeServiceRequestSpy).toHaveBeenCalledTimes(2);
120
+ });
121
+
122
+ it('names every id of the failed chunk on the error', async () => {
123
+ const ids = Array.from({ length: 150 }, (_, i) => `asset-${i}`);
124
+
125
+ makeServiceRequestSpy
126
+ .mockResolvedValueOnce([sampleEntry])
127
+ .mockRejectedValueOnce(Object.assign(new Error('rate limited'), {
128
+ response: { status: 429 },
129
+ }));
130
+
131
+ const error = await oxy.getServiceAssetMetadataByIds(ids).catch((e: unknown) => e);
132
+
133
+ expect(error).toBeInstanceOf(ServiceAssetMetadataError);
134
+ const typed = error as ServiceAssetMetadataError;
135
+ // The whole chunk is unresolved: which of those ids the server would have
136
+ // omitted anyway is unknowable when the request never landed.
137
+ expect(typed.unresolvedIds).toHaveLength(50);
138
+ expect(typed.unresolvedIds).toContain('asset-100');
139
+ expect(typed.unresolvedIds).not.toContain('asset-0');
140
+ expect(typed.statuses).toEqual([429]);
141
+ expect(typed.code).toBe('SERVICE_ASSET_METADATA_UNRESOLVED');
142
+ });
143
+
144
+ it('returns the resolved entries when the caller opts into { partial: true }', async () => {
145
+ const ids = Array.from({ length: 150 }, (_, i) => `asset-${i}`);
146
+
147
+ makeServiceRequestSpy
148
+ .mockResolvedValueOnce([sampleEntry])
149
+ .mockRejectedValueOnce(new Error('chunk failed'));
150
+
151
+ const result = await oxy.getServiceAssetMetadataByIds(ids, { partial: true });
112
152
 
113
153
  expect(makeServiceRequestSpy).toHaveBeenCalledTimes(2);
114
154
  expect(result).toEqual([sampleEntry]);
115
155
  });
156
+
157
+ it('does not throw when every chunk succeeds, with or without partial', async () => {
158
+ makeServiceRequestSpy.mockResolvedValue([sampleEntry]);
159
+
160
+ await expect(oxy.getServiceAssetMetadataByIds(['asset-1'])).resolves.toEqual([sampleEntry]);
161
+ await expect(
162
+ oxy.getServiceAssetMetadataByIds(['asset-1'], { partial: true }),
163
+ ).resolves.toEqual([sampleEntry]);
164
+ });
116
165
  });
@@ -1,4 +1,5 @@
1
1
  import {
2
+ createQuickAccount,
2
3
  getAccountDisplayName,
3
4
  getAccountFallbackHandle,
4
5
  formatPublicKeyHandle,
@@ -116,6 +117,27 @@ describe('getAccountFallbackHandle', () => {
116
117
  });
117
118
  });
118
119
 
120
+ describe('createQuickAccount', () => {
121
+ it('prefers API name.displayName over composed first/last', () => {
122
+ const account = createQuickAccount('sess-1', {
123
+ name: { first: 'Nate', last: 'Isern', displayName: 'Nate Isern' },
124
+ username: 'nateus',
125
+ id: 'user-1',
126
+ });
127
+ expect(account.displayName).toBe('Nate Isern');
128
+ });
129
+
130
+ it('falls back to the normalized handle when displayName is absent', () => {
131
+ const account = createQuickAccount('sess-1', {
132
+ name: { first: 'Nate', last: 'Isern' },
133
+ username: 'nateus',
134
+ id: 'user-1',
135
+ });
136
+ expect(account.displayName).toBe('nateus');
137
+ expect(account.displayName).not.toBe('Nate Isern');
138
+ });
139
+ });
140
+
119
141
  describe('formatPublicKeyHandle', () => {
120
142
  it('truncates a long key and strips the 0x prefix before re-adding it', () => {
121
143
  expect(formatPublicKeyHandle('0x1234567890abcdef')).toBe('0x12345678…');
@@ -241,13 +241,17 @@ describe('runColdBoot', () => {
241
241
  it('hangs forever when a step never settles and no deadline is set', async () => {
242
242
  const terminalRan = jest.fn();
243
243
  let settled = false;
244
+ let releaseHang: (() => void) | undefined;
244
245
 
245
246
  const outcomePromise = runColdBoot<TestSession>({
246
247
  steps: [
247
248
  {
248
249
  id: 'never-settles',
249
- // Never resolves or rejects — models a hung async call.
250
- run: () => new Promise<ColdBootStepResult<TestSession>>(() => {}),
250
+ // Never resolves or rejects until the test tears down — models a hung async call.
251
+ run: () =>
252
+ new Promise<ColdBootStepResult<TestSession>>((resolve) => {
253
+ releaseHang = () => resolve({ kind: 'skip' });
254
+ }),
251
255
  },
252
256
  {
253
257
  id: 'terminal',
@@ -268,8 +272,9 @@ describe('runColdBoot', () => {
268
272
  expect(settled).toBe(false);
269
273
  expect(terminalRan).not.toHaveBeenCalled();
270
274
 
271
- // Avoid a dangling unhandled promise in the test runner.
272
- void outcomePromise;
275
+ // Tear down the intentional hang so Jest workers can exit cleanly.
276
+ releaseHang?.();
277
+ await outcomePromise;
273
278
  });
274
279
 
275
280
  /**