@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
@@ -54,6 +54,12 @@ export declare class HttpService {
54
54
  private tokenRefreshCooldownUntil;
55
55
  private authRefreshHandler;
56
56
  private accessTokenProvider;
57
+ /**
58
+ * Epoch (ms) before which a cache-size telemetry warning must not be
59
+ * re-emitted. Throttles the {@link CACHE_SOFT_MAX_ENTRIES} warning to at most
60
+ * one per {@link CACHE_SIZE_WARNING_THROTTLE_MS} window.
61
+ */
62
+ private cacheSizeWarningSilentUntil;
57
63
  /**
58
64
  * Fan-out listeners notified on EVERY access-token change on this instance:
59
65
  * explicit `setTokens`, `clearTokens`, an AuthManager-owned refresh, and the
@@ -89,6 +95,20 @@ export declare class HttpService {
89
95
  * Main request method - handles everything in one place
90
96
  */
91
97
  request<T = unknown>(config: RequestConfig): Promise<T>;
98
+ /**
99
+ * Soft cache-size guard. Emits a single throttled telemetry warning when the
100
+ * identity-scoped response cache grows past {@link CACHE_SOFT_MAX_ENTRIES}.
101
+ *
102
+ * This intentionally does NOT evict: the {@link TTLCache} already bounds
103
+ * memory by TTL (and the global cleanup interval sweeps expired entries), so
104
+ * an LRU here would only risk thrashing a legitimately warm cache. The point
105
+ * is observability — if entry count climbs and stays high, an identity tag or
106
+ * cache key is folding in volatile data (a per-request nonce, an undecodable
107
+ * rotating token) and the cache is no longer doing its job. Surfacing that via
108
+ * the logger lets a consumer with `enableLogging` catch the regression in the
109
+ * field instead of debugging silent memory growth.
110
+ */
111
+ private warnIfCacheOversized;
92
112
  /**
93
113
  * Upload via XMLHttpRequest (React Native FormData workaround).
94
114
  *
@@ -122,22 +142,10 @@ export declare class HttpService {
122
142
  /**
123
143
  * Derive a stable, non-sensitive identity discriminator for cache scoping.
124
144
  *
125
- * The GET-response cache MUST be partitioned by caller identity: endpoints
126
- * with optional auth (e.g. `GET /profiles/recommendations`) return different
127
- * content for an anonymous vs an authenticated caller, and per-user content
128
- * for different authenticated users. Keying solely on `method:url:data`
129
- * (the previous behavior) let an anonymous response be served to an
130
- * authenticated caller — surfacing as "Who to follow" recommending accounts
131
- * the user already follows after a cold-boot session restore.
132
- *
133
- * We use the access token's decoded user id (`userId || id`) rather than the
134
- * raw JWT so the token never lands in a cache key (no token leakage through
135
- * any cache-key logging, no key bloat). The acting-as id is folded in because
136
- * managed-account responses differ per acting identity — and `X-Acting-As`
137
- * already changes the server response for the same bearer token. Falls back
138
- * to `'anon'` when there is no token, and to a short FNV-1a hash of the token
139
- * only if it is present but cannot be decoded (degraded but still partitioned,
140
- * never colliding anon with authed).
145
+ * Thin instance wrapper over the pure {@link computeIdentityTag} helper
146
+ * binds it to this instance's live access token and acting-as id. See that
147
+ * function's docs for the full resolution contract (anon fallback, decoded
148
+ * `userId || id`, token-hash fallback for undecodable tokens).
141
149
  */
142
150
  private computeIdentityTag;
143
151
  /**
@@ -7,7 +7,30 @@ export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>
7
7
  */
8
8
  deleteFile(fileId: string): Promise<any>;
9
9
  /**
10
- * Get file download URL (synchronous - uses stream endpoint for images to avoid ORB blocking)
10
+ * Build a synchronous file URL from an Oxy asset id.
11
+ *
12
+ * This is the single chokepoint every Oxy app uses to turn a stored file id
13
+ * (avatars, post media, etc.) into a `<img src>`-ready URL, so it resolves to
14
+ * one of two forms depending on whether the caller needs a signed/private URL:
15
+ *
16
+ * - **Public asset (default)** — no access token planted on the client AND no
17
+ * `expiresIn` requested → returns the clean CDN form
18
+ * `${cloudURL}/<id>[?variant=...]` (e.g. `https://cloud.oxy.so/<id>?variant=thumb`).
19
+ * CloudFront resolves the id against the public media origin. No token,
20
+ * `fallback`, or origin query params are emitted — these URLs are cacheable
21
+ * and shareable.
22
+ * - **Signed / private asset** — an access token is present on the client OR
23
+ * `expiresIn` was passed (the caller explicitly wants an expiring/authorized
24
+ * URL) → keeps the authenticated origin form
25
+ * `${baseURL}/assets/<id>/stream?...&token=...`. Private assets are NOT on
26
+ * the public CDN, so they must go through the API origin that can authorize
27
+ * the request.
28
+ *
29
+ * `cloudURL` (default `https://cloud.oxy.so`) is configured once on the
30
+ * `OxyServices` constructor and read via `getCloudURL()`; the API origin is
31
+ * `getBaseURL()` (e.g. `https://api.oxy.so`).
32
+ *
33
+ * For a CDN-signed URL fetched from the API, use {@link getFileDownloadUrlAsync}.
11
34
  */
12
35
  getFileDownloadUrl(fileId: string, variant?: string, expiresIn?: number): string;
13
36
  /**
@@ -2,7 +2,7 @@
2
2
  * User Management Methods Mixin
3
3
  */
4
4
  import type { User, Notification, NotificationPreferences, UserPreferences, SearchProfilesResponse, PrivacySettings } from '../models/interfaces';
5
- import type { UserNameResponse, UserProfileUpdate } from '@oxyhq/contracts';
5
+ import type { UserNameResponse, UserProfileUpdate, RecommendationRequest, RecommendationItem } from '@oxyhq/contracts';
6
6
  import type { OxyServicesBase } from '../OxyServices.base';
7
7
  import { type PaginationParams } from '../utils/apiUtils';
8
8
  /** Per-user outcome returned by `POST /users/follow/bulk`. */
@@ -81,7 +81,7 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
81
81
  ownerId?: string;
82
82
  }): Promise<User>;
83
83
  /**
84
- * Get profile recommendations, optionally filtering out specific user types.
84
+ * Get profile recommendations.
85
85
  *
86
86
  * Public discovery read — works WITHOUT authentication. The SDK attaches the
87
87
  * access token automatically when one is available (personalized via
@@ -89,32 +89,26 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
89
89
  * the caller is logged out. This deliberately does NOT use `withAuthRetry`,
90
90
  * which would throw an authentication timeout for logged-out callers before
91
91
  * the request is ever sent.
92
+ *
93
+ * Routing (two server endpoints, both validated against the same
94
+ * `@oxyhq/contracts` recommendation schemas so the wire shape cannot drift):
95
+ *
96
+ * - **GET `/profiles/recommendations`** (cached) is used for the simple,
97
+ * back-compatible case: no options at all, or only `excludeTypes` and/or
98
+ * `limit`. `excludeTypes` is sent as a comma-joined query param and
99
+ * `limit` as a numeric query param — byte-for-byte identical to the legacy
100
+ * behavior so every existing caller keeps the same request and cache key.
101
+ * - **POST `/profiles/recommendations`** is used whenever any of the scored
102
+ * (v2) fields — `boosts`, `excludeIds`, `signalWeights`, `clientId`, or
103
+ * `offset` — is present. The full options object is validated with
104
+ * `recommendationRequestSchema` and sent as the request body. The POST is
105
+ * cached by the HttpService keyed on the serialized body, so repeated
106
+ * identical scored requests are deduplicated/cached just like the GET path.
107
+ *
108
+ * @param options - {@link RecommendationRequest} from `@oxyhq/contracts`.
109
+ * Omitted entirely (or `{ excludeTypes }`) preserves the legacy GET path.
92
110
  */
93
- getProfileRecommendations(options?: {
94
- excludeTypes?: Array<"federated" | "agent" | "automated">;
95
- }): Promise<Array<{
96
- id: string;
97
- username: string;
98
- name: UserNameResponse;
99
- description?: string;
100
- isFederated?: boolean;
101
- isAgent?: boolean;
102
- isAutomated?: boolean;
103
- instance?: string;
104
- federation?: {
105
- actorUri?: string;
106
- domain?: string;
107
- actorId?: string;
108
- };
109
- automation?: {
110
- ownerId?: string;
111
- };
112
- _count?: {
113
- followers: number;
114
- following: number;
115
- };
116
- [key: string]: unknown;
117
- }>>;
111
+ getProfileRecommendations(options?: RecommendationRequest): Promise<RecommendationItem[]>;
118
112
  /**
119
113
  * Get profiles similar to a given user, based on co-follower overlap.
120
114
  */
@@ -0,0 +1,67 @@
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
+ /**
10
+ * Minimal JWT payload shape we read for cache scoping. The identity discriminator
11
+ * comes from `userId` (preferred) or `id`; nothing else is consulted here.
12
+ */
13
+ export interface CacheIdentityJwtPayload {
14
+ userId?: string;
15
+ id?: string;
16
+ [key: string]: unknown;
17
+ }
18
+ /**
19
+ * Discriminator used when there is no access token at all. Anonymous responses
20
+ * must never collide with any authenticated identity.
21
+ */
22
+ export declare const ANON_IDENTITY = "anon";
23
+ /**
24
+ * FNV-1a 32-bit non-cryptographic hash.
25
+ *
26
+ * Used by the cache-key generator for large payloads where full JSON inclusion
27
+ * would balloon the cache map keys, and as the fallback discriminator for an
28
+ * undecodable access token. Content-addressed: every byte of the input
29
+ * contributes to the digest, so two inputs with the same top-level shape but
30
+ * different field values produce different keys (the previous `keys + length`
31
+ * heuristic collided on these).
32
+ *
33
+ * Trade-offs:
34
+ * - 32 bits is ample for an in-process cache (collision risk negligible at our
35
+ * key counts; we also prefix with method + url which further partitions the
36
+ * keyspace).
37
+ * - Not cryptographically secure — never use for security decisions.
38
+ * - Zero dependencies, branch-free hot loop, ~1 GiB/s on V8.
39
+ */
40
+ export declare function fnv1a32(str: string): string;
41
+ /**
42
+ * Derive a stable, non-sensitive identity discriminator for cache scoping.
43
+ *
44
+ * The GET-response cache MUST be partitioned by caller identity: endpoints with
45
+ * optional auth (e.g. `GET /profiles/recommendations`) return different content
46
+ * for an anonymous vs an authenticated caller, and per-user content for
47
+ * different authenticated users. Keying solely on `method:url:data` let an
48
+ * anonymous response be served to an authenticated caller — surfacing as
49
+ * "Who to follow" recommending accounts the user already follows after a
50
+ * cold-boot session restore.
51
+ *
52
+ * Resolution order:
53
+ * - no token → {@link ANON_IDENTITY} (`'anon'`).
54
+ * - decodable token → the token's `userId || id`.
55
+ * - undecodable token → a short FNV-1a hash of the token, prefixed `t` so it
56
+ * can never collide with `'anon'` or a real user id.
57
+ *
58
+ * We use the decoded user id rather than the raw JWT so the token never lands
59
+ * in a cache key (no token leakage through any cache-key logging, no key bloat).
60
+ * The acting-as id is folded in because managed-account responses differ per
61
+ * acting identity — and `X-Acting-As` already changes the server response for
62
+ * the same bearer token.
63
+ *
64
+ * @param accessToken The current bearer access token, or `null` when anonymous.
65
+ * @param actingAsUserId The active managed-account id, or `null`.
66
+ */
67
+ export declare function computeIdentityTag(accessToken: string | null, actingAsUserId: string | null): string;
@@ -36,6 +36,18 @@ export declare function handleHttpError(error: unknown): ApiError;
36
36
  * Exported for use in other modules
37
37
  */
38
38
  export declare function getErrorCodeFromStatus(status: number): string;
39
+ /**
40
+ * Best-effort extraction of an HTTP status code from a thrown value.
41
+ *
42
+ * `HttpService` annotates the errors it throws with both `error.status` and
43
+ * `error.response.status`; an already-normalized {@link ApiError} carries
44
+ * `error.status`. This reads either, returning `undefined` when the value is
45
+ * not an object or carries no numeric status (e.g. a thrown string, a network
46
+ * `TypeError`). Used by discovery/read paths to distinguish a 404 "not found"
47
+ * from a transport/server failure for observability without re-deriving the
48
+ * narrowing at every call site.
49
+ */
50
+ export declare function extractErrorStatus(error: unknown): number | undefined;
39
51
  /**
40
52
  * Validate required fields and throw error if missing
41
53
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "3.6.0",
3
+ "version": "3.7.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",
@@ -97,7 +97,7 @@
97
97
  }
98
98
  },
99
99
  "dependencies": {
100
- "@oxyhq/contracts": "^0.1.1",
100
+ "@oxyhq/contracts": "^0.2.0",
101
101
  "bip39": "^3.1.0",
102
102
  "buffer": "^6.0.3",
103
103
  "elliptic": "^6.6.1",
@@ -19,6 +19,7 @@ import { retryAsync } from './utils/asyncUtils';
19
19
  import { handleHttpError } from './utils/errorUtils';
20
20
  import { jwtDecode } from 'jwt-decode';
21
21
  import { isNative, isReactNative, getPlatformOS } from './utils/platform';
22
+ import { computeIdentityTag, fnv1a32 } from './utils/cacheKey';
22
23
  import type { OxyConfig } from './models/interfaces';
23
24
 
24
25
  /**
@@ -58,33 +59,6 @@ interface FormDataLike {
58
59
  has(name: string): boolean;
59
60
  }
60
61
 
61
- /**
62
- * FNV-1a 32-bit non-cryptographic hash.
63
- *
64
- * Used by the cache-key generator for large payloads where full JSON
65
- * inclusion would balloon the cache map keys. Content-addressed: every
66
- * byte of the input contributes to the digest, so two payloads with the
67
- * same top-level shape but different field values produce different keys
68
- * (the previous `keys + length` heuristic collided on these).
69
- *
70
- * Trade-offs:
71
- * - 32 bits is ample for an in-process cache (collision risk negligible
72
- * at our key counts; we also prefix with method + url which further
73
- * partitions the keyspace).
74
- * - Not cryptographically secure — never use for security decisions.
75
- * - Zero dependencies, branch-free hot loop, ~1 GiB/s on V8.
76
- */
77
- function fnv1a32(str: string): string {
78
- let h = 0x811c9dc5;
79
- for (let i = 0; i < str.length; i++) {
80
- h ^= str.charCodeAt(i);
81
- // h * 16777619 mod 2^32, written as shift-and-add for portability and
82
- // to avoid 53-bit JS number truncation in the intermediate multiply.
83
- h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
84
- }
85
- return h.toString(16).padStart(8, '0');
86
- }
87
-
88
62
  export interface RequestOptions {
89
63
  cache?: boolean;
90
64
  cacheTTL?: number;
@@ -107,6 +81,69 @@ interface RequestConfig extends RequestOptions {
107
81
  _isCsrfRetry?: boolean;
108
82
  }
109
83
 
84
+ /**
85
+ * Default per-request timeout (ms) when neither the call site nor
86
+ * {@link OxyConfig.requestTimeout} overrides it. Kept tight so a stalled
87
+ * endpoint surfaces as an `AbortError` quickly rather than blocking the
88
+ * request queue.
89
+ */
90
+ const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
91
+
92
+ /**
93
+ * Timeout (ms) for the dedicated `GET /csrf-token` fetch. Independent of the
94
+ * regular request timeout: this is a small, fast, unauthenticated call and
95
+ * should never inherit a longer per-request budget.
96
+ */
97
+ const CSRF_FETCH_TIMEOUT_MS = 5000;
98
+
99
+ /**
100
+ * Number of attempts for fetching a CSRF token before giving up. The first
101
+ * failure is usually a cold edge/cookie race; a single retry recovers it
102
+ * without masking a genuinely broken `/csrf-token` route.
103
+ */
104
+ const CSRF_FETCH_MAX_ATTEMPTS = 2;
105
+
106
+ /**
107
+ * Backoff (ms) between CSRF-token fetch attempts. Short by design — a CSRF
108
+ * fetch sits in the critical path of a state-changing request, so the retry
109
+ * must add minimal latency.
110
+ */
111
+ const CSRF_FETCH_RETRY_DELAY_MS = 500;
112
+
113
+ /**
114
+ * Cooldown (ms) applied after a failed access-token refresh before another
115
+ * refresh is attempted. Prevents a refresh storm (and server hammering) when
116
+ * the AuthManager's refresh handler is failing — every in-flight request that
117
+ * hits a 401 would otherwise trigger its own refresh.
118
+ */
119
+ const TOKEN_REFRESH_COOLDOWN_MS = 15000;
120
+
121
+ /**
122
+ * Lead time (seconds) before access-token expiry at which a preflight refresh
123
+ * is triggered. A token within this window of `exp` is treated as effectively
124
+ * expired so the request carries a fresh bearer rather than racing the clock.
125
+ */
126
+ const TOKEN_REFRESH_LEAD_SECONDS = 60;
127
+
128
+ /**
129
+ * Soft ceiling on the number of live entries in the identity-scoped GET
130
+ * response cache. Crossing it does NOT evict anything (the {@link TTLCache}
131
+ * still expires by TTL and is swept on its cleanup interval) — it emits a
132
+ * single throttled telemetry warning via the logger so an unbounded-growth
133
+ * regression (e.g. an endpoint that mints a fresh identity tag per request, or
134
+ * a cache-key that accidentally folds in volatile data) is observable in the
135
+ * field instead of silently consuming memory. Tuned well above the working set
136
+ * a single authenticated user generates in normal use.
137
+ */
138
+ const CACHE_SOFT_MAX_ENTRIES = 500;
139
+
140
+ /**
141
+ * Minimum interval (ms) between successive cache-size telemetry warnings, so a
142
+ * cache that sits above the soft limit logs at most once per window rather than
143
+ * on every cached write.
144
+ */
145
+ const CACHE_SIZE_WARNING_THROTTLE_MS = 60000;
146
+
110
147
  /**
111
148
  * Token store for authentication (instance-based)
112
149
  * Each HttpService gets its own TokenStore to prevent conflicts
@@ -174,6 +211,13 @@ export class HttpService {
174
211
  private authRefreshHandler: AuthRefreshHandler | null = null;
175
212
  private accessTokenProvider: AccessTokenProvider | null = null;
176
213
 
214
+ /**
215
+ * Epoch (ms) before which a cache-size telemetry warning must not be
216
+ * re-emitted. Throttles the {@link CACHE_SOFT_MAX_ENTRIES} warning to at most
217
+ * one per {@link CACHE_SIZE_WARNING_THROTTLE_MS} window.
218
+ */
219
+ private cacheSizeWarningSilentUntil: number = 0;
220
+
177
221
  /**
178
222
  * Fan-out listeners notified on EVERY access-token change on this instance:
179
223
  * explicit `setTokens`, `clearTokens`, an AuthManager-owned refresh, and the
@@ -306,7 +350,7 @@ export class HttpService {
306
350
  url,
307
351
  data,
308
352
  params,
309
- timeout = this.config.requestTimeout || 5000,
353
+ timeout = this.config.requestTimeout || DEFAULT_REQUEST_TIMEOUT_MS,
310
354
  signal,
311
355
  cache = method === 'GET',
312
356
  cacheTTL,
@@ -571,11 +615,41 @@ export class HttpService {
571
615
  // Cache the result if caching is enabled
572
616
  if (cache && cacheKey && result) {
573
617
  this.cache.set(cacheKey, result, cacheTTL);
618
+ this.warnIfCacheOversized();
574
619
  }
575
620
 
576
621
  return result;
577
622
  }
578
623
 
624
+ /**
625
+ * Soft cache-size guard. Emits a single throttled telemetry warning when the
626
+ * identity-scoped response cache grows past {@link CACHE_SOFT_MAX_ENTRIES}.
627
+ *
628
+ * This intentionally does NOT evict: the {@link TTLCache} already bounds
629
+ * memory by TTL (and the global cleanup interval sweeps expired entries), so
630
+ * an LRU here would only risk thrashing a legitimately warm cache. The point
631
+ * is observability — if entry count climbs and stays high, an identity tag or
632
+ * cache key is folding in volatile data (a per-request nonce, an undecodable
633
+ * rotating token) and the cache is no longer doing its job. Surfacing that via
634
+ * the logger lets a consumer with `enableLogging` catch the regression in the
635
+ * field instead of debugging silent memory growth.
636
+ */
637
+ private warnIfCacheOversized(): void {
638
+ const size = this.cache.size();
639
+ if (size <= CACHE_SOFT_MAX_ENTRIES) {
640
+ return;
641
+ }
642
+ const now = Date.now();
643
+ if (now < this.cacheSizeWarningSilentUntil) {
644
+ return;
645
+ }
646
+ this.cacheSizeWarningSilentUntil = now + CACHE_SIZE_WARNING_THROTTLE_MS;
647
+ this.logger.warn(
648
+ 'Response cache exceeded soft entry limit — possible identity-tag or cache-key bloat',
649
+ { size, softLimit: CACHE_SOFT_MAX_ENTRIES },
650
+ );
651
+ }
652
+
579
653
  /**
580
654
  * Upload via XMLHttpRequest (React Native FormData workaround).
581
655
  *
@@ -701,37 +775,13 @@ export class HttpService {
701
775
  /**
702
776
  * Derive a stable, non-sensitive identity discriminator for cache scoping.
703
777
  *
704
- * The GET-response cache MUST be partitioned by caller identity: endpoints
705
- * with optional auth (e.g. `GET /profiles/recommendations`) return different
706
- * content for an anonymous vs an authenticated caller, and per-user content
707
- * for different authenticated users. Keying solely on `method:url:data`
708
- * (the previous behavior) let an anonymous response be served to an
709
- * authenticated caller — surfacing as "Who to follow" recommending accounts
710
- * the user already follows after a cold-boot session restore.
711
- *
712
- * We use the access token's decoded user id (`userId || id`) rather than the
713
- * raw JWT so the token never lands in a cache key (no token leakage through
714
- * any cache-key logging, no key bloat). The acting-as id is folded in because
715
- * managed-account responses differ per acting identity — and `X-Acting-As`
716
- * already changes the server response for the same bearer token. Falls back
717
- * to `'anon'` when there is no token, and to a short FNV-1a hash of the token
718
- * only if it is present but cannot be decoded (degraded but still partitioned,
719
- * never colliding anon with authed).
778
+ * Thin instance wrapper over the pure {@link computeIdentityTag} helper
779
+ * binds it to this instance's live access token and acting-as id. See that
780
+ * function's docs for the full resolution contract (anon fallback, decoded
781
+ * `userId || id`, token-hash fallback for undecodable tokens).
720
782
  */
721
783
  private computeIdentityTag(): string {
722
- const accessToken = this.tokenStore.getAccessToken();
723
- let principal = 'anon';
724
- if (accessToken) {
725
- try {
726
- const decoded = jwtDecode<JwtPayload>(accessToken);
727
- principal = decoded.userId || decoded.id || `t${fnv1a32(accessToken)}`;
728
- } catch {
729
- // Undecodable token — still partition it away from anon and from
730
- // other tokens via a hash. Never silently fall back to 'anon'.
731
- principal = `t${fnv1a32(accessToken)}`;
732
- }
733
- }
734
- return this._actingAsUserId ? `${principal}~as${this._actingAsUserId}` : principal;
784
+ return computeIdentityTag(this.tokenStore.getAccessToken(), this._actingAsUserId);
735
785
  }
736
786
 
737
787
  /**
@@ -820,14 +870,13 @@ export class HttpService {
820
870
  }
821
871
 
822
872
  const fetchPromise = (async () => {
823
- const maxAttempts = 2;
824
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
873
+ for (let attempt = 1; attempt <= CSRF_FETCH_MAX_ATTEMPTS; attempt++) {
825
874
  try {
826
875
  this.logger.debug('Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
827
876
 
828
877
  // Use AbortController for timeout (more compatible than AbortSignal.timeout)
829
878
  const controller = new AbortController();
830
- const timeoutId = setTimeout(() => controller.abort(), 5000);
879
+ const timeoutId = setTimeout(() => controller.abort(), CSRF_FETCH_TIMEOUT_MS);
831
880
 
832
881
  const response = await fetch(`${this.baseURL}/csrf-token`, {
833
882
  method: 'GET',
@@ -863,9 +912,9 @@ export class HttpService {
863
912
  this.logger.debug('CSRF fetch error:', error);
864
913
  this.logger.warn('CSRF token fetch error:', error);
865
914
  }
866
- // Wait before retry (500ms)
867
- if (attempt < maxAttempts) {
868
- await new Promise(resolve => setTimeout(resolve, 500));
915
+ // Brief backoff before the next attempt.
916
+ if (attempt < CSRF_FETCH_MAX_ATTEMPTS) {
917
+ await new Promise(resolve => setTimeout(resolve, CSRF_FETCH_RETRY_DELAY_MS));
869
918
  }
870
919
  }
871
920
  return null;
@@ -890,8 +939,8 @@ export class HttpService {
890
939
  const decoded = jwtDecode<JwtPayload>(accessToken);
891
940
  const currentTime = Math.floor(Date.now() / 1000);
892
941
 
893
- // If token expires in less than 60 seconds, refresh it
894
- if (decoded.exp && decoded.exp - currentTime < 60) {
942
+ // If the token expires within the refresh lead window, refresh it.
943
+ if (decoded.exp && decoded.exp - currentTime < TOKEN_REFRESH_LEAD_SECONDS) {
895
944
  const refreshed = await this.refreshAccessToken('preflight');
896
945
  if (refreshed) return `Bearer ${refreshed}`;
897
946
  if (decoded.exp > currentTime) {
@@ -921,7 +970,7 @@ export class HttpService {
921
970
  this.tokenRefreshPromise = this.authRefreshHandler(reason)
922
971
  .then((newToken) => {
923
972
  if (!newToken) {
924
- this.tokenRefreshCooldownUntil = Date.now() + 15000;
973
+ this.tokenRefreshCooldownUntil = Date.now() + TOKEN_REFRESH_COOLDOWN_MS;
925
974
  return null;
926
975
  }
927
976
  if (this.tokenStore.getAccessToken() !== newToken) {
@@ -933,7 +982,7 @@ export class HttpService {
933
982
  })
934
983
  .catch((error) => {
935
984
  this.logger.warn('Token refresh failed:', error);
936
- this.tokenRefreshCooldownUntil = Date.now() + 15000;
985
+ this.tokenRefreshCooldownUntil = Date.now() + TOKEN_REFRESH_COOLDOWN_MS;
937
986
  return null;
938
987
  })
939
988
  .finally(() => {
@@ -85,14 +85,14 @@ const TWO_ACCOUNTS: RefreshAllResponse = {
85
85
  accessToken: TOKEN_SLOT_0,
86
86
  expiresAt: '2099-01-01T00:00:00.000Z',
87
87
  sessionId: 'sess-slot-0',
88
- user: { id: 'user-0', username: 'alice', avatar: null, color: '#1abc9c' },
88
+ user: { id: 'user-0', username: 'alice', name: { displayName: 'Alice' }, avatar: null, color: '#1abc9c' },
89
89
  },
90
90
  {
91
91
  authuser: 1,
92
92
  accessToken: TOKEN_SLOT_1,
93
93
  expiresAt: '2099-01-01T00:00:00.000Z',
94
94
  sessionId: 'sess-slot-1',
95
- user: { id: 'user-1', username: 'bob', avatar: null, color: '#3498db' },
95
+ user: { id: 'user-1', username: 'bob', name: { displayName: 'Bob' }, avatar: null, color: '#3498db' },
96
96
  },
97
97
  ],
98
98
  };
@@ -89,14 +89,14 @@ const TWO_ACCOUNTS: RefreshAllResponse = {
89
89
  accessToken: TOKEN_SLOT_0,
90
90
  expiresAt: '2099-01-01T00:00:00.000Z',
91
91
  sessionId: 'sess-slot-0',
92
- user: { id: 'user-0', username: 'alice', avatar: null, color: '#1abc9c' },
92
+ user: { id: 'user-0', username: 'alice', name: { displayName: 'Alice' }, avatar: null, color: '#1abc9c' },
93
93
  },
94
94
  {
95
95
  authuser: 1,
96
96
  accessToken: TOKEN_SLOT_1,
97
97
  expiresAt: '2099-01-01T00:00:00.000Z',
98
98
  sessionId: 'sess-slot-1',
99
- user: { id: 'user-1', username: 'bob', avatar: null, color: '#3498db' },
99
+ user: { id: 'user-1', username: 'bob', name: { displayName: 'Bob' }, avatar: null, color: '#3498db' },
100
100
  },
101
101
  ],
102
102
  };
@@ -195,4 +195,75 @@ describe('HttpService identity-scoped response cache', () => {
195
195
  await http.get('/users/u1', { cache: true });
196
196
  expect(fetchMock).toHaveBeenCalledTimes(3);
197
197
  });
198
+
199
+ /**
200
+ * The soft cache-size guard is observability-only: it must warn (once,
201
+ * throttled) when the entry count blows past the soft ceiling, but must NEVER
202
+ * evict a live entry. We synthesize bloat by giving every request a distinct
203
+ * undecodable token, so each write mints a fresh identity tag → a fresh key.
204
+ */
205
+ describe('soft cache-size telemetry guard', () => {
206
+ /** Number of distinct cached entries needed to cross the 500 soft ceiling. */
207
+ const ENTRIES_PAST_LIMIT = 520;
208
+
209
+ const newLoggingService = (): HttpService =>
210
+ new HttpService({
211
+ baseURL: 'http://test.invalid',
212
+ enableRetry: false,
213
+ requestTimeout: 1000,
214
+ enableLogging: true,
215
+ logLevel: 'warn',
216
+ });
217
+
218
+ let warnSpy: jest.SpyInstance;
219
+ beforeEach(() => {
220
+ // SimpleLogger routes `warn` to console.warn; spy there to assert telemetry.
221
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
222
+ });
223
+ afterEach(() => {
224
+ warnSpy.mockRestore();
225
+ });
226
+
227
+ it('warns (throttled) once the cache exceeds the soft entry limit and never evicts', async () => {
228
+ const http = newLoggingService();
229
+
230
+ // Each iteration: a fresh undecodable token → fresh identity tag → fresh
231
+ // key, so the cache grows by one live entry per call.
232
+ for (let i = 0; i < ENTRIES_PAST_LIMIT; i++) {
233
+ http.setTokens(`undecodable-token-${i}`);
234
+ fetchMock.mockResolvedValueOnce(jsonResponse({ i }));
235
+ await http.get('/profiles/recommendations', { cache: true });
236
+ }
237
+
238
+ // No eviction: every distinct entry is still resident.
239
+ expect(http.getCacheStats().size).toBe(ENTRIES_PAST_LIMIT);
240
+
241
+ // Telemetry fired, and exactly once within the throttle window.
242
+ const cacheWarnings = warnSpy.mock.calls.filter((args) =>
243
+ args.some(
244
+ (a: unknown) =>
245
+ typeof a === 'string' && a.includes('exceeded soft entry limit'),
246
+ ),
247
+ );
248
+ expect(cacheWarnings.length).toBe(1);
249
+ });
250
+
251
+ it('does not warn while the cache stays under the soft limit', async () => {
252
+ const http = newLoggingService();
253
+
254
+ for (let i = 0; i < 10; i++) {
255
+ http.setTokens(`undecodable-token-${i}`);
256
+ fetchMock.mockResolvedValueOnce(jsonResponse({ i }));
257
+ await http.get('/profiles/recommendations', { cache: true });
258
+ }
259
+
260
+ const cacheWarnings = warnSpy.mock.calls.filter((args) =>
261
+ args.some(
262
+ (a: unknown) =>
263
+ typeof a === 'string' && a.includes('exceeded soft entry limit'),
264
+ ),
265
+ );
266
+ expect(cacheWarnings.length).toBe(0);
267
+ });
268
+ });
198
269
  });