@oxyhq/core 15.0.0 → 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.
@@ -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 { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors';
60
+ import { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError, ServiceAssetMetadataError } 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 { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError };
124
+ export { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError, ServiceAssetMetadataError };
125
125
  /**
126
126
  * Default Oxy Cloud URL — used when no `cloudURL` is provided to OxyServices.
127
127
  */
@@ -46,6 +46,39 @@ export declare class AssetUrlResolutionError extends Error {
46
46
  readonly cause?: unknown;
47
47
  constructor(fileId: string, variant: string | undefined, status: number | undefined, cause?: unknown);
48
48
  }
49
+ /**
50
+ * Thrown when one or more chunks of `getServiceAssetMetadataByIds` could not be
51
+ * resolved.
52
+ *
53
+ * Exists because for this endpoint a FAILED request and an ABSENT asset produce
54
+ * the same observable result. The server legitimately omits unknown/deleted ids,
55
+ * so callers are documented to map the response by `id` and treat a missing
56
+ * entry as "no such asset" — which means a chunk that 429s, times out or 5xxs
57
+ * reads as authoritative absence unless it is raised.
58
+ *
59
+ * That was not hypothetical: a metadata backfill counted every throttled asset
60
+ * as needing no update and exited 0, and the MTN signed-record builder embedded
61
+ * media with no content hash into records that are immutable once signed. Both
62
+ * paths reported success and wrote nothing.
63
+ *
64
+ * `unresolvedIds` carries every id in a failed chunk — not the subset the server
65
+ * would have omitted anyway, which is unknowable when the request never landed.
66
+ * A caller that wants best-effort passes `{ partial: true }` and never sees this.
67
+ */
68
+ export declare class ServiceAssetMetadataError extends Error {
69
+ readonly code = "SERVICE_ASSET_METADATA_UNRESOLVED";
70
+ /** Every id belonging to a chunk whose request failed. */
71
+ readonly unresolvedIds: string[];
72
+ /** HTTP statuses observed across the failed chunks (deduped, ascending). */
73
+ readonly statuses: number[];
74
+ /**
75
+ * The first underlying transport/API failure. Declared on the class rather
76
+ * than relying on `Error.cause` because this package targets ES2020, where
77
+ * `cause` is not part of the `Error` type.
78
+ */
79
+ readonly cause?: unknown;
80
+ constructor(unresolvedIds: string[], statuses: number[], cause?: unknown);
81
+ }
49
82
  export declare class OxyAuthenticationTimeoutError extends OxyAuthenticationError {
50
83
  constructor(operationName: string, timeoutMs: number);
51
84
  }
@@ -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, AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices';
20
+ export { OxyServices, AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError, ServiceAssetMetadataError } 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';
@@ -160,15 +160,27 @@ export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>
160
160
  * throws because no credentials are available. A plain user-session request
161
161
  * is rejected by the route's service-auth guard.
162
162
  *
163
- * Resilience: chunks are independent. A failed chunk is logged and skipped —
164
- * the method returns every entry that resolved successfully rather than
165
- * discarding the whole call on one chunk's failure. An empty/whitespace-only
166
- * input resolves immediately with `[]` and performs no network call.
163
+ * FAILURE IS NOT ABSENCE. The server legitimately omits unknown/deleted ids,
164
+ * so a short result is normal — which means a chunk that FAILED (a 429, a
165
+ * timeout, a 5xx) is indistinguishable from "those assets don't exist" if it
166
+ * simply contributes nothing. This method used to swallow a failed chunk and
167
+ * return the rest, so every caller silently read a throttled request as "no
168
+ * metadata": a backfill counted the asset as needing no update, and the
169
+ * signed-record builder embedded a media item with no hash. Both reported
170
+ * success while writing nothing, and the MTN chain's records are immutable.
171
+ *
172
+ * So a failed chunk THROWS {@link ServiceAssetMetadataError} by default,
173
+ * carrying the ids it could not resolve. A caller that genuinely wants
174
+ * best-effort opts in with `{ partial: true }` and gets the old behaviour
175
+ * explicitly. An empty/whitespace-only input resolves immediately with `[]`
176
+ * and performs no network call.
167
177
  *
168
178
  * Not cached at the SDK layer: it's a POST keyed on a multi-id body (low hit
169
179
  * rate), mirroring the sibling service/POST methods which never cache.
170
180
  */
171
- getServiceAssetMetadataByIds(ids: string[]): Promise<ServiceAssetMetadata[]>;
181
+ getServiceAssetMetadataByIds(ids: string[], options?: {
182
+ partial?: boolean;
183
+ }): Promise<ServiceAssetMetadata[]>;
172
184
  /**
173
185
  * Reverse content-address lookup: resolve many content `sha256` digests to
174
186
  * the servable Oxy asset holding each, in one round-trip per chunk via
@@ -104,6 +104,24 @@ export declare function OxyServicesDeviceBootMixin<T extends typeof OxyServicesB
104
104
  * method would be dead code plus a second implementation of the failure
105
105
  * rules. The asymmetry is the design.
106
106
  *
107
+ * **Call this from NATIVE only.** There is no background worker on web to
108
+ * consume the credential, and handing a browser origin a long-lived
109
+ * non-rotating secret to persist is strictly weaker than the rotating device
110
+ * secret it already holds. The 404 degrade below is also native-shaped: a
111
+ * browser attaches `Origin`, which a server predating this route answers
112
+ * `403 BAD_ORIGIN` from its router-wide same-site guard rather than 404, so
113
+ * the quiet degrade would not fire there. A native client sends no `Origin`
114
+ * and gets the 404. Gate the caller by platform; do not widen the degrade to
115
+ * 403, which would also swallow a genuine origin misconfiguration.
116
+ *
117
+ * That paragraph is LOAD-BEARING, not belt-and-braces: the route sits above
118
+ * oxy-api's router-wide origin guard (deliberately, so a native client with
119
+ * no `Origin` is not rejected). oxy-api additionally refuses callers that
120
+ * carry browser context signals (`Origin` or `Sec-Fetch-Site`) with
121
+ * `403 browser_not_allowed` — native HTTP clients send neither. Gate the
122
+ * caller by platform on the client as well; do not widen the 404 degrade to
123
+ * 403, which would also swallow a genuine origin misconfiguration.
124
+ *
107
125
  * @returns the provisioned credential, or `null` when the endpoint is absent
108
126
  * (404). The API deploy leads the SDK release, so a client on a newer SDK
109
127
  * than the server degrades to "no background session" quietly instead of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "15.0.0",
3
+ "version": "16.0.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
@@ -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
 
@@ -157,6 +157,24 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
157
157
  * method would be dead code plus a second implementation of the failure
158
158
  * rules. The asymmetry is the design.
159
159
  *
160
+ * **Call this from NATIVE only.** There is no background worker on web to
161
+ * consume the credential, and handing a browser origin a long-lived
162
+ * non-rotating secret to persist is strictly weaker than the rotating device
163
+ * secret it already holds. The 404 degrade below is also native-shaped: a
164
+ * browser attaches `Origin`, which a server predating this route answers
165
+ * `403 BAD_ORIGIN` from its router-wide same-site guard rather than 404, so
166
+ * the quiet degrade would not fire there. A native client sends no `Origin`
167
+ * and gets the 404. Gate the caller by platform; do not widen the degrade to
168
+ * 403, which would also swallow a genuine origin misconfiguration.
169
+ *
170
+ * That paragraph is LOAD-BEARING, not belt-and-braces: the route sits above
171
+ * oxy-api's router-wide origin guard (deliberately, so a native client with
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
+ *
160
178
  * @returns the provisioned credential, or `null` when the endpoint is absent
161
179
  * (404). The API deploy leads the SDK release, so a client on a newer SDK
162
180
  * than the server degrades to "no background session" quietly instead of
@@ -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
  });
@@ -0,0 +1,135 @@
1
+ import type { DeviceBackgroundCredentialResponse } from '@oxyhq/contracts';
2
+ import { OxyServices } from '../../OxyServices';
3
+
4
+ /**
5
+ * Real-stack integration test for `provisionBackgroundCredential`: a genuine
6
+ * `HttpService` (via `OxyServices`) with `global.fetch` stubbed to return the
7
+ * EXACT wire bodies oxy-api sends, rather than a `makeRequest` spy.
8
+ *
9
+ * The unit suite (`OxyServices.deviceBoot.test.ts`) stubs `makeRequest`, so by
10
+ * construction it cannot see either of the two things this file pins:
11
+ *
12
+ * 1. **The `{ data }` envelope.** The route answers
13
+ * `{ data: { deviceId, secret, accountId, expiresAt } }`, and
14
+ * `HttpService.unwrapResponse` strips that outer envelope — so the mixin
15
+ * must validate the FLAT credential and must not read `.data` a second
16
+ * time. A spy returning the flat shape asserts that assumption instead of
17
+ * testing it; the same blind spot produced a P0 in `SessionClient` (see
18
+ * `SessionClient.httpIntegration.test.ts`).
19
+ * 2. **The 404 degrade against the REAL error object.** The `404 → null` path
20
+ * is what keeps a client on a newer SDK than the server from breaking, and
21
+ * it keys on the status surviving whatever `HttpService` throws. A
22
+ * hand-built `Object.assign(new Error(), { status: 404 })` proves only that
23
+ * the branch reads the shape the test itself invented.
24
+ *
25
+ * The stub is URL-aware because a state-changing request through the real stack
26
+ * fetches `GET /csrf-token` FIRST. A blanket stub answers that call too, which
27
+ * both shifts the request under test out of `calls[0]` and (on a non-200 stub)
28
+ * makes the CSRF fetch burn its own retries — so a naive call-count assertion
29
+ * measures CSRF attempts rather than the route.
30
+ */
31
+ const ROUTE = '/session/device/background-credential';
32
+
33
+ const CREDENTIAL: DeviceBackgroundCredentialResponse = {
34
+ deviceId: 'device-real',
35
+ secret: 'bg-secret-from-the-wire',
36
+ accountId: 'acct-1',
37
+ expiresAt: '2030-01-01T00:00:00.000Z',
38
+ };
39
+
40
+ /** The route's success body: the credential under this API's `data` envelope. */
41
+ const ROUTE_BODY = { data: CREDENTIAL };
42
+
43
+ /** oxy-api's 404 body for an unmatched path (server.ts's terminal handler). */
44
+ const NOT_FOUND_BODY = { error: 'NOT_FOUND', message: 'Resource not found' };
45
+
46
+ const jsonResponse = (body: unknown, status: number) =>
47
+ new Response(JSON.stringify(body), {
48
+ status,
49
+ headers: { 'content-type': 'application/json' },
50
+ });
51
+
52
+ /**
53
+ * A syntactically real, far-future access token. It must be a decodable JWT:
54
+ * `HttpService.getAuthHeader` runs `jwtDecode` and sends NO bearer at all when
55
+ * that throws, so an opaque placeholder would silently turn this into an
56
+ * anonymous request and make the bearer assertion below untestable.
57
+ */
58
+ const ACCESS_TOKEN = (() => {
59
+ const segment = (payload: object) => Buffer.from(JSON.stringify(payload)).toString('base64url');
60
+ return [
61
+ segment({ alg: 'none', typ: 'JWT' }),
62
+ segment({ sub: 'acct-1', exp: 4_102_444_800 }), // 2100-01-01
63
+ 'signature-not-verified-client-side',
64
+ ].join('.');
65
+ })();
66
+
67
+ describe('provisionBackgroundCredential over a real HttpService', () => {
68
+ const originalFetch = global.fetch;
69
+
70
+ /** Answers the CSRF preflight properly; answers the route under test with `body`/`status`. */
71
+ const stubFetch = (body: unknown, status: number) => {
72
+ const fetchMock = jest.fn(async (input: unknown) => {
73
+ if (String(input).includes('/csrf-token')) {
74
+ return jsonResponse({ csrfToken: 'csrf-test-token' }, 200);
75
+ }
76
+ return jsonResponse(body, status);
77
+ });
78
+ global.fetch = fetchMock as unknown as typeof fetch;
79
+ return fetchMock;
80
+ };
81
+
82
+ /** Every stubbed call whose URL is the route under test (i.e. not the CSRF preflight). */
83
+ const routeCalls = (fetchMock: jest.Mock) =>
84
+ fetchMock.mock.calls.filter(([input]) => String(input).includes(ROUTE));
85
+
86
+ const client = () => {
87
+ const oxy = new OxyServices({ baseURL: 'http://api.test.invalid' });
88
+ // Bearer required: the server derives both the deviceId and the account
89
+ // from it, and this call (unlike the device-secret mint) does not skipAuth.
90
+ oxy.setTokens(ACCESS_TOKEN);
91
+ return oxy;
92
+ };
93
+
94
+ afterEach(() => {
95
+ global.fetch = originalFetch;
96
+ });
97
+
98
+ it('unwraps the { data } envelope and returns the flat credential', async () => {
99
+ stubFetch(ROUTE_BODY, 200);
100
+
101
+ const result = await client().provisionBackgroundCredential();
102
+
103
+ // Would be `{ data: {...} }` if the envelope were not unwrapped, and would
104
+ // throw (contract validation failure) if `.data` were read twice.
105
+ expect(result).toEqual(CREDENTIAL);
106
+ expect(result?.secret).toBe('bg-secret-from-the-wire');
107
+ });
108
+
109
+ it('sends a POST to the route with NO body and a bearer', async () => {
110
+ const fetchMock = stubFetch(ROUTE_BODY, 200);
111
+
112
+ await client().provisionBackgroundCredential();
113
+
114
+ const calls = routeCalls(fetchMock);
115
+ expect(calls).toHaveLength(1);
116
+ const [, init] = calls[0] as unknown as [string, RequestInit];
117
+ expect(init.method).toBe('POST');
118
+ // No body at all — not `'undefined'`, not `'{}'`. The server derives the
119
+ // deviceId and the account from the bearer; anything sent here would be
120
+ // ignored at best and mass-assignment surface at worst.
121
+ expect(init.body ?? null).toBeNull();
122
+ expect(new Headers(init.headers).get('authorization')).toBe(`Bearer ${ACCESS_TOKEN}`);
123
+ });
124
+
125
+ it('returns null on the real 404 response, without retrying the route', async () => {
126
+ const fetchMock = stubFetch(NOT_FOUND_BODY, 404);
127
+
128
+ await expect(client().provisionBackgroundCredential()).resolves.toBeNull();
129
+
130
+ // 4xx is not retried (`retryAsync`'s default shouldRetry), so an absent
131
+ // endpoint costs exactly one request to the route — the degrade must not
132
+ // burn a backoff loop on every provision attempt.
133
+ expect(routeCalls(fetchMock)).toHaveLength(1);
134
+ });
135
+ });
@@ -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…');
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { translate } from '../i18n';
7
+ import { getNormalizedUserHandle } from './userHandle';
7
8
 
8
9
  export interface QuickAccount {
9
10
  sessionId: string;
@@ -154,7 +155,14 @@ export const createQuickAccount = (
154
155
  existingAccount?: QuickAccount,
155
156
  getFileDownloadUrl?: (fileId: string, variant: string) => string
156
157
  ): QuickAccount => {
157
- const displayName = getAccountDisplayName(userData);
158
+ const nameObj =
159
+ userData.name && typeof userData.name === 'object' ? userData.name : undefined;
160
+ const apiDisplayName =
161
+ typeof nameObj?.displayName === 'string' ? nameObj.displayName.trim() : '';
162
+ const displayName =
163
+ apiDisplayName ||
164
+ getNormalizedUserHandle(userData) ||
165
+ getAccountDisplayName(null);
158
166
  const userId = userData.id || (typeof userData._id === 'string' ? userData._id : userData._id?.toString());
159
167
 
160
168
  // Preserve existing avatarUrl if avatar hasn't changed (prevents image reload)