@oxyhq/core 3.6.0 → 3.7.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.
Files changed (32) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +102 -66
  3. package/dist/cjs/mixins/OxyServices.assets.js +34 -2
  4. package/dist/cjs/mixins/OxyServices.user.js +78 -14
  5. package/dist/cjs/utils/cacheKey.js +87 -0
  6. package/dist/cjs/utils/errorUtils.js +25 -0
  7. package/dist/esm/.tsbuildinfo +1 -1
  8. package/dist/esm/HttpService.js +101 -65
  9. package/dist/esm/mixins/OxyServices.assets.js +34 -2
  10. package/dist/esm/mixins/OxyServices.user.js +78 -14
  11. package/dist/esm/utils/cacheKey.js +82 -0
  12. package/dist/esm/utils/errorUtils.js +24 -0
  13. package/dist/types/.tsbuildinfo +1 -1
  14. package/dist/types/HttpService.d.ts +24 -16
  15. package/dist/types/mixins/OxyServices.assets.d.ts +24 -1
  16. package/dist/types/mixins/OxyServices.user.d.ts +21 -27
  17. package/dist/types/utils/cacheKey.d.ts +67 -0
  18. package/dist/types/utils/errorUtils.d.ts +12 -0
  19. package/package.json +2 -2
  20. package/src/HttpService.ts +116 -67
  21. package/src/__tests__/authManager.cookiePath.test.ts +2 -2
  22. package/src/__tests__/authManager.security.test.ts +2 -2
  23. package/src/__tests__/httpServiceCache.test.ts +71 -0
  24. package/src/mixins/OxyServices.assets.ts +36 -2
  25. package/src/mixins/OxyServices.user.ts +104 -32
  26. package/src/mixins/__tests__/discoveryErrorHandling.test.ts +266 -0
  27. package/src/mixins/__tests__/getFileDownloadUrl.test.ts +83 -0
  28. package/src/mixins/__tests__/sso.test.ts +13 -3
  29. package/src/utils/__tests__/cacheKey.test.ts +0 -0
  30. package/src/utils/__tests__/coldBoot.test.ts +125 -0
  31. package/src/utils/cacheKey.ts +98 -0
  32. package/src/utils/errorUtils.ts +25 -0
@@ -18,37 +18,67 @@ import { retryAsync } from './utils/asyncUtils.js';
18
18
  import { handleHttpError } from './utils/errorUtils.js';
19
19
  import { jwtDecode } from 'jwt-decode';
20
20
  import { isNative, isReactNative, getPlatformOS } from './utils/platform.js';
21
+ import { computeIdentityTag, fnv1a32 } from './utils/cacheKey.js';
21
22
  /**
22
23
  * Check if we're running in a native app environment (React Native, not web)
23
24
  * This is used to determine CSRF handling mode
24
25
  */
25
26
  const isNativeApp = isNative();
26
27
  /**
27
- * FNV-1a 32-bit non-cryptographic hash.
28
- *
29
- * Used by the cache-key generator for large payloads where full JSON
30
- * inclusion would balloon the cache map keys. Content-addressed: every
31
- * byte of the input contributes to the digest, so two payloads with the
32
- * same top-level shape but different field values produce different keys
33
- * (the previous `keys + length` heuristic collided on these).
34
- *
35
- * Trade-offs:
36
- * - 32 bits is ample for an in-process cache (collision risk negligible
37
- * at our key counts; we also prefix with method + url which further
38
- * partitions the keyspace).
39
- * - Not cryptographically secure — never use for security decisions.
40
- * - Zero dependencies, branch-free hot loop, ~1 GiB/s on V8.
28
+ * Default per-request timeout (ms) when neither the call site nor
29
+ * {@link OxyConfig.requestTimeout} overrides it. Kept tight so a stalled
30
+ * endpoint surfaces as an `AbortError` quickly rather than blocking the
31
+ * request queue.
41
32
  */
42
- function fnv1a32(str) {
43
- let h = 0x811c9dc5;
44
- for (let i = 0; i < str.length; i++) {
45
- h ^= str.charCodeAt(i);
46
- // h * 16777619 mod 2^32, written as shift-and-add for portability and
47
- // to avoid 53-bit JS number truncation in the intermediate multiply.
48
- h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
49
- }
50
- return h.toString(16).padStart(8, '0');
51
- }
33
+ const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
34
+ /**
35
+ * Timeout (ms) for the dedicated `GET /csrf-token` fetch. Independent of the
36
+ * regular request timeout: this is a small, fast, unauthenticated call and
37
+ * should never inherit a longer per-request budget.
38
+ */
39
+ const CSRF_FETCH_TIMEOUT_MS = 5000;
40
+ /**
41
+ * Number of attempts for fetching a CSRF token before giving up. The first
42
+ * failure is usually a cold edge/cookie race; a single retry recovers it
43
+ * without masking a genuinely broken `/csrf-token` route.
44
+ */
45
+ const CSRF_FETCH_MAX_ATTEMPTS = 2;
46
+ /**
47
+ * Backoff (ms) between CSRF-token fetch attempts. Short by design — a CSRF
48
+ * fetch sits in the critical path of a state-changing request, so the retry
49
+ * must add minimal latency.
50
+ */
51
+ const CSRF_FETCH_RETRY_DELAY_MS = 500;
52
+ /**
53
+ * Cooldown (ms) applied after a failed access-token refresh before another
54
+ * refresh is attempted. Prevents a refresh storm (and server hammering) when
55
+ * the AuthManager's refresh handler is failing — every in-flight request that
56
+ * hits a 401 would otherwise trigger its own refresh.
57
+ */
58
+ const TOKEN_REFRESH_COOLDOWN_MS = 15000;
59
+ /**
60
+ * Lead time (seconds) before access-token expiry at which a preflight refresh
61
+ * is triggered. A token within this window of `exp` is treated as effectively
62
+ * expired so the request carries a fresh bearer rather than racing the clock.
63
+ */
64
+ const TOKEN_REFRESH_LEAD_SECONDS = 60;
65
+ /**
66
+ * Soft ceiling on the number of live entries in the identity-scoped GET
67
+ * response cache. Crossing it does NOT evict anything (the {@link TTLCache}
68
+ * still expires by TTL and is swept on its cleanup interval) — it emits a
69
+ * single throttled telemetry warning via the logger so an unbounded-growth
70
+ * regression (e.g. an endpoint that mints a fresh identity tag per request, or
71
+ * a cache-key that accidentally folds in volatile data) is observable in the
72
+ * field instead of silently consuming memory. Tuned well above the working set
73
+ * a single authenticated user generates in normal use.
74
+ */
75
+ const CACHE_SOFT_MAX_ENTRIES = 500;
76
+ /**
77
+ * Minimum interval (ms) between successive cache-size telemetry warnings, so a
78
+ * cache that sits above the soft limit logs at most once per window rather than
79
+ * on every cached write.
80
+ */
81
+ const CACHE_SIZE_WARNING_THROTTLE_MS = 60000;
52
82
  /**
53
83
  * Token store for authentication (instance-based)
54
84
  * Each HttpService gets its own TokenStore to prevent conflicts
@@ -101,6 +131,12 @@ export class HttpService {
101
131
  this.tokenRefreshCooldownUntil = 0;
102
132
  this.authRefreshHandler = null;
103
133
  this.accessTokenProvider = null;
134
+ /**
135
+ * Epoch (ms) before which a cache-size telemetry warning must not be
136
+ * re-emitted. Throttles the {@link CACHE_SOFT_MAX_ENTRIES} warning to at most
137
+ * one per {@link CACHE_SIZE_WARNING_THROTTLE_MS} window.
138
+ */
139
+ this.cacheSizeWarningSilentUntil = 0;
104
140
  /**
105
141
  * Fan-out listeners notified on EVERY access-token change on this instance:
106
142
  * explicit `setTokens`, `clearTokens`, an AuthManager-owned refresh, and the
@@ -202,7 +238,7 @@ export class HttpService {
202
238
  * Main request method - handles everything in one place
203
239
  */
204
240
  async request(config) {
205
- const { method, url, data, params, timeout = this.config.requestTimeout || 5000, signal, cache = method === 'GET', cacheTTL, deduplicate = true, retry = this.config.enableRetry !== false, maxRetries = this.config.maxRetries || 3, } = config;
241
+ const { method, url, data, params, timeout = this.config.requestTimeout || DEFAULT_REQUEST_TIMEOUT_MS, signal, cache = method === 'GET', cacheTTL, deduplicate = true, retry = this.config.enableRetry !== false, maxRetries = this.config.maxRetries || 3, } = config;
206
242
  // Generate cache key (optimized for large objects)
207
243
  const cacheKey = cache ? this.generateCacheKey(method, url, data || params) : null;
208
244
  // Check cache first
@@ -433,9 +469,35 @@ export class HttpService {
433
469
  // Cache the result if caching is enabled
434
470
  if (cache && cacheKey && result) {
435
471
  this.cache.set(cacheKey, result, cacheTTL);
472
+ this.warnIfCacheOversized();
436
473
  }
437
474
  return result;
438
475
  }
476
+ /**
477
+ * Soft cache-size guard. Emits a single throttled telemetry warning when the
478
+ * identity-scoped response cache grows past {@link CACHE_SOFT_MAX_ENTRIES}.
479
+ *
480
+ * This intentionally does NOT evict: the {@link TTLCache} already bounds
481
+ * memory by TTL (and the global cleanup interval sweeps expired entries), so
482
+ * an LRU here would only risk thrashing a legitimately warm cache. The point
483
+ * is observability — if entry count climbs and stays high, an identity tag or
484
+ * cache key is folding in volatile data (a per-request nonce, an undecodable
485
+ * rotating token) and the cache is no longer doing its job. Surfacing that via
486
+ * the logger lets a consumer with `enableLogging` catch the regression in the
487
+ * field instead of debugging silent memory growth.
488
+ */
489
+ warnIfCacheOversized() {
490
+ const size = this.cache.size();
491
+ if (size <= CACHE_SOFT_MAX_ENTRIES) {
492
+ return;
493
+ }
494
+ const now = Date.now();
495
+ if (now < this.cacheSizeWarningSilentUntil) {
496
+ return;
497
+ }
498
+ this.cacheSizeWarningSilentUntil = now + CACHE_SIZE_WARNING_THROTTLE_MS;
499
+ this.logger.warn('Response cache exceeded soft entry limit — possible identity-tag or cache-key bloat', { size, softLimit: CACHE_SOFT_MAX_ENTRIES });
500
+ }
439
501
  /**
440
502
  * Upload via XMLHttpRequest (React Native FormData workaround).
441
503
  *
@@ -542,38 +604,13 @@ export class HttpService {
542
604
  /**
543
605
  * Derive a stable, non-sensitive identity discriminator for cache scoping.
544
606
  *
545
- * The GET-response cache MUST be partitioned by caller identity: endpoints
546
- * with optional auth (e.g. `GET /profiles/recommendations`) return different
547
- * content for an anonymous vs an authenticated caller, and per-user content
548
- * for different authenticated users. Keying solely on `method:url:data`
549
- * (the previous behavior) let an anonymous response be served to an
550
- * authenticated caller — surfacing as "Who to follow" recommending accounts
551
- * the user already follows after a cold-boot session restore.
552
- *
553
- * We use the access token's decoded user id (`userId || id`) rather than the
554
- * raw JWT so the token never lands in a cache key (no token leakage through
555
- * any cache-key logging, no key bloat). The acting-as id is folded in because
556
- * managed-account responses differ per acting identity — and `X-Acting-As`
557
- * already changes the server response for the same bearer token. Falls back
558
- * to `'anon'` when there is no token, and to a short FNV-1a hash of the token
559
- * only if it is present but cannot be decoded (degraded but still partitioned,
560
- * never colliding anon with authed).
607
+ * Thin instance wrapper over the pure {@link computeIdentityTag} helper
608
+ * binds it to this instance's live access token and acting-as id. See that
609
+ * function's docs for the full resolution contract (anon fallback, decoded
610
+ * `userId || id`, token-hash fallback for undecodable tokens).
561
611
  */
562
612
  computeIdentityTag() {
563
- const accessToken = this.tokenStore.getAccessToken();
564
- let principal = 'anon';
565
- if (accessToken) {
566
- try {
567
- const decoded = jwtDecode(accessToken);
568
- principal = decoded.userId || decoded.id || `t${fnv1a32(accessToken)}`;
569
- }
570
- catch {
571
- // Undecodable token — still partition it away from anon and from
572
- // other tokens via a hash. Never silently fall back to 'anon'.
573
- principal = `t${fnv1a32(accessToken)}`;
574
- }
575
- }
576
- return this._actingAsUserId ? `${principal}~as${this._actingAsUserId}` : principal;
613
+ return computeIdentityTag(this.tokenStore.getAccessToken(), this._actingAsUserId);
577
614
  }
578
615
  /**
579
616
  * Generate cache key efficiently
@@ -651,13 +688,12 @@ export class HttpService {
651
688
  return existingPromise;
652
689
  }
653
690
  const fetchPromise = (async () => {
654
- const maxAttempts = 2;
655
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
691
+ for (let attempt = 1; attempt <= CSRF_FETCH_MAX_ATTEMPTS; attempt++) {
656
692
  try {
657
693
  this.logger.debug('Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
658
694
  // Use AbortController for timeout (more compatible than AbortSignal.timeout)
659
695
  const controller = new AbortController();
660
- const timeoutId = setTimeout(() => controller.abort(), 5000);
696
+ const timeoutId = setTimeout(() => controller.abort(), CSRF_FETCH_TIMEOUT_MS);
661
697
  const response = await fetch(`${this.baseURL}/csrf-token`, {
662
698
  method: 'GET',
663
699
  headers: { 'Accept': 'application/json' },
@@ -688,9 +724,9 @@ export class HttpService {
688
724
  this.logger.debug('CSRF fetch error:', error);
689
725
  this.logger.warn('CSRF token fetch error:', error);
690
726
  }
691
- // Wait before retry (500ms)
692
- if (attempt < maxAttempts) {
693
- await new Promise(resolve => setTimeout(resolve, 500));
727
+ // Brief backoff before the next attempt.
728
+ if (attempt < CSRF_FETCH_MAX_ATTEMPTS) {
729
+ await new Promise(resolve => setTimeout(resolve, CSRF_FETCH_RETRY_DELAY_MS));
694
730
  }
695
731
  }
696
732
  return null;
@@ -711,8 +747,8 @@ export class HttpService {
711
747
  try {
712
748
  const decoded = jwtDecode(accessToken);
713
749
  const currentTime = Math.floor(Date.now() / 1000);
714
- // If token expires in less than 60 seconds, refresh it
715
- if (decoded.exp && decoded.exp - currentTime < 60) {
750
+ // If the token expires within the refresh lead window, refresh it.
751
+ if (decoded.exp && decoded.exp - currentTime < TOKEN_REFRESH_LEAD_SECONDS) {
716
752
  const refreshed = await this.refreshAccessToken('preflight');
717
753
  if (refreshed)
718
754
  return `Bearer ${refreshed}`;
@@ -740,7 +776,7 @@ export class HttpService {
740
776
  this.tokenRefreshPromise = this.authRefreshHandler(reason)
741
777
  .then((newToken) => {
742
778
  if (!newToken) {
743
- this.tokenRefreshCooldownUntil = Date.now() + 15000;
779
+ this.tokenRefreshCooldownUntil = Date.now() + TOKEN_REFRESH_COOLDOWN_MS;
744
780
  return null;
745
781
  }
746
782
  if (this.tokenStore.getAccessToken() !== newToken) {
@@ -752,7 +788,7 @@ export class HttpService {
752
788
  })
753
789
  .catch((error) => {
754
790
  this.logger.warn('Token refresh failed:', error);
755
- this.tokenRefreshCooldownUntil = Date.now() + 15000;
791
+ this.tokenRefreshCooldownUntil = Date.now() + TOKEN_REFRESH_COOLDOWN_MS;
756
792
  return null;
757
793
  })
758
794
  .finally(() => {
@@ -15,9 +15,42 @@ export function OxyServicesAssetsMixin(Base) {
15
15
  }
16
16
  }
17
17
  /**
18
- * Get file download URL (synchronous - uses stream endpoint for images to avoid ORB blocking)
18
+ * Build a synchronous file URL from an Oxy asset id.
19
+ *
20
+ * This is the single chokepoint every Oxy app uses to turn a stored file id
21
+ * (avatars, post media, etc.) into a `<img src>`-ready URL, so it resolves to
22
+ * one of two forms depending on whether the caller needs a signed/private URL:
23
+ *
24
+ * - **Public asset (default)** — no access token planted on the client AND no
25
+ * `expiresIn` requested → returns the clean CDN form
26
+ * `${cloudURL}/<id>[?variant=...]` (e.g. `https://cloud.oxy.so/<id>?variant=thumb`).
27
+ * CloudFront resolves the id against the public media origin. No token,
28
+ * `fallback`, or origin query params are emitted — these URLs are cacheable
29
+ * and shareable.
30
+ * - **Signed / private asset** — an access token is present on the client OR
31
+ * `expiresIn` was passed (the caller explicitly wants an expiring/authorized
32
+ * URL) → keeps the authenticated origin form
33
+ * `${baseURL}/assets/<id>/stream?...&token=...`. Private assets are NOT on
34
+ * the public CDN, so they must go through the API origin that can authorize
35
+ * the request.
36
+ *
37
+ * `cloudURL` (default `https://cloud.oxy.so`) is configured once on the
38
+ * `OxyServices` constructor and read via `getCloudURL()`; the API origin is
39
+ * `getBaseURL()` (e.g. `https://api.oxy.so`).
40
+ *
41
+ * For a CDN-signed URL fetched from the API, use {@link getFileDownloadUrlAsync}.
19
42
  */
20
43
  getFileDownloadUrl(fileId, variant, expiresIn) {
44
+ const token = this.getClient().getAccessToken();
45
+ // Public case: no auth token and no expiry requested → clean CDN URL.
46
+ // CloudFront serves the public media origin under `${cloudURL}/<id>`.
47
+ if (!token && !expiresIn) {
48
+ const variantQs = variant ? `?variant=${encodeURIComponent(variant)}` : '';
49
+ return `${this.getCloudURL()}/${encodeURIComponent(fileId)}${variantQs}`;
50
+ }
51
+ // Signed / private case: route through the authenticated API origin's
52
+ // stream endpoint so the request can be authorized (private assets are not
53
+ // exposed on the public CDN).
21
54
  const base = this.getBaseURL();
22
55
  const params = new URLSearchParams();
23
56
  if (variant)
@@ -25,7 +58,6 @@ export function OxyServicesAssetsMixin(Base) {
25
58
  if (expiresIn)
26
59
  params.set('expiresIn', String(expiresIn));
27
60
  params.set('fallback', 'placeholderVisible');
28
- const token = this.getClient().getAccessToken();
29
61
  if (token)
30
62
  params.set('token', token);
31
63
  const qs = params.toString();
@@ -1,7 +1,10 @@
1
+ import { recommendationRequestSchema } from '@oxyhq/contracts';
1
2
  import { buildSearchParams, buildPaginationParams } from '../utils/apiUtils.js';
2
3
  import { KeyManager } from '../crypto/keyManager.js';
3
4
  import { SignatureService } from '../crypto/signatureService.js';
4
5
  import { normalizeUserIdentity, normalizeUserIdentityOrNull } from '../utils/userIdentity.js';
6
+ import { logger } from '../utils/loggerUtils.js';
7
+ import { extractErrorStatus } from '../utils/errorUtils.js';
5
8
  export function OxyServicesUserMixin(Base) {
6
9
  return class extends Base {
7
10
  constructor(...args) {
@@ -81,7 +84,23 @@ export function OxyServicesUserMixin(Base) {
81
84
  });
82
85
  return normalizeUserIdentityOrNull(result);
83
86
  }
84
- catch {
87
+ catch (error) {
88
+ // Discovery is best-effort: an unresolvable handle is a normal "not
89
+ // found", not an exceptional condition, so the contract stays `null`.
90
+ // But a 404 (handle genuinely absent) must be distinguishable from a
91
+ // network/server failure (WebFinger upstream down, 5xx) for debugging —
92
+ // both used to be swallowed identically. Log at `debug` with context so
93
+ // the distinction is observable without turning expected misses into
94
+ // noise. Return contract is unchanged.
95
+ const status = extractErrorStatus(error);
96
+ const isNotFound = status === 404;
97
+ logger.debug(isNotFound ? 'resolveProfile: handle not found' : 'resolveProfile: discovery failed', {
98
+ method: 'resolveProfile',
99
+ handle,
100
+ status,
101
+ notFound: isNotFound,
102
+ error: error instanceof Error ? error.message : String(error),
103
+ });
85
104
  return null;
86
105
  }
87
106
  }
@@ -94,7 +113,7 @@ export function OxyServicesUserMixin(Base) {
94
113
  return normalizeUserIdentity(await this.makeRequest('PUT', '/users/resolve', data));
95
114
  }
96
115
  /**
97
- * Get profile recommendations, optionally filtering out specific user types.
116
+ * Get profile recommendations.
98
117
  *
99
118
  * Public discovery read — works WITHOUT authentication. The SDK attaches the
100
119
  * access token automatically when one is available (personalized via
@@ -102,15 +121,67 @@ export function OxyServicesUserMixin(Base) {
102
121
  * the caller is logged out. This deliberately does NOT use `withAuthRetry`,
103
122
  * which would throw an authentication timeout for logged-out callers before
104
123
  * the request is ever sent.
124
+ *
125
+ * Routing (two server endpoints, both validated against the same
126
+ * `@oxyhq/contracts` recommendation schemas so the wire shape cannot drift):
127
+ *
128
+ * - **GET `/profiles/recommendations`** (cached) is used for the simple,
129
+ * back-compatible case: no options at all, or only `excludeTypes` and/or
130
+ * `limit`. `excludeTypes` is sent as a comma-joined query param and
131
+ * `limit` as a numeric query param — byte-for-byte identical to the legacy
132
+ * behavior so every existing caller keeps the same request and cache key.
133
+ * - **POST `/profiles/recommendations`** is used whenever any of the scored
134
+ * (v2) fields — `boosts`, `excludeIds`, `signalWeights`, `clientId`, or
135
+ * `offset` — is present. The full options object is validated with
136
+ * `recommendationRequestSchema` and sent as the request body. The POST is
137
+ * cached by the HttpService keyed on the serialized body, so repeated
138
+ * identical scored requests are deduplicated/cached just like the GET path.
139
+ *
140
+ * @param options - {@link RecommendationRequest} from `@oxyhq/contracts`.
141
+ * Omitted entirely (or `{ excludeTypes }`) preserves the legacy GET path.
105
142
  */
106
143
  async getProfileRecommendations(options) {
144
+ // The scored (v2) POST path is selected when any field beyond the
145
+ // legacy GET-supported `excludeTypes`/`limit` pair is present.
146
+ const usesScoredPath = Boolean(options &&
147
+ (options.clientId !== undefined ||
148
+ options.offset !== undefined ||
149
+ (options.excludeIds?.length ?? 0) > 0 ||
150
+ (options.boosts?.length ?? 0) > 0 ||
151
+ options.signalWeights !== undefined));
107
152
  try {
108
- const params = options?.excludeTypes?.length
109
- ? { excludeTypes: options.excludeTypes.join(',') }
110
- : undefined;
111
- return await this.makeRequest('GET', '/profiles/recommendations', params, { cache: true });
153
+ if (usesScoredPath && options) {
154
+ // Validate the full request against the shared contract before
155
+ // sending. A malformed payload is surfaced to the caller rather than
156
+ // bounced by the server, and the parsed value strips unknown keys.
157
+ const body = recommendationRequestSchema.parse(options);
158
+ return await this.makeRequest('POST', '/profiles/recommendations', body,
159
+ // Cache keyed on the serialized body (see HttpService.generateCacheKey)
160
+ // so identical scored requests are served from cache, matching the
161
+ // GET path's caching semantics.
162
+ { cache: true });
163
+ }
164
+ const params = {};
165
+ if (options?.excludeTypes?.length) {
166
+ params.excludeTypes = options.excludeTypes.join(',');
167
+ }
168
+ if (options?.limit !== undefined) {
169
+ params.limit = String(options.limit);
170
+ }
171
+ return await this.makeRequest('GET', '/profiles/recommendations', Object.keys(params).length > 0 ? params : undefined, { cache: true });
112
172
  }
113
173
  catch (error) {
174
+ // Recommendations are a discovery read; failures are surfaced to the
175
+ // caller (contract unchanged: rethrow via `handleError`). Add debug
176
+ // observability first so a recurring upstream failure is diagnosable
177
+ // — distinguishing an auth/transport problem from a server 5xx.
178
+ logger.debug('getProfileRecommendations: discovery read failed', {
179
+ method: 'getProfileRecommendations',
180
+ path: usesScoredPath ? 'POST' : 'GET',
181
+ excludeTypes: options?.excludeTypes,
182
+ status: extractErrorStatus(error),
183
+ error: error instanceof Error ? error.message : String(error),
184
+ });
114
185
  throw this.handleError(error);
115
186
  }
116
187
  }
@@ -183,14 +254,7 @@ export function OxyServicesUserMixin(Base) {
183
254
  }
184
255
  catch (error) {
185
256
  const errorMessage = error instanceof Error ? error.message : String(error);
186
- const errorRecord = error && typeof error === 'object'
187
- ? error
188
- : null;
189
- const status = typeof errorRecord?.status === 'number'
190
- ? errorRecord.status
191
- : typeof errorRecord?.response?.status === 'number'
192
- ? errorRecord.response.status
193
- : undefined;
257
+ const status = extractErrorStatus(error);
194
258
  // Check if it's an authentication error (401)
195
259
  const isAuthError = status === 401 ||
196
260
  errorMessage.includes('Authentication required') ||
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Cache-key primitives for the identity-scoped HTTP GET response cache.
3
+ *
4
+ * Extracted from {@link HttpService} so the identity-tag derivation is a pure,
5
+ * independently testable function with no dependency on instance/token state.
6
+ * The HTTP service injects the live access token and acting-as id; everything
7
+ * here is referentially transparent given those inputs.
8
+ */
9
+ import { jwtDecode } from 'jwt-decode';
10
+ /**
11
+ * Discriminator used when there is no access token at all. Anonymous responses
12
+ * must never collide with any authenticated identity.
13
+ */
14
+ export const ANON_IDENTITY = 'anon';
15
+ /**
16
+ * FNV-1a 32-bit non-cryptographic hash.
17
+ *
18
+ * Used by the cache-key generator for large payloads where full JSON inclusion
19
+ * would balloon the cache map keys, and as the fallback discriminator for an
20
+ * undecodable access token. Content-addressed: every byte of the input
21
+ * contributes to the digest, so two inputs with the same top-level shape but
22
+ * different field values produce different keys (the previous `keys + length`
23
+ * heuristic collided on these).
24
+ *
25
+ * Trade-offs:
26
+ * - 32 bits is ample for an in-process cache (collision risk negligible at our
27
+ * key counts; we also prefix with method + url which further partitions the
28
+ * keyspace).
29
+ * - Not cryptographically secure — never use for security decisions.
30
+ * - Zero dependencies, branch-free hot loop, ~1 GiB/s on V8.
31
+ */
32
+ export function fnv1a32(str) {
33
+ let h = 0x811c9dc5;
34
+ for (let i = 0; i < str.length; i++) {
35
+ h ^= str.charCodeAt(i);
36
+ // h * 16777619 mod 2^32, written as shift-and-add for portability and
37
+ // to avoid 53-bit JS number truncation in the intermediate multiply.
38
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
39
+ }
40
+ return h.toString(16).padStart(8, '0');
41
+ }
42
+ /**
43
+ * Derive a stable, non-sensitive identity discriminator for cache scoping.
44
+ *
45
+ * The GET-response cache MUST be partitioned by caller identity: endpoints with
46
+ * optional auth (e.g. `GET /profiles/recommendations`) return different content
47
+ * for an anonymous vs an authenticated caller, and per-user content for
48
+ * different authenticated users. Keying solely on `method:url:data` let an
49
+ * anonymous response be served to an authenticated caller — surfacing as
50
+ * "Who to follow" recommending accounts the user already follows after a
51
+ * cold-boot session restore.
52
+ *
53
+ * Resolution order:
54
+ * - no token → {@link ANON_IDENTITY} (`'anon'`).
55
+ * - decodable token → the token's `userId || id`.
56
+ * - undecodable token → a short FNV-1a hash of the token, prefixed `t` so it
57
+ * can never collide with `'anon'` or a real user id.
58
+ *
59
+ * We use the decoded user id rather than the raw JWT so the token never lands
60
+ * in a cache key (no token leakage through any cache-key logging, no key bloat).
61
+ * The acting-as id is folded in because managed-account responses differ per
62
+ * acting identity — and `X-Acting-As` already changes the server response for
63
+ * the same bearer token.
64
+ *
65
+ * @param accessToken The current bearer access token, or `null` when anonymous.
66
+ * @param actingAsUserId The active managed-account id, or `null`.
67
+ */
68
+ export function computeIdentityTag(accessToken, actingAsUserId) {
69
+ let principal = ANON_IDENTITY;
70
+ if (accessToken) {
71
+ try {
72
+ const decoded = jwtDecode(accessToken);
73
+ principal = decoded.userId || decoded.id || `t${fnv1a32(accessToken)}`;
74
+ }
75
+ catch {
76
+ // Undecodable token — still partition it away from anon and from other
77
+ // tokens via a hash. Never silently fall back to ANON_IDENTITY.
78
+ principal = `t${fnv1a32(accessToken)}`;
79
+ }
80
+ }
81
+ return actingAsUserId ? `${principal}~as${actingAsUserId}` : principal;
82
+ }
@@ -117,6 +117,30 @@ export function getErrorCodeFromStatus(status) {
117
117
  return ErrorCodes.INTERNAL_ERROR;
118
118
  }
119
119
  }
120
+ /**
121
+ * Best-effort extraction of an HTTP status code from a thrown value.
122
+ *
123
+ * `HttpService` annotates the errors it throws with both `error.status` and
124
+ * `error.response.status`; an already-normalized {@link ApiError} carries
125
+ * `error.status`. This reads either, returning `undefined` when the value is
126
+ * not an object or carries no numeric status (e.g. a thrown string, a network
127
+ * `TypeError`). Used by discovery/read paths to distinguish a 404 "not found"
128
+ * from a transport/server failure for observability without re-deriving the
129
+ * narrowing at every call site.
130
+ */
131
+ export function extractErrorStatus(error) {
132
+ if (!error || typeof error !== 'object') {
133
+ return undefined;
134
+ }
135
+ const record = error;
136
+ if (typeof record.status === 'number') {
137
+ return record.status;
138
+ }
139
+ if (typeof record.response?.status === 'number') {
140
+ return record.response.status;
141
+ }
142
+ return undefined;
143
+ }
120
144
  /**
121
145
  * Validate required fields and throw error if missing
122
146
  */