@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.
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OxyServicesAccountsMixin = OxyServicesAccountsMixin;
4
4
  const userIdentity_1 = require("../utils/userIdentity");
5
+ const platform_1 = require("../utils/platform");
6
+ const loggerUtils_1 = require("../utils/loggerUtils");
5
7
  const mixinHelpers_1 = require("./mixinHelpers");
6
8
  function OxyServicesAccountsMixin(Base) {
7
9
  return class extends Base {
@@ -57,10 +59,19 @@ function OxyServicesAccountsMixin(Base) {
57
59
  * Unlike the removed `X-Acting-As` delegation header, the returned session
58
60
  * IS the new identity: this plants `accessToken` as the active token —
59
61
  * exactly like `claimSessionByToken` / `verifyChallenge` — so every
60
- * subsequent request authenticates as the target account. The refresh token
61
- * is the server-set httpOnly `oxy_rt_<authuser>` cookie (never in the body),
62
- * so it joins the device multi-account set and survives reload /
63
- * `refresh-all` with no extra client work.
62
+ * subsequent request authenticates as the target account.
63
+ *
64
+ * Joining the device multi-account set (so the switch survives a reload and
65
+ * propagates cross-domain via `/auth/refresh-all`) requires a SECOND call, to
66
+ * `POST /auth/session`, made here after the token is planted. The switch route
67
+ * lives at `/accounts/*`, OUTSIDE the `oxy_rt_<authuser>` cookie's `Path=/auth`
68
+ * scope, so the server never sees the device's existing slots from it and
69
+ * would clobber slot 0 (destroying the operator's own session). `/auth/session`
70
+ * runs where those cookies ARE visible, so the server allocates a NEW slot that
71
+ * coexists with the operator's and returns its `authuser`. This step is
72
+ * web-only (native multi-account uses stored sessions, not cookies) and
73
+ * best-effort — a failure leaves the in-session switch intact; the switched
74
+ * account simply won't survive a reload until the cookie is next established.
64
75
  *
65
76
  * After planting, the SDK's identity-scoped GET cache is fully cleared so
66
77
  * every cached read re-fetches as the new account. (The consuming
@@ -77,11 +88,35 @@ function OxyServicesAccountsMixin(Base) {
77
88
  const res = await this.makeRequest('POST', `/accounts/${encodeURIComponent(accountId)}/switch`, undefined, { cache: false });
78
89
  // Plant the freshly minted session as the ACTIVE session, mirroring
79
90
  // `claimSessionByToken` / `verifyChallenge`: the response body carries
80
- // the first access token; the refresh token is the server-set httpOnly
81
- // cookie, so there is nothing else to store here.
91
+ // the first access token. The device refresh cookie is established below.
82
92
  if (res?.accessToken) {
83
93
  this.setTokens(res.accessToken);
84
94
  }
95
+ // Register the switched session in the device's multi-account set by
96
+ // establishing its first-party refresh cookie. This MUST be a separate
97
+ // call to `POST /auth/session`: the switch route is at `/accounts/*`,
98
+ // outside the `oxy_rt_<authuser>` cookie's `Path=/auth` scope, so it can
99
+ // never read the device's existing slots and would overwrite slot 0
100
+ // (destroying the operator's own session). `/auth/session` runs where the
101
+ // cookies ARE visible, so the server allocates a NEW slot that coexists
102
+ // with the operator's and returns its `authuser`. Web-only; best-effort.
103
+ let authuser = res.authuser;
104
+ if ((0, platform_1.isWeb)()) {
105
+ try {
106
+ const established = await this.makeRequest('POST', '/auth/session', undefined, { cache: false });
107
+ if (typeof established?.authuser === 'number') {
108
+ authuser = established.authuser;
109
+ }
110
+ // /auth/session mints a fresh access token off the same session;
111
+ // re-plant it so the active token matches the rotated cookie.
112
+ if (established?.accessToken) {
113
+ this.setTokens(established.accessToken);
114
+ }
115
+ }
116
+ catch (error) {
117
+ loggerUtils_1.logger.warn('[OxyServices] Failed to establish device refresh cookie after account switch; the switch is active in-session but may not survive a reload', { component: 'OxyServices', method: 'switchToAccount' }, error);
118
+ }
119
+ }
85
120
  // Identity changed → drop the entire GET response cache so no entry
86
121
  // personalised for the previous identity is reused. Cache keys are
87
122
  // identity-scoped, so a different identity could not READ the old
@@ -90,6 +125,7 @@ function OxyServicesAccountsMixin(Base) {
90
125
  this.clearCache();
91
126
  return {
92
127
  ...res,
128
+ ...(typeof authuser === 'number' ? { authuser } : {}),
93
129
  user: (0, userIdentity_1.normalizeUserIdentity)(res.user),
94
130
  };
95
131
  }
@@ -2,6 +2,15 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OxyServicesAssetsMixin = OxyServicesAssetsMixin;
4
4
  const protocol_1 = require("@oxyhq/protocol");
5
+ const loggerUtils_1 = require("../utils/loggerUtils");
6
+ const errorUtils_1 = require("../utils/errorUtils");
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;
5
14
  function OxyServicesAssetsMixin(Base) {
6
15
  return class extends Base {
7
16
  constructor(...args) {
@@ -147,6 +156,63 @@ function OxyServicesAssetsMixin(Base) {
147
156
  }
148
157
  return urls;
149
158
  }
159
+ /**
160
+ * Resolve many Oxy asset ids to their content-addressed metadata in one
161
+ * round-trip per chunk via `POST /assets/service/by-ids` (body `{ ids }`).
162
+ *
163
+ * Returns each asset's `sha256`, `mime`, byte `size`, and `status` — built
164
+ * for server-to-server callers (e.g. Mention's MTN Protocol blob-ref
165
+ * resolution) that need the content hash for an asset id. Ids are
166
+ * deduplicated and validated (empty/blank ids dropped) before being split
167
+ * into chunks of {@link SERVICE_ASSET_METADATA_CHUNK_SIZE} (the server-side
168
+ * cap). The server omits unknown/deleted ids from each chunk's `data`, so
169
+ * the merged result may be shorter than the requested id list and the caller
170
+ * is expected to map by `id`.
171
+ *
172
+ * **Service-token auth (required).** `/assets/service/by-ids` is guarded by
173
+ * `serviceAuthMiddleware` + the `files:read` scope and is called via
174
+ * `makeServiceRequest`, which attaches `Authorization: Bearer <serviceToken>`
175
+ * (the same client that calls `POST /assets/service/cache`). The calling
176
+ * client MUST be service-configured (`configureServiceAuth(apiKey,
177
+ * apiSecret)`) before invoking this method; otherwise `getServiceToken()`
178
+ * throws because no credentials are available. A plain user-session request
179
+ * is rejected by the route's service-auth guard.
180
+ *
181
+ * Resilience: chunks are independent. A failed chunk is logged and skipped —
182
+ * the method returns every entry that resolved successfully rather than
183
+ * discarding the whole call on one chunk's failure. An empty/whitespace-only
184
+ * input resolves immediately with `[]` and performs no network call.
185
+ *
186
+ * Not cached at the SDK layer: it's a POST keyed on a multi-id body (low hit
187
+ * rate), mirroring the sibling service/POST methods which never cache.
188
+ */
189
+ async getServiceAssetMetadataByIds(ids) {
190
+ const uniqueIds = Array.from(new Set(ids.filter((id) => typeof id === 'string' && id.trim().length > 0)));
191
+ if (uniqueIds.length === 0) {
192
+ return [];
193
+ }
194
+ const chunks = [];
195
+ for (let i = 0; i < uniqueIds.length; i += SERVICE_ASSET_METADATA_CHUNK_SIZE) {
196
+ chunks.push(uniqueIds.slice(i, i + SERVICE_ASSET_METADATA_CHUNK_SIZE));
197
+ }
198
+ // Run chunks concurrently; a single chunk failure must not sink the rest.
199
+ const settled = await Promise.all(chunks.map(async (chunk) => {
200
+ try {
201
+ const entries = await this.makeServiceRequest('POST', '/assets/service/by-ids', { ids: chunk });
202
+ return Array.isArray(entries) ? entries : [];
203
+ }
204
+ catch (error) {
205
+ loggerUtils_1.logger.warn('getServiceAssetMetadataByIds: chunk failed, continuing with remaining chunks', {
206
+ method: 'getServiceAssetMetadataByIds',
207
+ chunkSize: chunk.length,
208
+ status: (0, errorUtils_1.extractErrorStatus)(error),
209
+ error: error instanceof Error ? error.message : String(error),
210
+ });
211
+ return [];
212
+ }
213
+ }));
214
+ return settled.flat();
215
+ }
150
216
  /**
151
217
  * Upload raw file data
152
218
  */
@@ -233,16 +233,28 @@ function OxyServicesUserMixin(Base) {
233
233
  * by `id`); each is run through `normalizeUserIdentity`, matching
234
234
  * `getUserById`.
235
235
  *
236
- * **Service-token auth (required).** `/users/by-ids` is a server-to-server
237
- * bulk fetch of PUBLIC user data and is called via `makeServiceRequest`,
238
- * which attaches `Authorization: Bearer <serviceToken>`. oxy-api's CSRF
239
- * middleware skips bearer-authenticated requests, so the calling client
240
- * MUST be service-configured (`configureServiceAuth(apiKey, apiSecret)`)
241
- * before invoking this method; otherwise `getServiceToken()` throws because
242
- * no credentials are available. (A plain user-session request fails here:
243
- * server-to-server there is no cookie jar, so the auto-attached
244
- * `X-CSRF-Token` has no matching cookie and oxy-api rejects the POST with
245
- * 403 "CSRF token missing".)
236
+ * **Dual-mode auth.** `/users/by-ids` is `optionalUserOrServiceAuth` on
237
+ * oxy-api: it accepts a service token, a user session, or an anonymous
238
+ * caller, and returns the SAME public `{ data: PublicUserProfile[] }`
239
+ * payload (canonical `name.displayName` + `_count`) in every case — no
240
+ * viewer-specific fields. This method picks the path automatically:
241
+ * - **Service-configured host (backend):** when `configureServiceAuth(apiKey,
242
+ * apiSecret)` has been called, the chunk is fetched via `makeServiceRequest`
243
+ * (attaches `Authorization: Bearer <serviceToken>`). This is the
244
+ * server-to-server feed/notification hydration path (e.g. Mention's
245
+ * `PostHydrationService`) and is unchanged.
246
+ * - **Plain client (browser / React Native with a user session):** when no
247
+ * service credentials are configured, the chunk is fetched via
248
+ * `makeRequest`, which attaches the configured user bearer. oxy-api's CSRF
249
+ * middleware skips bearer-authenticated writes, and `makeRequest` only
250
+ * fetches a CSRF token for cookie-only (no-bearer) state-changing requests,
251
+ * so the user-bearer POST is sent without CSRF and succeeds. Previously
252
+ * this method always used the service path, so every client-side caller
253
+ * silently received `[]` because `getServiceToken()` had no credentials.
254
+ *
255
+ * Both paths run results through `normalizeUserIdentity` and unwrap the
256
+ * API's `{ data }` envelope identically (`makeServiceRequest` is literally
257
+ * `makeRequest` plus a bearer service header).
246
258
  *
247
259
  * Resilience: chunks are independent. A failed chunk is logged and skipped
248
260
  * — the method returns every user that resolved successfully rather than
@@ -261,15 +273,22 @@ function OxyServicesUserMixin(Base) {
261
273
  for (let i = 0; i < uniqueIds.length; i += USERS_BY_IDS_CHUNK_SIZE) {
262
274
  chunks.push(uniqueIds.slice(i, i + USERS_BY_IDS_CHUNK_SIZE));
263
275
  }
276
+ // A backend that called configureServiceAuth() uses the bearer-service
277
+ // path; any other caller (browser / RN with a user session) uses the
278
+ // user-bearer path. See the method doc for why the user path is CSRF-safe.
279
+ const useServiceAuth = Boolean(this._serviceApiKey && this._serviceApiSecret);
264
280
  // Run chunks concurrently; a single chunk failure must not sink the rest.
265
281
  const settled = await Promise.all(chunks.map(async (chunk) => {
266
282
  try {
267
- const users = await this.makeServiceRequest('POST', '/users/by-ids', { ids: chunk });
283
+ const users = useServiceAuth
284
+ ? await this.makeServiceRequest('POST', '/users/by-ids', { ids: chunk })
285
+ : await this.makeRequest('POST', '/users/by-ids', { ids: chunk }, { cache: false });
268
286
  return Array.isArray(users) ? users.map((user) => (0, userIdentity_1.normalizeUserIdentity)(user)) : [];
269
287
  }
270
288
  catch (error) {
271
289
  loggerUtils_1.logger.warn('getUsersByIds: chunk failed, continuing with remaining chunks', {
272
290
  method: 'getUsersByIds',
291
+ mode: useServiceAuth ? 'service' : 'user',
273
292
  chunkSize: chunk.length,
274
293
  status: (0, errorUtils_1.extractErrorStatus)(error),
275
294
  error: error instanceof Error ? error.message : String(error),