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