@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
|
@@ -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
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
/**
|
package/dist/types/index.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
|
|
|
33
33
|
export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
|
|
34
34
|
export type { CreateManagedAccountInput, ManagedAccountManager, ManagedAccount, } from './mixins/OxyServices.managedAccounts';
|
|
35
35
|
export type { ContactDiscoveryMatch, ContactDiscoveryResponse, } from './mixins/OxyServices.contacts';
|
|
36
|
-
export type { BulkFollowEntry, BulkFollowResult, } from './mixins/OxyServices.user';
|
|
36
|
+
export type { BulkFollowEntry, BulkFollowResult, BulkUnfollowEntry, BulkUnfollowResult, } from './mixins/OxyServices.user';
|
|
37
37
|
export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData';
|
|
38
38
|
export { getNormalizedUserId, normalizeUserIdentity, normalizeUserIdentityOrNull, } from './utils/userIdentity';
|
|
39
39
|
export { getCanonicalUserHandle, getNormalizedUserHandle, } from './utils/userHandle';
|
|
@@ -7,7 +7,30 @@ export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>
|
|
|
7
7
|
*/
|
|
8
8
|
deleteFile(fileId: string): Promise<any>;
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
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`. */
|
|
@@ -21,6 +21,22 @@ export interface BulkFollowResult {
|
|
|
21
21
|
/** Number of users newly followed by this request. */
|
|
22
22
|
followedCount: number;
|
|
23
23
|
}
|
|
24
|
+
/** Per-user outcome returned by `POST /users/unfollow/bulk`. */
|
|
25
|
+
export interface BulkUnfollowEntry {
|
|
26
|
+
/** The user ID that was processed. */
|
|
27
|
+
userId: string;
|
|
28
|
+
/** Whether the unfollow was applied (or already absent) without error. */
|
|
29
|
+
success: boolean;
|
|
30
|
+
/** Whether the caller was following this user before the request. */
|
|
31
|
+
wasFollowing: boolean;
|
|
32
|
+
}
|
|
33
|
+
/** Response shape of `POST /users/unfollow/bulk`. */
|
|
34
|
+
export interface BulkUnfollowResult {
|
|
35
|
+
/** Per-user outcomes, in request order. */
|
|
36
|
+
results: BulkUnfollowEntry[];
|
|
37
|
+
/** Number of users newly unfollowed by this request. */
|
|
38
|
+
unfollowedCount: number;
|
|
39
|
+
}
|
|
24
40
|
export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T): {
|
|
25
41
|
new (...args: any[]): {
|
|
26
42
|
/**
|
|
@@ -65,7 +81,7 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
|
|
|
65
81
|
ownerId?: string;
|
|
66
82
|
}): Promise<User>;
|
|
67
83
|
/**
|
|
68
|
-
* Get profile recommendations
|
|
84
|
+
* Get profile recommendations.
|
|
69
85
|
*
|
|
70
86
|
* Public discovery read — works WITHOUT authentication. The SDK attaches the
|
|
71
87
|
* access token automatically when one is available (personalized via
|
|
@@ -73,32 +89,26 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
|
|
|
73
89
|
* the caller is logged out. This deliberately does NOT use `withAuthRetry`,
|
|
74
90
|
* which would throw an authentication timeout for logged-out callers before
|
|
75
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.
|
|
76
110
|
*/
|
|
77
|
-
getProfileRecommendations(options?:
|
|
78
|
-
excludeTypes?: Array<"federated" | "agent" | "automated">;
|
|
79
|
-
}): Promise<Array<{
|
|
80
|
-
id: string;
|
|
81
|
-
username: string;
|
|
82
|
-
name: UserNameResponse;
|
|
83
|
-
description?: string;
|
|
84
|
-
isFederated?: boolean;
|
|
85
|
-
isAgent?: boolean;
|
|
86
|
-
isAutomated?: boolean;
|
|
87
|
-
instance?: string;
|
|
88
|
-
federation?: {
|
|
89
|
-
actorUri?: string;
|
|
90
|
-
domain?: string;
|
|
91
|
-
actorId?: string;
|
|
92
|
-
};
|
|
93
|
-
automation?: {
|
|
94
|
-
ownerId?: string;
|
|
95
|
-
};
|
|
96
|
-
_count?: {
|
|
97
|
-
followers: number;
|
|
98
|
-
following: number;
|
|
99
|
-
};
|
|
100
|
-
[key: string]: unknown;
|
|
101
|
-
}>>;
|
|
111
|
+
getProfileRecommendations(options?: RecommendationRequest): Promise<RecommendationItem[]>;
|
|
102
112
|
/**
|
|
103
113
|
* Get profiles similar to a given user, based on co-follower overlap.
|
|
104
114
|
*/
|
|
@@ -177,7 +187,14 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
|
|
|
177
187
|
message: string;
|
|
178
188
|
}>;
|
|
179
189
|
/**
|
|
180
|
-
* Follow a user
|
|
190
|
+
* Follow a user.
|
|
191
|
+
*
|
|
192
|
+
* Invalidates the cached `GET /users/<id>/follow-status` response after
|
|
193
|
+
* the write. `getFollowStatus` caches for ~1 minute (identity-scoped);
|
|
194
|
+
* without busting that entry, a `FollowButton` that remounts within the
|
|
195
|
+
* TTL window re-reads the STALE pre-write status and reverts the optimistic
|
|
196
|
+
* UI (the "follow resets after navigating away and back" bug).
|
|
197
|
+
* `clearCacheEntry` deletes every identity-scoped variant of the key.
|
|
181
198
|
*/
|
|
182
199
|
followUser(userId: string): Promise<{
|
|
183
200
|
success: boolean;
|
|
@@ -192,6 +209,15 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
|
|
|
192
209
|
* result and performs no network call.
|
|
193
210
|
*/
|
|
194
211
|
followUsers(userIds: string[]): Promise<BulkFollowResult>;
|
|
212
|
+
/**
|
|
213
|
+
* Unfollow multiple users in a single request.
|
|
214
|
+
*
|
|
215
|
+
* POSTs `/users/unfollow/bulk` with `{ userIds }` (server caps the batch at
|
|
216
|
+
* 200). Returns the per-user outcomes and the count of users newly
|
|
217
|
+
* unfollowed. An empty `userIds` array resolves immediately with an empty
|
|
218
|
+
* result and performs no network call.
|
|
219
|
+
*/
|
|
220
|
+
unfollowUsers(userIds: string[]): Promise<BulkUnfollowResult>;
|
|
195
221
|
/**
|
|
196
222
|
* Unfollow a user
|
|
197
223
|
*/
|
|
@@ -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
package/src/HttpService.ts
CHANGED
|
@@ -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 ||
|
|
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
|
-
*
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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(),
|
|
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
|
-
//
|
|
867
|
-
if (attempt <
|
|
868
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
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
|
|
894
|
-
if (decoded.exp && decoded.exp - currentTime <
|
|
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() +
|
|
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() +
|
|
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
|
};
|