@oxyhq/core 5.0.0 → 5.1.1

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
  */
@@ -48,6 +48,17 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
48
48
  * server-to-server `/users/by-ids` bulk fetch with a bearer service token.
49
49
  */
50
50
  makeServiceRequest: <R = unknown>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: unknown, userId?: string) => Promise<R>;
51
+ /**
52
+ * Raw service credentials stored by `configureServiceAuth()` on the auth
53
+ * mixin (earlier in the pipeline). Surfaced here via `declare` — for the
54
+ * same typing reason as `makeServiceRequest` above — so `getUsersByIds` can
55
+ * detect whether this instance is service-configured (a backend) and pick
56
+ * the bearer-service path, or fall back to the user-session path (a browser/
57
+ * RN client). Both are `null` until `configureServiceAuth(apiKey, apiSecret)`
58
+ * is called.
59
+ */
60
+ _serviceApiKey: string | null;
61
+ _serviceApiSecret: string | null;
51
62
  /**
52
63
  * Get profile by username
53
64
  */
@@ -138,16 +149,28 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
138
149
  * by `id`); each is run through `normalizeUserIdentity`, matching
139
150
  * `getUserById`.
140
151
  *
141
- * **Service-token auth (required).** `/users/by-ids` is a server-to-server
142
- * bulk fetch of PUBLIC user data and is called via `makeServiceRequest`,
143
- * which attaches `Authorization: Bearer <serviceToken>`. oxy-api's CSRF
144
- * middleware skips bearer-authenticated requests, so the calling client
145
- * MUST be service-configured (`configureServiceAuth(apiKey, apiSecret)`)
146
- * before invoking this method; otherwise `getServiceToken()` throws because
147
- * no credentials are available. (A plain user-session request fails here:
148
- * server-to-server there is no cookie jar, so the auto-attached
149
- * `X-CSRF-Token` has no matching cookie and oxy-api rejects the POST with
150
- * 403 "CSRF token missing".)
152
+ * **Dual-mode auth.** `/users/by-ids` is `optionalUserOrServiceAuth` on
153
+ * oxy-api: it accepts a service token, a user session, or an anonymous
154
+ * caller, and returns the SAME public `{ data: PublicUserProfile[] }`
155
+ * payload (canonical `name.displayName` + `_count`) in every case — no
156
+ * viewer-specific fields. This method picks the path automatically:
157
+ * - **Service-configured host (backend):** when `configureServiceAuth(apiKey,
158
+ * apiSecret)` has been called, the chunk is fetched via `makeServiceRequest`
159
+ * (attaches `Authorization: Bearer <serviceToken>`). This is the
160
+ * server-to-server feed/notification hydration path (e.g. Mention's
161
+ * `PostHydrationService`) and is unchanged.
162
+ * - **Plain client (browser / React Native with a user session):** when no
163
+ * service credentials are configured, the chunk is fetched via
164
+ * `makeRequest`, which attaches the configured user bearer. oxy-api's CSRF
165
+ * middleware skips bearer-authenticated writes, and `makeRequest` only
166
+ * fetches a CSRF token for cookie-only (no-bearer) state-changing requests,
167
+ * so the user-bearer POST is sent without CSRF and succeeds. Previously
168
+ * this method always used the service path, so every client-side caller
169
+ * silently received `[]` because `getServiceToken()` had no credentials.
170
+ *
171
+ * Both paths run results through `normalizeUserIdentity` and unwrap the
172
+ * API's `{ data }` envelope identically (`makeServiceRequest` is literally
173
+ * `makeRequest` plus a bearer service header).
151
174
  *
152
175
  * Resilience: chunks are independent. A failed chunk is logged and skipped
153
176
  * — the method returns every user that resolved successfully rather than
@@ -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.1",
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
  */
@@ -88,6 +88,18 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
88
88
  userId?: string,
89
89
  ) => Promise<R>;
90
90
 
91
+ /**
92
+ * Raw service credentials stored by `configureServiceAuth()` on the auth
93
+ * mixin (earlier in the pipeline). Surfaced here via `declare` — for the
94
+ * same typing reason as `makeServiceRequest` above — so `getUsersByIds` can
95
+ * detect whether this instance is service-configured (a backend) and pick
96
+ * the bearer-service path, or fall back to the user-session path (a browser/
97
+ * RN client). Both are `null` until `configureServiceAuth(apiKey, apiSecret)`
98
+ * is called.
99
+ */
100
+ declare _serviceApiKey: string | null;
101
+ declare _serviceApiSecret: string | null;
102
+
91
103
  /**
92
104
  * Get profile by username
93
105
  */
@@ -349,16 +361,28 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
349
361
  * by `id`); each is run through `normalizeUserIdentity`, matching
350
362
  * `getUserById`.
351
363
  *
352
- * **Service-token auth (required).** `/users/by-ids` is a server-to-server
353
- * bulk fetch of PUBLIC user data and is called via `makeServiceRequest`,
354
- * which attaches `Authorization: Bearer <serviceToken>`. oxy-api's CSRF
355
- * middleware skips bearer-authenticated requests, so the calling client
356
- * MUST be service-configured (`configureServiceAuth(apiKey, apiSecret)`)
357
- * before invoking this method; otherwise `getServiceToken()` throws because
358
- * no credentials are available. (A plain user-session request fails here:
359
- * server-to-server there is no cookie jar, so the auto-attached
360
- * `X-CSRF-Token` has no matching cookie and oxy-api rejects the POST with
361
- * 403 "CSRF token missing".)
364
+ * **Dual-mode auth.** `/users/by-ids` is `optionalUserOrServiceAuth` on
365
+ * oxy-api: it accepts a service token, a user session, or an anonymous
366
+ * caller, and returns the SAME public `{ data: PublicUserProfile[] }`
367
+ * payload (canonical `name.displayName` + `_count`) in every case — no
368
+ * viewer-specific fields. This method picks the path automatically:
369
+ * - **Service-configured host (backend):** when `configureServiceAuth(apiKey,
370
+ * apiSecret)` has been called, the chunk is fetched via `makeServiceRequest`
371
+ * (attaches `Authorization: Bearer <serviceToken>`). This is the
372
+ * server-to-server feed/notification hydration path (e.g. Mention's
373
+ * `PostHydrationService`) and is unchanged.
374
+ * - **Plain client (browser / React Native with a user session):** when no
375
+ * service credentials are configured, the chunk is fetched via
376
+ * `makeRequest`, which attaches the configured user bearer. oxy-api's CSRF
377
+ * middleware skips bearer-authenticated writes, and `makeRequest` only
378
+ * fetches a CSRF token for cookie-only (no-bearer) state-changing requests,
379
+ * so the user-bearer POST is sent without CSRF and succeeds. Previously
380
+ * this method always used the service path, so every client-side caller
381
+ * silently received `[]` because `getServiceToken()` had no credentials.
382
+ *
383
+ * Both paths run results through `normalizeUserIdentity` and unwrap the
384
+ * API's `{ data }` envelope identically (`makeServiceRequest` is literally
385
+ * `makeRequest` plus a bearer service header).
362
386
  *
363
387
  * Resilience: chunks are independent. A failed chunk is logged and skipped
364
388
  * — the method returns every user that resolved successfully rather than
@@ -381,15 +405,23 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
381
405
  chunks.push(uniqueIds.slice(i, i + USERS_BY_IDS_CHUNK_SIZE));
382
406
  }
383
407
 
408
+ // A backend that called configureServiceAuth() uses the bearer-service
409
+ // path; any other caller (browser / RN with a user session) uses the
410
+ // user-bearer path. See the method doc for why the user path is CSRF-safe.
411
+ const useServiceAuth = Boolean(this._serviceApiKey && this._serviceApiSecret);
412
+
384
413
  // Run chunks concurrently; a single chunk failure must not sink the rest.
385
414
  const settled = await Promise.all(
386
415
  chunks.map(async (chunk): Promise<User[]> => {
387
416
  try {
388
- const users = await this.makeServiceRequest<User[]>('POST', '/users/by-ids', { ids: chunk });
417
+ const users = useServiceAuth
418
+ ? await this.makeServiceRequest<User[]>('POST', '/users/by-ids', { ids: chunk })
419
+ : await this.makeRequest<User[]>('POST', '/users/by-ids', { ids: chunk }, { cache: false });
389
420
  return Array.isArray(users) ? users.map((user) => normalizeUserIdentity(user)) : [];
390
421
  } catch (error: unknown) {
391
422
  logger.warn('getUsersByIds: chunk failed, continuing with remaining chunks', {
392
423
  method: 'getUsersByIds',
424
+ mode: useServiceAuth ? 'service' : 'user',
393
425
  chunkSize: chunk.length,
394
426
  status: extractErrorStatus(error),
395
427
  error: error instanceof Error ? error.message : String(error),
@@ -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
+ });