@oxyhq/core 5.0.0 → 5.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.
@@ -62,7 +62,7 @@ export { RecoveryPhraseService } from './crypto/recoveryPhrase';
62
62
  export type { RecoveryPhraseResult } from './crypto/recoveryPhrase';
63
63
  export { DeviceManager } from './utils/deviceManager';
64
64
  export type { DeviceFingerprint, StoredDeviceInfo } from './utils/deviceManager';
65
- 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, AccountStorageCategoryUsage, AccountStorageUsageResponse, SecurityEventType, SecurityEventSeverity, SecurityActivity, SecurityActivityResponse, AssetUploadProgress, DeviceSession, DeviceSessionsResponse, DeviceSessionLogoutResponse, UpdateDeviceNameResponse, } from './models/interfaces';
65
+ 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, AccountStorageCategoryUsage, AccountStorageUsageResponse, SecurityEventType, SecurityEventSeverity, SecurityActivity, SecurityActivityResponse, AssetUploadProgress, DeviceSession, DeviceSessionsResponse, DeviceSessionLogoutResponse, UpdateDeviceNameResponse, } from './models/interfaces';
66
66
  export { SECURITY_EVENT_SEVERITY_MAP } from './models/interfaces';
67
67
  export { TopicType, TopicSource } from './models/Topic';
68
68
  export type { TopicData, TopicTranslation } from './models/Topic';
@@ -437,10 +437,19 @@ export declare function OxyServicesAccountsMixin<T extends typeof OxyServicesBas
437
437
  * Unlike the removed `X-Acting-As` delegation header, the returned session
438
438
  * IS the new identity: this plants `accessToken` as the active token —
439
439
  * exactly like `claimSessionByToken` / `verifyChallenge` — so every
440
- * subsequent request authenticates as the target account. The refresh token
441
- * is the server-set httpOnly `oxy_rt_<authuser>` cookie (never in the body),
442
- * so it joins the device multi-account set and survives reload /
443
- * `refresh-all` with no extra client work.
440
+ * subsequent request authenticates as the target account.
441
+ *
442
+ * Joining the device multi-account set (so the switch survives a reload and
443
+ * propagates cross-domain via `/auth/refresh-all`) requires a SECOND call, to
444
+ * `POST /auth/session`, made here after the token is planted. The switch route
445
+ * lives at `/accounts/*`, OUTSIDE the `oxy_rt_<authuser>` cookie's `Path=/auth`
446
+ * scope, so the server never sees the device's existing slots from it and
447
+ * would clobber slot 0 (destroying the operator's own session). `/auth/session`
448
+ * runs where those cookies ARE visible, so the server allocates a NEW slot that
449
+ * coexists with the operator's and returns its `authuser`. This step is
450
+ * web-only (native multi-account uses stored sessions, not cookies) and
451
+ * best-effort — a failure leaves the in-session switch intact; the switched
452
+ * account simply won't survive a reload until the cookie is next established.
444
453
  *
445
454
  * After planting, the SDK's identity-scoped GET cache is fully cleared so
446
455
  * every cached read re-fetches as the new account. (The consuming
@@ -1,7 +1,17 @@
1
- import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant } from '../models/interfaces';
1
+ import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, ServiceAssetMetadata } 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[]): {
5
+ /**
6
+ * Service-token request, implemented by the auth mixin earlier in the
7
+ * composition pipeline (see `mixins/index.ts`). The assets mixin is typed
8
+ * against `OxyServicesBase`, which does not carry the auth mixin's methods,
9
+ * so this `declare` surfaces the inherited runtime method to TypeScript
10
+ * without re-implementing it. Used by
11
+ * {@link getServiceAssetMetadataByIds} to authenticate the server-to-server
12
+ * `/assets/service/by-ids` bulk fetch with a bearer service token.
13
+ */
14
+ makeServiceRequest: <R = unknown>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: unknown, userId?: string) => Promise<R>;
5
15
  /**
6
16
  * Delete file
7
17
  */
@@ -49,6 +59,37 @@ export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>
49
59
  * Get download URLs for multiple files efficiently
50
60
  */
51
61
  getFileDownloadUrls(fileIds: string[], context?: string): Promise<Record<string, string>>;
62
+ /**
63
+ * Resolve many Oxy asset ids to their content-addressed metadata in one
64
+ * round-trip per chunk via `POST /assets/service/by-ids` (body `{ ids }`).
65
+ *
66
+ * Returns each asset's `sha256`, `mime`, byte `size`, and `status` — built
67
+ * for server-to-server callers (e.g. Mention's MTN Protocol blob-ref
68
+ * resolution) that need the content hash for an asset id. Ids are
69
+ * deduplicated and validated (empty/blank ids dropped) before being split
70
+ * into chunks of {@link SERVICE_ASSET_METADATA_CHUNK_SIZE} (the server-side
71
+ * cap). The server omits unknown/deleted ids from each chunk's `data`, so
72
+ * the merged result may be shorter than the requested id list and the caller
73
+ * is expected to map by `id`.
74
+ *
75
+ * **Service-token auth (required).** `/assets/service/by-ids` is guarded by
76
+ * `serviceAuthMiddleware` + the `files:read` scope and is called via
77
+ * `makeServiceRequest`, which attaches `Authorization: Bearer <serviceToken>`
78
+ * (the same client that calls `POST /assets/service/cache`). The calling
79
+ * client MUST be service-configured (`configureServiceAuth(apiKey,
80
+ * apiSecret)`) before invoking this method; otherwise `getServiceToken()`
81
+ * throws because no credentials are available. A plain user-session request
82
+ * is rejected by the route's service-auth guard.
83
+ *
84
+ * Resilience: chunks are independent. A failed chunk is logged and skipped —
85
+ * the method returns every entry that resolved successfully rather than
86
+ * discarding the whole call on one chunk's failure. An empty/whitespace-only
87
+ * input resolves immediately with `[]` and performs no network call.
88
+ *
89
+ * Not cached at the SDK layer: it's a POST keyed on a multi-id body (low hit
90
+ * rate), mirroring the sibling service/POST methods which never cache.
91
+ */
92
+ getServiceAssetMetadataByIds(ids: string[]): Promise<ServiceAssetMetadata[]>;
52
93
  /**
53
94
  * Upload raw file data
54
95
  */
@@ -447,6 +447,24 @@ export interface AssetUpdateVisibilityResponse {
447
447
  updatedAt: string;
448
448
  };
449
449
  }
450
+ /**
451
+ * Minimal, service-token-scoped asset metadata returned by
452
+ * `POST /assets/service/by-ids`.
453
+ *
454
+ * Resolves an Oxy asset `id` to its content-addressed identity (`sha256`),
455
+ * MIME type, byte `size`, and storage `status`. Used by server-to-server
456
+ * callers (e.g. Mention's MTN Protocol blob-ref resolution) that hold a
457
+ * `files:read`-scoped service token rather than a user session. Unknown or
458
+ * deleted ids are omitted from the response (never error the whole batch),
459
+ * so the result may be shorter than the requested id list.
460
+ */
461
+ export interface ServiceAssetMetadata {
462
+ id: string;
463
+ sha256: string;
464
+ mime: string;
465
+ size: number;
466
+ status: 'active' | 'trash';
467
+ }
450
468
  /**
451
469
  * Account storage usage (server-side usage, not local AsyncStorage)
452
470
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "5.0.0",
3
+ "version": "5.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",
@@ -95,7 +95,7 @@
95
95
  },
96
96
  "dependencies": {
97
97
  "@oxyhq/contracts": "^0.7.0",
98
- "@oxyhq/protocol": "^0.1.0",
98
+ "@oxyhq/protocol": "^0.1.1",
99
99
  "bip39": "^3.1.0",
100
100
  "buffer": "^6.0.3",
101
101
  "elliptic": "^6.6.1",
package/src/index.ts CHANGED
@@ -324,6 +324,7 @@ export type {
324
324
  AssetDeleteSummary,
325
325
  AssetUpdateVisibilityRequest,
326
326
  AssetUpdateVisibilityResponse,
327
+ ServiceAssetMetadata,
327
328
  AccountStorageCategoryUsage,
328
329
  AccountStorageUsageResponse,
329
330
  SecurityEventType,
@@ -35,6 +35,8 @@ import type { User } from '../models/interfaces';
35
35
  import type { SessionLoginResponse } from '../models/session';
36
36
  import type { OxyServicesBase } from '../OxyServices.base';
37
37
  import { normalizeUserIdentity } from '../utils/userIdentity';
38
+ import { isWeb } from '../utils/platform';
39
+ import { logger } from '../utils/loggerUtils';
38
40
  import { CACHE_TIMES } from './mixinHelpers';
39
41
 
40
42
  // ---------------------------------------------------------------------------
@@ -520,10 +522,19 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
520
522
  * Unlike the removed `X-Acting-As` delegation header, the returned session
521
523
  * IS the new identity: this plants `accessToken` as the active token —
522
524
  * exactly like `claimSessionByToken` / `verifyChallenge` — so every
523
- * subsequent request authenticates as the target account. The refresh token
524
- * is the server-set httpOnly `oxy_rt_<authuser>` cookie (never in the body),
525
- * so it joins the device multi-account set and survives reload /
526
- * `refresh-all` with no extra client work.
525
+ * subsequent request authenticates as the target account.
526
+ *
527
+ * Joining the device multi-account set (so the switch survives a reload and
528
+ * propagates cross-domain via `/auth/refresh-all`) requires a SECOND call, to
529
+ * `POST /auth/session`, made here after the token is planted. The switch route
530
+ * lives at `/accounts/*`, OUTSIDE the `oxy_rt_<authuser>` cookie's `Path=/auth`
531
+ * scope, so the server never sees the device's existing slots from it and
532
+ * would clobber slot 0 (destroying the operator's own session). `/auth/session`
533
+ * runs where those cookies ARE visible, so the server allocates a NEW slot that
534
+ * coexists with the operator's and returns its `authuser`. This step is
535
+ * web-only (native multi-account uses stored sessions, not cookies) and
536
+ * best-effort — a failure leaves the in-session switch intact; the switched
537
+ * account simply won't survive a reload until the cookie is next established.
527
538
  *
528
539
  * After planting, the SDK's identity-scoped GET cache is fully cleared so
529
540
  * every cached read re-fetches as the new account. (The consuming
@@ -546,12 +557,45 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
546
557
 
547
558
  // Plant the freshly minted session as the ACTIVE session, mirroring
548
559
  // `claimSessionByToken` / `verifyChallenge`: the response body carries
549
- // the first access token; the refresh token is the server-set httpOnly
550
- // cookie, so there is nothing else to store here.
560
+ // the first access token. The device refresh cookie is established below.
551
561
  if (res?.accessToken) {
552
562
  this.setTokens(res.accessToken);
553
563
  }
554
564
 
565
+ // Register the switched session in the device's multi-account set by
566
+ // establishing its first-party refresh cookie. This MUST be a separate
567
+ // call to `POST /auth/session`: the switch route is at `/accounts/*`,
568
+ // outside the `oxy_rt_<authuser>` cookie's `Path=/auth` scope, so it can
569
+ // never read the device's existing slots and would overwrite slot 0
570
+ // (destroying the operator's own session). `/auth/session` runs where the
571
+ // cookies ARE visible, so the server allocates a NEW slot that coexists
572
+ // with the operator's and returns its `authuser`. Web-only; best-effort.
573
+ let authuser = res.authuser;
574
+ if (isWeb()) {
575
+ try {
576
+ const established = await this.makeRequest<{ accessToken?: string; authuser?: number }>(
577
+ 'POST',
578
+ '/auth/session',
579
+ undefined,
580
+ { cache: false },
581
+ );
582
+ if (typeof established?.authuser === 'number') {
583
+ authuser = established.authuser;
584
+ }
585
+ // /auth/session mints a fresh access token off the same session;
586
+ // re-plant it so the active token matches the rotated cookie.
587
+ if (established?.accessToken) {
588
+ this.setTokens(established.accessToken);
589
+ }
590
+ } catch (error) {
591
+ logger.warn(
592
+ '[OxyServices] Failed to establish device refresh cookie after account switch; the switch is active in-session but may not survive a reload',
593
+ { component: 'OxyServices', method: 'switchToAccount' },
594
+ error,
595
+ );
596
+ }
597
+ }
598
+
555
599
  // Identity changed → drop the entire GET response cache so no entry
556
600
  // personalised for the previous identity is reused. Cache keys are
557
601
  // identity-scoped, so a different identity could not READ the old
@@ -561,6 +605,7 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
561
605
 
562
606
  return {
563
607
  ...res,
608
+ ...(typeof authuser === 'number' ? { authuser } : {}),
564
609
  user: normalizeUserIdentity(res.user),
565
610
  };
566
611
  } catch (error) {
@@ -1,6 +1,16 @@
1
- import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, RNFileDescriptor } from '../models/interfaces';
1
+ import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, RNFileDescriptor, ServiceAssetMetadata } from '../models/interfaces';
2
2
  import type { OxyServicesBase } from '../OxyServices.base';
3
3
  import { isReactNative } from '@oxyhq/protocol';
4
+ import { logger } from '../utils/loggerUtils';
5
+ import { extractErrorStatus } from '../utils/errorUtils';
6
+
7
+ /**
8
+ * Maximum number of ids sent per `POST /assets/service/by-ids` request. Matches
9
+ * the server-side batch cap (the route rejects empty or > 100 id arrays with a
10
+ * 400); larger inputs are split into multiple chunked calls and merged. Mirrors
11
+ * `getUsersByIds`'s `USERS_BY_IDS_CHUNK_SIZE`.
12
+ */
13
+ const SERVICE_ASSET_METADATA_CHUNK_SIZE = 100;
4
14
 
5
15
  export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T) {
6
16
  return class extends Base {
@@ -8,6 +18,22 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
8
18
  super(...(args as [any]));
9
19
  }
10
20
 
21
+ /**
22
+ * Service-token request, implemented by the auth mixin earlier in the
23
+ * composition pipeline (see `mixins/index.ts`). The assets mixin is typed
24
+ * against `OxyServicesBase`, which does not carry the auth mixin's methods,
25
+ * so this `declare` surfaces the inherited runtime method to TypeScript
26
+ * without re-implementing it. Used by
27
+ * {@link getServiceAssetMetadataByIds} to authenticate the server-to-server
28
+ * `/assets/service/by-ids` bulk fetch with a bearer service token.
29
+ */
30
+ declare makeServiceRequest: <R = unknown>(
31
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
32
+ url: string,
33
+ data?: unknown,
34
+ userId?: string,
35
+ ) => Promise<R>;
36
+
11
37
  /**
12
38
  * Delete file
13
39
  */
@@ -167,6 +193,74 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
167
193
  return urls;
168
194
  }
169
195
 
196
+ /**
197
+ * Resolve many Oxy asset ids to their content-addressed metadata in one
198
+ * round-trip per chunk via `POST /assets/service/by-ids` (body `{ ids }`).
199
+ *
200
+ * Returns each asset's `sha256`, `mime`, byte `size`, and `status` — built
201
+ * for server-to-server callers (e.g. Mention's MTN Protocol blob-ref
202
+ * resolution) that need the content hash for an asset id. Ids are
203
+ * deduplicated and validated (empty/blank ids dropped) before being split
204
+ * into chunks of {@link SERVICE_ASSET_METADATA_CHUNK_SIZE} (the server-side
205
+ * cap). The server omits unknown/deleted ids from each chunk's `data`, so
206
+ * the merged result may be shorter than the requested id list and the caller
207
+ * is expected to map by `id`.
208
+ *
209
+ * **Service-token auth (required).** `/assets/service/by-ids` is guarded by
210
+ * `serviceAuthMiddleware` + the `files:read` scope and is called via
211
+ * `makeServiceRequest`, which attaches `Authorization: Bearer <serviceToken>`
212
+ * (the same client that calls `POST /assets/service/cache`). The calling
213
+ * client MUST be service-configured (`configureServiceAuth(apiKey,
214
+ * apiSecret)`) before invoking this method; otherwise `getServiceToken()`
215
+ * throws because no credentials are available. A plain user-session request
216
+ * is rejected by the route's service-auth guard.
217
+ *
218
+ * Resilience: chunks are independent. A failed chunk is logged and skipped —
219
+ * the method returns every entry that resolved successfully rather than
220
+ * discarding the whole call on one chunk's failure. An empty/whitespace-only
221
+ * input resolves immediately with `[]` and performs no network call.
222
+ *
223
+ * Not cached at the SDK layer: it's a POST keyed on a multi-id body (low hit
224
+ * rate), mirroring the sibling service/POST methods which never cache.
225
+ */
226
+ async getServiceAssetMetadataByIds(ids: string[]): Promise<ServiceAssetMetadata[]> {
227
+ const uniqueIds = Array.from(
228
+ new Set(ids.filter((id): id is string => typeof id === 'string' && id.trim().length > 0)),
229
+ );
230
+ if (uniqueIds.length === 0) {
231
+ return [];
232
+ }
233
+
234
+ const chunks: string[][] = [];
235
+ for (let i = 0; i < uniqueIds.length; i += SERVICE_ASSET_METADATA_CHUNK_SIZE) {
236
+ chunks.push(uniqueIds.slice(i, i + SERVICE_ASSET_METADATA_CHUNK_SIZE));
237
+ }
238
+
239
+ // Run chunks concurrently; a single chunk failure must not sink the rest.
240
+ const settled = await Promise.all(
241
+ chunks.map(async (chunk): Promise<ServiceAssetMetadata[]> => {
242
+ try {
243
+ const entries = await this.makeServiceRequest<ServiceAssetMetadata[]>(
244
+ 'POST',
245
+ '/assets/service/by-ids',
246
+ { ids: chunk },
247
+ );
248
+ return Array.isArray(entries) ? entries : [];
249
+ } catch (error: unknown) {
250
+ logger.warn('getServiceAssetMetadataByIds: chunk failed, continuing with remaining chunks', {
251
+ method: 'getServiceAssetMetadataByIds',
252
+ chunkSize: chunk.length,
253
+ status: extractErrorStatus(error),
254
+ error: error instanceof Error ? error.message : String(error),
255
+ });
256
+ return [];
257
+ }
258
+ }),
259
+ );
260
+
261
+ return settled.flat();
262
+ }
263
+
170
264
  /**
171
265
  * Upload raw file data
172
266
  */
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `getServiceAssetMetadataByIds` mixin tests.
3
+ *
4
+ * Stubs `makeServiceRequest` (the service-token transport used by the route's
5
+ * `serviceAuthMiddleware` + `files:read` scope) so the tests run with no network
6
+ * and no `getServiceToken()` round-trip, then asserts:
7
+ * - empty / whitespace-only input no-ops to `[]` with no network call;
8
+ * - input is de-duplicated and a single chunk is sent for <= 100 unique ids,
9
+ * POSTed to `/assets/service/by-ids` as `{ ids }`;
10
+ * - the `{ data }` envelope is unwrapped to a bare `ServiceAssetMetadata[]`
11
+ * (mirroring how `makeServiceRequest<T[]>` returns the inner array);
12
+ * - inputs > 100 ids are chunked at 100/request and merged;
13
+ * - a failed chunk is logged and skipped — successful chunks still return.
14
+ */
15
+
16
+ import type { ServiceAssetMetadata } from '../../models/interfaces';
17
+ import { OxyServices } from '../../OxyServices';
18
+
19
+ const sampleEntry: ServiceAssetMetadata = {
20
+ id: 'asset-1',
21
+ sha256: 'a'.repeat(64),
22
+ mime: 'image/jpeg',
23
+ size: 12345,
24
+ status: 'active',
25
+ };
26
+
27
+ describe('OxyServices.assets — getServiceAssetMetadataByIds', () => {
28
+ let oxy: OxyServices;
29
+ let makeServiceRequestSpy: jest.SpyInstance;
30
+
31
+ beforeEach(() => {
32
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
33
+ makeServiceRequestSpy = jest.spyOn(oxy, 'makeServiceRequest');
34
+ });
35
+
36
+ afterEach(() => {
37
+ jest.restoreAllMocks();
38
+ });
39
+
40
+ it('returns [] and performs no network call for empty / whitespace input', async () => {
41
+ await expect(oxy.getServiceAssetMetadataByIds([])).resolves.toEqual([]);
42
+ await expect(oxy.getServiceAssetMetadataByIds(['', ' '])).resolves.toEqual([]);
43
+ expect(makeServiceRequestSpy).not.toHaveBeenCalled();
44
+ });
45
+
46
+ it('de-duplicates and sends a single chunk for <= 100 unique ids', async () => {
47
+ // makeServiceRequest unwraps the API's `{ data }` envelope, so the resolved
48
+ // value is the bare array (NOT `{ data: [...] }`) — mirror that real shape.
49
+ makeServiceRequestSpy.mockResolvedValueOnce([sampleEntry]);
50
+
51
+ const result = await oxy.getServiceAssetMetadataByIds([
52
+ 'asset-1',
53
+ 'asset-1', // duplicate
54
+ ' ', // dropped
55
+ ]);
56
+
57
+ expect(result).toEqual([sampleEntry]);
58
+ expect(makeServiceRequestSpy).toHaveBeenCalledTimes(1);
59
+ expect(makeServiceRequestSpy).toHaveBeenCalledWith(
60
+ 'POST',
61
+ '/assets/service/by-ids',
62
+ { ids: ['asset-1'] },
63
+ );
64
+ });
65
+
66
+ it('chunks at 100 ids per request and merges each chunk', async () => {
67
+ const ids = Array.from({ length: 250 }, (_, i) => `asset-${i}`);
68
+
69
+ makeServiceRequestSpy.mockImplementation(
70
+ async (
71
+ _method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
72
+ _url: string,
73
+ data?: { ids: string[] },
74
+ ): Promise<ServiceAssetMetadata[]> =>
75
+ (data?.ids ?? []).map((id) => ({
76
+ id,
77
+ sha256: 'b'.repeat(64),
78
+ mime: 'application/octet-stream',
79
+ size: 1,
80
+ status: 'active' as const,
81
+ })),
82
+ );
83
+
84
+ const result = await oxy.getServiceAssetMetadataByIds(ids);
85
+
86
+ // 250 unique ids => 100 + 100 + 50 across three POSTs.
87
+ expect(makeServiceRequestSpy).toHaveBeenCalledTimes(3);
88
+ const chunkSizes = makeServiceRequestSpy.mock.calls.map(
89
+ (call) => (call[2] as { ids: string[] }).ids.length,
90
+ );
91
+ expect(chunkSizes).toEqual([100, 100, 50]);
92
+
93
+ expect(result).toHaveLength(250);
94
+ expect(result[0]).toEqual({
95
+ id: 'asset-0',
96
+ sha256: 'b'.repeat(64),
97
+ mime: 'application/octet-stream',
98
+ size: 1,
99
+ status: 'active',
100
+ });
101
+ expect(result[249]?.id).toBe('asset-249');
102
+ });
103
+
104
+ it('skips a failed chunk and returns the entries that resolved', async () => {
105
+ const ids = Array.from({ length: 150 }, (_, i) => `asset-${i}`);
106
+
107
+ makeServiceRequestSpy
108
+ .mockResolvedValueOnce([sampleEntry]) // first chunk (100 ids) succeeds
109
+ .mockRejectedValueOnce(new Error('chunk failed')); // second chunk (50 ids) fails
110
+
111
+ const result = await oxy.getServiceAssetMetadataByIds(ids);
112
+
113
+ expect(makeServiceRequestSpy).toHaveBeenCalledTimes(2);
114
+ expect(result).toEqual([sampleEntry]);
115
+ });
116
+ });
@@ -176,57 +176,100 @@ describe('OxyServices.accounts', () => {
176
176
  });
177
177
 
178
178
  describe('switchToAccount', () => {
179
+ // The switch route NO LONGER returns `authuser` or sets the device cookie —
180
+ // it can't (it's at /accounts/*, outside the Path=/auth cookie scope). The SDK
181
+ // establishes the cookie + resolves the slot via a follow-up POST /auth/session.
179
182
  const switchResponse: SwitchAccountResult = {
180
183
  sessionId: 'sess_switch',
181
184
  deviceId: 'dev_switch',
182
185
  expiresAt: '2026-06-30T01:00:00.000Z',
183
186
  accessToken: 'access_switch',
184
187
  user: { id: 'acc1', username: 'oxy-org', name: { displayName: 'Oxy Org' } },
185
- authuser: 2,
186
188
  };
189
+ // POST /auth/session establishes the cookie in a correctly-allocated NEW slot
190
+ // and returns that slot + a fresh access token off the same session.
191
+ const sessionResponse = { accessToken: 'access_session', authuser: 1 };
192
+
193
+ // Route makeRequest by path: the switch call vs the /auth/session establishment.
194
+ const routeByPath = (response = switchResponse) =>
195
+ makeRequestSpy.mockImplementation((_method: string, path: string) =>
196
+ Promise.resolve(path === '/auth/session' ? sessionResponse : response),
197
+ );
187
198
 
188
- it('posts to /:id/switch (no body, no cache), plants the token, sweeps the cache, and returns the session', async () => {
199
+ it('posts to /:id/switch then establishes the cookie via POST /auth/session, planting both tokens and sweeping the cache', async () => {
189
200
  const setTokensSpy = jest.spyOn(oxy, 'setTokens');
190
201
  const clearCacheSpy = jest.spyOn(oxy, 'clearCache');
191
- makeRequestSpy.mockResolvedValue(switchResponse);
202
+ routeByPath();
192
203
 
193
204
  const result = await oxy.switchToAccount('acc1');
194
205
 
195
- // Request shape: POST, exact path, no body, cache disabled.
206
+ // Switch request shape: POST, exact path, no body, cache disabled.
196
207
  expect(makeRequestSpy).toHaveBeenCalledWith(
197
208
  'POST',
198
209
  '/accounts/acc1/switch',
199
210
  undefined,
200
211
  expect.objectContaining({ cache: false }),
201
212
  );
213
+ // Then the canonical refresh-cookie establishment under /auth (where the
214
+ // device's oxy_rt_* slots ARE visible, so a NEW slot is allocated).
215
+ expect(makeRequestSpy).toHaveBeenCalledWith(
216
+ 'POST',
217
+ '/auth/session',
218
+ undefined,
219
+ expect.objectContaining({ cache: false }),
220
+ );
202
221
 
203
- // Session planting: the access token from the body is installed as the
204
- // active token (mirrors claimSessionByToken / verifyChallenge).
205
- expect(setTokensSpy).toHaveBeenCalledWith('access_switch');
206
- expect(oxy.getAccessToken()).toBe('access_switch');
222
+ // The switch token is planted first; /auth/session's fresh token re-planted.
223
+ expect(setTokensSpy).toHaveBeenNthCalledWith(1, 'access_switch');
224
+ expect(setTokensSpy).toHaveBeenNthCalledWith(2, 'access_session');
225
+ expect(oxy.getAccessToken()).toBe('access_session');
207
226
  expect(oxy.hasValidToken()).toBe(true);
208
227
 
209
- // Identity changed the whole GET cache is swept so reads refetch as the
210
- // new account, AND it happens AFTER the token is planted.
228
+ // Cache swept once, AFTER both tokens are planted.
211
229
  expect(clearCacheSpy).toHaveBeenCalledTimes(1);
212
- expect(setTokensSpy.mock.invocationCallOrder[0]).toBeLessThan(
230
+ expect(setTokensSpy.mock.invocationCallOrder[1]).toBeLessThan(
213
231
  clearCacheSpy.mock.invocationCallOrder[0],
214
232
  );
215
233
 
216
- // The returned session carries the target account (id-normalised) + authuser.
217
- expect(result).toEqual({ ...switchResponse, user: { id: 'acc1', username: 'oxy-org', name: { displayName: 'Oxy Org' } } });
218
- expect(result.authuser).toBe(2);
234
+ // The returned session carries the target account (id-normalised) and the
235
+ // slot resolved by /auth/session NEVER the clobbering slot 0.
236
+ expect(result.sessionId).toBe('sess_switch');
237
+ expect(result.user).toEqual({ id: 'acc1', username: 'oxy-org', name: { displayName: 'Oxy Org' } });
238
+ expect(result.authuser).toBe(1);
219
239
 
220
240
  setTokensSpy.mockRestore();
221
241
  clearCacheSpy.mockRestore();
222
242
  });
223
243
 
224
244
  it('URL-encodes the accountId path segment', async () => {
225
- makeRequestSpy.mockResolvedValue(switchResponse);
245
+ routeByPath();
226
246
  await oxy.switchToAccount('a b/c');
227
247
  expect(makeRequestSpy.mock.calls[0][1]).toBe('/accounts/a%20b%2Fc/switch');
228
248
  });
229
249
 
250
+ it('keeps the switch active in-session when /auth/session fails (best-effort cookie)', async () => {
251
+ const setTokensSpy = jest.spyOn(oxy, 'setTokens');
252
+ const clearCacheSpy = jest.spyOn(oxy, 'clearCache');
253
+ makeRequestSpy.mockImplementation((_method: string, path: string) =>
254
+ path === '/auth/session'
255
+ ? Promise.reject(Object.assign(new Error('origin'), { response: { status: 403 } }))
256
+ : Promise.resolve(switchResponse),
257
+ );
258
+
259
+ const result = await oxy.switchToAccount('acc1');
260
+
261
+ // The in-session switch survives: the switch token stays planted and the
262
+ // cache is still swept. The switched account just won't survive a reload
263
+ // until the cookie is next established (no authuser resolved).
264
+ expect(setTokensSpy).toHaveBeenCalledWith('access_switch');
265
+ expect(oxy.getAccessToken()).toBe('access_switch');
266
+ expect(clearCacheSpy).toHaveBeenCalledTimes(1);
267
+ expect(result.authuser).toBeUndefined();
268
+
269
+ setTokensSpy.mockRestore();
270
+ clearCacheSpy.mockRestore();
271
+ });
272
+
230
273
  it('does NOT plant or sweep when the operator is not authorized (403 surfaces via handleError)', async () => {
231
274
  const setTokensSpy = jest.spyOn(oxy, 'setTokens');
232
275
  const clearCacheSpy = jest.spyOn(oxy, 'clearCache');
@@ -235,9 +278,16 @@ describe('OxyServices.accounts', () => {
235
278
  );
236
279
 
237
280
  await expect(oxy.switchToAccount('acc1')).rejects.toThrow();
238
- // A failed switch must NOT mutate session state.
281
+ // A failed switch must NOT mutate session state, and must NOT attempt the
282
+ // /auth/session establishment.
239
283
  expect(setTokensSpy).not.toHaveBeenCalled();
240
284
  expect(clearCacheSpy).not.toHaveBeenCalled();
285
+ expect(makeRequestSpy).not.toHaveBeenCalledWith(
286
+ 'POST',
287
+ '/auth/session',
288
+ undefined,
289
+ expect.anything(),
290
+ );
241
291
 
242
292
  setTokensSpy.mockRestore();
243
293
  clearCacheSpy.mockRestore();
@@ -514,6 +514,25 @@ export interface AssetUpdateVisibilityResponse {
514
514
  };
515
515
  }
516
516
 
517
+ /**
518
+ * Minimal, service-token-scoped asset metadata returned by
519
+ * `POST /assets/service/by-ids`.
520
+ *
521
+ * Resolves an Oxy asset `id` to its content-addressed identity (`sha256`),
522
+ * MIME type, byte `size`, and storage `status`. Used by server-to-server
523
+ * callers (e.g. Mention's MTN Protocol blob-ref resolution) that hold a
524
+ * `files:read`-scoped service token rather than a user session. Unknown or
525
+ * deleted ids are omitted from the response (never error the whole batch),
526
+ * so the result may be shorter than the requested id list.
527
+ */
528
+ export interface ServiceAssetMetadata {
529
+ id: string;
530
+ sha256: string;
531
+ mime: string;
532
+ size: number;
533
+ status: 'active' | 'trash';
534
+ }
535
+
517
536
  /**
518
537
  * Account storage usage (server-side usage, not local AsyncStorage)
519
538
  */