@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +102 -66
- package/dist/cjs/mixins/OxyServices.assets.js +34 -2
- package/dist/cjs/mixins/OxyServices.user.js +123 -18
- package/dist/cjs/utils/cacheKey.js +87 -0
- package/dist/cjs/utils/errorUtils.js +25 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +101 -65
- package/dist/esm/mixins/OxyServices.assets.js +34 -2
- package/dist/esm/mixins/OxyServices.user.js +123 -18
- package/dist/esm/utils/cacheKey.js +82 -0
- package/dist/esm/utils/errorUtils.js +24 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +24 -16
- package/dist/types/index.d.ts +1 -1
- package/dist/types/mixins/OxyServices.assets.d.ts +24 -1
- package/dist/types/mixins/OxyServices.user.d.ts +54 -28
- package/dist/types/utils/cacheKey.d.ts +67 -0
- package/dist/types/utils/errorUtils.d.ts +12 -0
- package/package.json +1 -1
- package/src/HttpService.ts +116 -67
- package/src/__tests__/authManager.cookiePath.test.ts +2 -2
- package/src/__tests__/authManager.security.test.ts +2 -2
- package/src/__tests__/httpServiceCache.test.ts +71 -0
- package/src/index.ts +2 -0
- package/src/mixins/OxyServices.assets.ts +36 -2
- package/src/mixins/OxyServices.user.ts +167 -36
- package/src/mixins/__tests__/discoveryErrorHandling.test.ts +266 -0
- package/src/mixins/__tests__/followCacheInvalidation.test.ts +168 -0
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +83 -0
- package/src/mixins/__tests__/sso.test.ts +13 -3
- package/src/utils/__tests__/cacheKey.test.ts +0 -0
- package/src/utils/__tests__/coldBoot.test.ts +125 -0
- package/src/utils/cacheKey.ts +98 -0
- package/src/utils/errorUtils.ts +25 -0
package/dist/cjs/HttpService.js
CHANGED
|
@@ -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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 ||
|
|
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
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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(),
|
|
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
|
-
//
|
|
695
|
-
if (attempt <
|
|
696
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
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
|
|
718
|
-
if (decoded.exp && decoded.exp - currentTime <
|
|
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() +
|
|
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() +
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*/
|