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