/** * Unified HTTP Service * * Consolidates HttpClient + RequestManager into a single efficient class. * Uses native fetch instead of axios for smaller bundle size. * * Handles: * - Authentication (token management, auto-refresh) * - Caching (TTL-based) * - Deduplication (concurrent requests) * - Retry logic * - Error handling * - Request queuing */ import { TTLCache, registerCacheForCleanup, unregisterCacheFromCleanup } from './utils/cache'; import { RequestDeduplicator, RequestQueue, SimpleLogger } from './utils/requestUtils'; import { retryAsync } from './utils/asyncUtils'; import { awaitRequestSignal } from './utils/requestSignal'; import { createCancelledError, ErrorCodes, handleHttpError, isCancelledError, parseHttpErrorBody, } from './utils/errorUtils'; import { decodeTokenClaims } from './utils/tokenClaims'; import { isReactNative } from '@oxy.so/protocol/random'; import { computeIdentityTag, fnv1a32 } from './utils/cacheKey'; import { redactUrlQuery } from './utils/redactUrl'; import type { OxyConfig } from './models/interfaces'; import type { DeviceSecretMintOutcome } from './session/refresh'; import { OxyAuthenticationError } from './OxyServices.errors'; import { peekBrowserEdgeRegionHeader } from './utils/edgeRegion'; import { getBrowserActivityIdHeader } from './utils/activityId'; import { requiresCorsPreflight } from './utils/corsPreflight'; import { trimLeadingSlashes, trimTrailingSlashes } from './utils/slashes'; /** Secret proofs that must remain within the configured API origin. */ const REQUESTER_PROOF_HEADERS = new Set([ 'x-oxy-requester-token', 'x-oxy-task-id', 'x-oxy-capability-ticket', 'x-oxy-mcp-media-token', 'x-oxy-requester-context', 'x-oxy-presented-requester-context', 'x-oxy-presented-service-token', ]); export type AuthRefreshReason = 'preflight' | 'response-401'; export type AuthRefreshHandler = (reason: AuthRefreshReason) => Promise; export type AccessTokenProvider = () => string | null; /** * Supplies a bearer for a request made while this client holds NO user session * — `null` to send it anonymous. See {@link HttpService.setAnonymousAuthProvider}. */ export type AnonymousAuthProvider = () => Promise; /** Comparison only fences local async work; it never validates token authority. */ function sameTokenSession(left: string | null, right: string | null): boolean { if (left === right) return true; if (!left || !right) return false; const a = decodeTokenClaims(left); const b = decodeTokenClaims(right); if (!a || !b) return false; if (a.type === 'service' || b.type === 'service') { if (a.type !== 'service' || b.type !== 'service') return false; return ( ['appId', 'credentialId', 'ownerAccountId', 'environment', 'aud', 'iss'].every( (key) => typeof a[key] === 'string' && a[key] !== '' && a[key] === b[key], ) && a.authority === b.authority && JSON.stringify(a.scopes) === JSON.stringify(b.scopes) ); } if (!(a.userId || a.id || a.sub) || !(b.userId || b.id || b.sub)) return false; // A refresh may rotate exp/iat/jti while preserving its account and session. // Opaque/malformed context fields cannot establish that equivalence. return [ 'userId', 'id', 'sub', 'sessionId', 'operatorId', 'clientId', 'appId', 'authMethodId', ].every((key) => (a[key] === undefined || typeof a[key] === 'string') && a[key] === b[key]); } /** A trusted platform transport, for example Expo's streaming fetch. */ export type ResponseTransport = (input: string, init: RequestInit) => Promise; /** * A low-level same-origin request whose response body remains unread. * Only absent/string request bodies can be replayed after a 401. Multipart, * binary and stream bodies are sent once, even if their platform can replay them. * The caller owns response parsing and the AbortSignal for its full lifetime. */ export interface ResponseRequest { method: 'GET' | 'HEAD' | 'OPTIONS' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; url: string; body?: BodyInit; headers?: HeadersInit; signal?: AbortSignal; /** Must honour the supplied URL, headers, signal and redirect policy. */ fetch?: ResponseTransport; } /** Same response contract, but rejects locally when the SDK has no bearer. */ export type AuthenticatedResponseRequest = ResponseRequest; /** * Structural type that captures the multipart-write surface every supported * FormData implementation exposes (browser, React Native, Node `form-data` * polyfill, jsdom, undici, etc). We type-narrow against this in * `isFormData()` so callers don't have to know which runtime produced the * value. * * Deliberately mirrored from the lib.dom `FormData` interface — kept as a * local type because @types/node and @types/react-native model FormData * differently and a single import wouldn't be safe in both bundles. */ interface FormDataLike { append(name: string, value: unknown, fileName?: string): void; delete(name: string): void; get(name: string): unknown; getAll(name: string): unknown[]; has(name: string): boolean; } export interface RequestOptions { cache?: boolean; cacheTTL?: number; deduplicate?: boolean; retry?: boolean; maxRetries?: number; /** * Retry an attempt that hit {@link timeout}. Default `false`. * * Off by default because a timeout usually means the SERVER is slow, so more * attempts add load to the thing already struggling — and because retrying * one behind a short per-attempt timeout is how a single slow endpoint used * to cost ~28s of wall clock. Turn it on for an idempotent read where a * retry is genuinely likely to help, and pair it with {@link deadline}. */ retryOnTimeout?: boolean; /** * Wall-clock budget in ms for the WHOLE call, retries and backoff included. * * {@link timeout} bounds one attempt; this bounds the call. Without it the * total is `attempts x timeout + backoff`, which is arithmetic nobody does * at the call site — so a caller who wants "never more than 8 seconds" can * say that instead of deriving it. */ deadline?: number; timeout?: number; signal?: AbortSignal; headers?: Record; responseType?: 'blob'; /** * Skip BOTH the bearer auth header (and its near-expiry preflight refresh) * AND the 401-driven auto-refresh/retry for this request. * * Required for the body-authenticated device-secret mint (`POST * /session/device/token`): it does not need a bearer, and — critically — it is * itself invoked from inside the registered `AuthRefreshHandler`. If it went * through the normal preflight, `getAuthHeader` would call * `refreshAccessToken` while the handler-owning `tokenRefreshPromise` is * still in flight and await ITSELF (deadlock). Skipping auth makes the * mint call fully independent of the current (near-expired) bearer. */ skipAuth?: boolean; /** * Execute this request WITHOUT taking a `RequestQueue` slot — run it directly. * * A queue slot represents network occupancy for ordinary DATA requests. The * CONTROL-PLANE calls the auth lane depends on (the device-secret mint, `POST * /session/device/token`, reached from `getAuthHeader` → `refreshAccessToken`) * must never compete for a slot: when `maxConcurrentRequests` requests are all * parked awaiting that very mint, a queued mint could never acquire a slot to * run — a systemic deadlock. `bypassQueue` lets the mint run even when the * queue is saturated. (The auth preflight is also resolved OUTSIDE the slot in * `request()`, so ordinary requests never hold a slot while awaiting the mint; * this flag is the explicit guarantee for the mint itself.) */ bypassQueue?: boolean; } interface RequestConfig extends RequestOptions { method: 'GET' | 'HEAD' | 'OPTIONS' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; url: string; data?: unknown; params?: Record; /** @internal Used to prevent infinite auth retry loops */ _isAuthRetry?: boolean; } /** * The keys whose presence beside `data` makes a body a PAGE rather than a * payload — see {@link HttpService.unwrapResponse} for why this list is narrow. * * - `pagination` — the offset-paginated house envelope (`sendPaginated`). * - `nextCursor` — the keyset-paginated one (the account audit trails). * * Membership is decided by key PRESENCE, never by value: the last page sends * `nextCursor: null`, and an envelope that collapsed into a bare payload * exactly when the stream ended would be a worse bug than the one this fixes. */ const PAGE_ENVELOPE_KEYS = ['pagination', 'nextCursor'] as const; /** `T`'s declared keys — an index signature (`Record`) declares none. */ type DeclaredKeys = keyof { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K]; }; /** * `never` for the house `{ data }` envelope that is not a page; `unknown` * (no-op in an intersection) for everything else. See {@link ResponsePayload}. */ type EnvelopeGuard = T extends object ? 'data' extends DeclaredKeys ? [Extract, (typeof PAGE_ENVELOPE_KEYS)[number]>] extends [never] ? never : unknown : unknown : unknown; /** * What a request typed `T` resolves to: `T` itself — unless `T` is the house * `{ data }` envelope, which resolves to `never`. * * `T` names what the transport DELIVERS, never the body the route sends: * {@link HttpService.unwrapResponse} reduces `{ data: X }` to `X` before the * caller sees it. A call site typed `request<{ data: X }>` that then read * `.data` was reading it off `X` — `undefined` on every call, with no error * anywhere; that is how `users.moderators()` answered `[]` for every * community. As `never`, reading anything off such a result is a compile * error (`Property 'data' does not exist on type 'never'`): type the payload. * * It follows the runtime rule: `data` beside a {@link PAGE_ENVELOPE_KEYS} key * is a page, which travels whole and is typed whole. `unknown`, arrays, * primitives, index signatures and every shape without a declared `data` key * are unchanged. An intersection rather than a conditional, so it stays * assignable to `T`: a generic wrapper that forwards its own `T` and returns * `Promise` compiles as before (it can declare `Promise>` * to carry the check to its own callers). */ export type ResponsePayload = T & EnvelopeGuard; /** Methods whose calls concurrent callers may share (read-only). */ const SAFE_METHODS: ReadonlySet = new Set(['GET']); /** Methods safe to re-send after a transient failure (RFC 9110 §9.2.2). */ const IDEMPOTENT_METHODS: ReadonlySet = new Set(['GET', 'PUT', 'DELETE']); /** * Default per-request timeout (ms) when neither the call site nor * {@link OxyConfig.requestTimeout} overrides it. Kept tight so a stalled * endpoint surfaces as an `AbortError` quickly rather than blocking the * request queue. */ const DEFAULT_REQUEST_TIMEOUT_MS = 5000; /** * Cooldown (ms) applied after a failed access-token refresh before another * refresh is attempted while the CURRENT token is still valid (a proactive, * near-expiry refresh). Prevents a refresh storm (and server hammering) when * the auth refresh handler is failing — every in-flight request that * hits a 401 would otherwise trigger its own refresh. A still-valid token can * afford to wait this out; the request keeps carrying it in the meantime. */ const TOKEN_REFRESH_COOLDOWN_MS = 15000; /** * Cooldown (ms) applied after a failed refresh when the CURRENT access token is * already past its `exp`. Much shorter than {@link TOKEN_REFRESH_COOLDOWN_MS}: * an expired token is UNUSABLE, so the client must re-mint as soon as the mint * endpoint is reachable again (e.g. a few seconds after an ECS rolling-deploy * blip drains/restarts a task) instead of waiting out the full proactive * cooldown while every request forwards or omits a stale bearer → server 401. * * Still NON-ZERO on purpose: it bounds the request-driven retry rate to at most * one attempt per this interval so a PROLONGED outage cannot become a tight * network storm. Combined with the process-wide single-flight below (concurrent * requests coalesce to one in-flight mint) and the refresh handler's own * terminal-state handling (a genuinely revoked session clears its device * credential and stops issuing network mints), this recovers a transient blip * ~15× faster without weakening the storm guard. */ const EXPIRED_TOKEN_REFRESH_COOLDOWN_MS = 1000; /** * Cooldown (ms) applied after the refresh endpoint answered 429 Too Many * Requests. Overrides BOTH cooldowns above, because neither of them is about * this failure. * * The other two ask "is the current token still usable?" and answer a server * that is unreachable or erroring. A 429 is the opposite situation: the server * is perfectly reachable and is telling this client, specifically, that it is * asking too often. `EXPIRED_TOKEN_REFRESH_COOLDOWN_MS` then becomes actively * harmful — an expired token drives the request-time preflight and the 401 * retry lanes at one mint per second (60/min) against a mint budget of 30/min, * so the client's own retries hold the limiter tripped and it can never fall * back under the ceiling. The limit stops being self-clearing and becomes * self-sustaining for as long as the app keeps issuing requests. * * Sized to the mint limiter's own window (60s, `packages/api/src/routes/sessionDevice.ts`) * so ONE attempt lands per window: enough to discover the moment the budget has * drained, never enough to consume it. The per-device lockout answers 429 for * far longer (15 min), and that is fine — one probe a minute against it is * negligible traffic and the session recovers the instant the lockout lifts. * * NOT routed through the scheduler's exponential backoff: that backoff is * effect-local to `startTokenRefreshScheduler` and governs only the scheduled * lane, while the lanes that actually produce the storm here (preflight, 401 * retry) are request-driven and are throttled solely by this cooldown. */ const RATE_LIMITED_REFRESH_COOLDOWN_MS = 60_000; /** * Lead time (seconds) before access-token expiry at which a preflight refresh * is triggered. A token within this window of `exp` is treated as effectively * expired so the request carries a fresh bearer rather than racing the clock. */ const TOKEN_REFRESH_LEAD_SECONDS = 60; /** * Whether `token` is a JWT that is still valid but already inside the refresh * lead window. `false` for an expired, opaque or no-`exp` token. */ function isInsideRefreshLeadWindow(token: string): boolean { const decoded = decodeTokenClaims(token); if (typeof decoded?.exp !== 'number') { return false; } const now = Math.floor(Date.now() / 1000); // `<=`: the proactive scheduler fires at exactly `exp - lead`. return decoded.exp > now && decoded.exp - now <= TOKEN_REFRESH_LEAD_SECONDS; } /** * Soft ceiling on the number of live entries in the identity-scoped GET * response cache. Crossing it does NOT evict anything (the {@link TTLCache} * still expires by TTL and is swept on its cleanup interval) — it emits a * single throttled telemetry warning via the logger so an unbounded-growth * regression (e.g. an endpoint that mints a fresh identity tag per request, or * a cache-key that accidentally folds in volatile data) is observable in the * field instead of silently consuming memory. Tuned well above the working set * a single authenticated user generates in normal use. */ const CACHE_SOFT_MAX_ENTRIES = 500; /** * Minimum interval (ms) between successive cache-size telemetry warnings, so a * cache that sits above the soft limit logs at most once per window rather than * on every cached write. */ const CACHE_SIZE_WARNING_THROTTLE_MS = 60000; /** * Token store for authentication (instance-based) * Each HttpService gets its own TokenStore to prevent conflicts * when multiple OxyServices instances coexist server-side. */ class TokenStore { private accessToken: string | null = null; setTokens(accessToken: string): void { this.accessToken = accessToken; } getAccessToken(): string | null { return this.accessToken; } clearTokens(): void { this.accessToken = null; } hasAccessToken(): boolean { return !!this.accessToken; } } /** * Propagate `parent`'s abort to `child`, returning a disposer. * * Three things a bare `parent.addEventListener('abort', ...)` gets wrong, all * of which this exists to stop repeating: * * 1. **An already-aborted parent never fires again.** `abort` is a once-only * event, so linking to a signal that has already fired silently links to * nothing — which is how a cancelled request got re-issued on every retry. * The `aborted` pre-check is the whole point. * 2. **The listener outlives the request.** A long-lived caller signal (one * React Query owns for a query's lifetime, say) accumulates one listener per * attempt per request with nothing ever removing them. * 3. **The reason is dropped**, so the child cannot tell WHY it was aborted. * * `AbortSignal.any` does all this natively and is not used: Hermes support is * not safe to assume, and this SDK ships to React Native. The pattern is the * one `uploadViaXHR` already had right; this is that code, shared. */ function isAbortLike(error: unknown): boolean { return ( typeof error === 'object' && error !== null && (error as { name?: unknown }).name === 'AbortError' ); } function linkAbort(parent: AbortSignal, child: AbortController): () => void { if (parent.aborted) { child.abort((parent as AbortSignal & { reason?: unknown }).reason); return () => { /* nothing was ever attached */ }; } const onAbort = (): void => { child.abort((parent as AbortSignal & { reason?: unknown }).reason); }; parent.addEventListener('abort', onAbort, { once: true }); return () => parent.removeEventListener('abort', onAbort); } /** * Unified HTTP Service * * Consolidates HttpClient + RequestManager into a single efficient class. * Uses native fetch instead of axios for smaller bundle size. */ export class HttpService { private baseURL: string; private tokenStore: TokenStore; // Only active calls retain a lease. Invalidation detaches matching leases, // so late results cannot warm the cache or attract new deduplicated callers. private readonly readLeases = new Map< string, { key: string; id: number; valid: boolean; readers: number } >(); private nextReadLeaseId = 0; private cache: TTLCache; /** * When true, the per-instance GET response cache is OFF: GET responses are * never read from nor written to {@link cache}, so every request hits the * network. Set from `config.enableCache === false` OR `config.cacheTTL <= 0`. * A disabled instance does not register its cache for the global cleanup * interval (nothing ever lands in it). Request deduplication is unaffected — * concurrent identical in-flight requests still collapse into one. */ private readonly cacheDisabled: boolean; private deduplicator: RequestDeduplicator; private requestQueue: RequestQueue; private logger: SimpleLogger; private config: OxyConfig; private tokenRefreshPromise: Promise | null = null; /** * Epoch ms of the last FAILED refresh (0 = none since the last success). The * post-failure cooldown is measured from here; its length depends on whether * the current token is still valid ({@link TOKEN_REFRESH_COOLDOWN_MS}) or * already expired ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}), so an expired * token recovers promptly the instant it crosses `exp` — without storing a * fixed deadline that could not shrink once the token expired mid-cooldown. */ private lastRefreshFailureAt = 0; /** * Whether the last refresh failure was the server answering 429. Selects * {@link RATE_LIMITED_REFRESH_COOLDOWN_MS} over the two token-state cooldowns * for the NEXT attempt, and is cleared by any success or any other failure. * * A boolean beside {@link lastRefreshFailureAt} rather than a stored deadline, * for the same reason that field gives: the cooldown stays a function of the * current state, so it can still shorten if the situation changes underneath * it, and there is exactly one timestamp to reason about. */ private lastRefreshWasRateLimited = false; /** * The access token the last SUCCESSFUL refresh handed back while it was still * inside the refresh lead window, or `null`. * * The device mint (`POST /session/device/token`) answers with the session's * STORED access token for as long as that token has not expired — it only * mints a new one once the old one is past `exp`. A client that re-mints * {@link TOKEN_REFRESH_LEAD_SECONDS} before `exp` therefore gets the SAME * token back, and because that counts as a success (no cooldown), every * following request re-ran the preflight and minted again: one mint per * request for the last minute of every token, which exhausted the server's * 30/min mint budget. The 429 that followed imposed a 60s cooldown that * straddled the token's real expiry, so the next request went out with no * bearer, drew a 401, found the refresh cooling down, and signed the user out. * * Remembering the answer ends that: while the current token IS this value and * has not yet expired, the server has already said it has nothing fresher, so * the preflight sends the still-valid token instead of asking again. Once the * token crosses `exp` the preflight asks once more and the server rotates. */ private noFresherTokenThan: string | null = null; private authRefreshHandler: AuthRefreshHandler | null = null; private accessTokenProvider: AccessTokenProvider | null = null; private anonymousAuthProvider: AnonymousAuthProvider | null = null; private deviceSecretMintInFlight: Promise | null = null; /** * Bumped by every {@link endSession}. A re-mint captures it before its first * await and plants nothing if it moved: a refresh that was already in flight * when the user signed out would otherwise complete a moment later and put a * bearer — and through the token listeners, the account — back. */ private sessionEpoch = 0; /** * Set by {@link endSession}, cleared by the next {@link setTokens}. Read by the * native Commons-identity re-mint arm: after a sign-out a 401 is the expected * answer, not a cue to sign back in with the identity key. The device-secret * arm is NOT gated on it — minting from a credential the store still holds is * how a signed-out tab joins a sign-in made in another tab. */ private sessionEnded = false; /** * Epoch (ms) before which a cache-size telemetry warning must not be * re-emitted. Throttles the {@link CACHE_SOFT_MAX_ENTRIES} warning to at most * one per {@link CACHE_SIZE_WARNING_THROTTLE_MS} window. */ private cacheSizeWarningSilentUntil = 0; /** * Fan-out listeners notified on EVERY access-token change on this instance: * explicit `setTokens`, `clearTokens`, a refresh-handler rotation, and the * internal 401-driven clear. This is a Set so multiple independent observers * can mirror token state without clobbering each other. * * Each listener receives the resulting access token, or `null` when cleared. */ private _tokenChangeListeners = new Set<(accessToken: string | null) => void>(); // Performance monitoring private requestMetrics = { totalRequests: 0, successfulRequests: 0, failedRequests: 0, cacheHits: 0, cacheMisses: 0, averageResponseTime: 0, }; constructor(config: OxyConfig) { this.config = config; this.baseURL = config.baseURL; this.tokenStore = new TokenStore(); this.logger = new SimpleLogger( config.enableLogging || false, config.logLevel || 'error', 'HttpService', ); // Initialize performance infrastructure. The per-instance GET response // cache is disabled when the consumer explicitly opts out // (`enableCache: false`) or asks for a non-positive TTL (`cacheTTL <= 0`). // When disabled, nothing is ever stored, so there is no reason to register // the cache for the global cleanup interval. Default (config unset) keeps // caching ON with the 5-minute TTL — unchanged for existing consumers. this.cacheDisabled = config.enableCache === false || (typeof config.cacheTTL === 'number' && config.cacheTTL <= 0); this.cache = new TTLCache( config.cacheTTL && config.cacheTTL > 0 ? config.cacheTTL : 5 * 60 * 1000, ); if (!this.cacheDisabled) { registerCacheForCleanup(this.cache); } this.deduplicator = new RequestDeduplicator(); this.requestQueue = new RequestQueue( config.maxConcurrentRequests || 10, config.requestQueueSize || 100, ); } private syncAccessTokenFromProvider(): string | null { if (!this.accessTokenProvider) { return this.tokenStore.getAccessToken(); } const providedToken = this.accessTokenProvider(); const currentToken = this.tokenStore.getAccessToken(); if (providedToken) { if (providedToken !== currentToken) { this.setTokens(providedToken); } return providedToken; } if (currentToken) { this.clearTokens(); } return null; } /** * Robust FormData detection that works in browser, React Native, and * Node.js polyfill environments. * * Why we don't use `instanceof FormData` alone: * - React Native's FormData is a separate class, not the browser one — * `instanceof FormData` is true only inside the JS runtime that * instantiated the value (browser-side polyfills also have their own). * - The Node.js `form-data` polyfill ships its own constructor. * * Why we explicitly reject `URLSearchParams`: * - `URLSearchParams` ALSO exposes `append` / `get` / `has`, so the * duck-type fallback below would have misidentified it as FormData and * sent an empty multipart body. * - It has its own encoding path instead — see {@link isUrlSearchParams}. */ private isFormData(data: unknown): data is FormDataLike { if (!data || typeof data !== 'object') { return false; } // Reject URLSearchParams up front: it shares the duck-typed surface // (append / get / has) but is a fundamentally different content type. // The caller routes URLSearchParams through the regular body path. if (typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams) { return false; } // Primary check: instanceof FormData. Works whenever the value was // constructed by the same runtime/realm that exposes `FormData`. if (typeof FormData !== 'undefined' && data instanceof FormData) { return true; } // Fallback: detect Node / RN polyfills by constructor name. Limited to // the small handful of known names so we don't accept arbitrary // user-supplied objects with a coincidental `name`. const constructorName = data.constructor?.name; if (constructorName === 'FormData' || constructorName === 'FormDataImpl') { return true; } // Last-resort duck typing — require the full FormData write surface // (`append`, `get`, `has`, `getAll`, `delete`) so plain objects with // an `append` method don't accidentally match. const candidate = data as Partial>; return ( typeof candidate.append === 'function' && typeof candidate.get === 'function' && typeof candidate.has === 'function' && typeof candidate.getAll === 'function' && typeof candidate.delete === 'function' ); } /** * True for an `application/x-www-form-urlencoded` payload. * * Needed because a handful of endpoints are defined by a standard that fixes * their request encoding rather than by our own JSON conventions — today * `POST /auth/oauth/token`, whose encoding RFC 6749 §4.1.3 mandates. Passing * a `URLSearchParams` as `data` selects that encoding; everything else is * still JSON. */ private isUrlSearchParams(data: unknown): data is URLSearchParams { return typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams; } /** * A raw byte body — `Uint8Array` (Node's `Buffer` included), `ArrayBuffer`, * or `Blob` — sent exactly as given, with the caller's `Content-Type`. The * service-token stream uploads (`/assets/service/app`, `/service/user-media`) * take the file's bytes as the whole body, not as JSON or multipart. */ private isBinaryBody(data: unknown): data is ArrayBuffer | ArrayBufferView | Blob { if (typeof ArrayBuffer !== 'undefined') { if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) return true; } return typeof Blob !== 'undefined' && data instanceof Blob; } /** * One request to the API, resolving to what the route's body DELIVERS: the * payload of a `{ data }` envelope, a page whole — see * {@link ResponsePayload}, which makes reading the envelope a compile error. */ async request(config: RequestConfig): Promise> { // Per-call authority is not represented by the instance's session cache // key. Never share or retain responses authenticated by explicit headers. if ( Object.keys(config.headers ?? {}).some( (key) => ['authorization', 'x-oxy-user-id'].includes(key.toLowerCase()) || REQUESTER_PROOF_HEADERS.has(key.toLowerCase()), ) ) { config = { ...config, headers: { ...config.headers }, cache: false, deduplicate: false }; } if ( Object.keys(config.headers ?? {}).some((key) => REQUESTER_PROOF_HEADERS.has(key.toLowerCase()), ) ) { const target = new URL(this.buildURL(config.url)); if (target.origin !== new URL(this.baseURL).origin || target.username || target.password) { throw new Error('Requester proofs must target the configured API origin'); } } const cache = (config.cache ?? config.method === 'GET') && !this.cacheDisabled; const deduplicate = config.deduplicate ?? SAFE_METHODS.has(config.method); if (!cache && !deduplicate) { return this.requestWithCacheLease>(config, { key: '', id: 0, valid: true, }); } const key = this.generateCacheKey(config.method, config.url, config.data || config.params); let lease = this.readLeases.get(key); if (!lease) { lease = { key, id: ++this.nextReadLeaseId, valid: true, readers: 0 }; this.readLeases.set(key, lease); } lease.readers++; try { return await this.requestWithCacheLease>(config, lease); } finally { lease.readers--; if (lease.readers === 0 && this.readLeases.get(key) === lease) this.readLeases.delete(key); } } private async requestWithCacheLease( config: RequestConfig, lease: { key: string; id: number; valid: boolean }, ): Promise { const { method, url, data, params, timeout = this.config.requestTimeout || DEFAULT_REQUEST_TIMEOUT_MS, signal, cache: cacheRequested = method === 'GET', cacheTTL, // Only reads are shared by default. A write's body can be opaque // (`FormData`, `URLSearchParams` key as empty objects), so two different // uploads used to collapse into one call and the second caller got the // first file's result. deduplicate = SAFE_METHODS.has(method), // Only idempotent methods are re-sent by default: re-sending a POST after a // 5xx or a dropped connection can apply it twice. retry = this.config.enableRetry !== false && IDEMPOTENT_METHODS.has(method), maxRetries = this.config.maxRetries || 3, } = config; // A per-instance disabled cache (`enableCache:false` / `cacheTTL<=0`) // overrides any per-request `cache:true`: nothing is read from nor written // to the response cache. Request deduplication below is unaffected. const cache = cacheRequested && !this.cacheDisabled; // Generate cache key (optimized for large objects) const cacheKey = cache ? lease.key : null; // Check cache first if (cache && cacheKey) { const cached = this.cache.get(cacheKey) as T | null; if (cached !== null) { this.requestMetrics.cacheHits++; // Redact the query string: an asset stream URL passed here carries a // scoped `mt=` media token that must never reach a log sink. this.logger.debug('Cache hit:', redactUrlQuery(url)); return cached; } this.requestMetrics.cacheMisses++; } // Resolve the auth preflight OUTSIDE the queue slot. A slot represents // NETWORK OCCUPANCY only. `getAuthHeader` may await the single-flight token // refresh/mint — itself a request — so if a slot-holding request awaited it // here and every slot were held by requests all waiting on that same mint, // the mint could never acquire a slot to run (systemic deadlock). Resolving // it before enqueue means auth-blocked requests hold NO slot while the shared // mint runs. `skipAuth` requests (the body-authenticated mint) send NO bearer // and skip the near-expiry preflight — see RequestOptions.skipAuth. The // 401 retry re-enters request() with a fresh config, so it re-resolves // this here with the refreshed token. const authHeader = config.skipAuth ? null : await this.getAuthHeader(); // Never awaited: the PoP is a hint, and waiting on its trace fetch put up // to a second in front of requests. const edgeRegionHeader = peekBrowserEdgeRegionHeader(); const activityIdHeader = getBrowserActivityIdHeader(); // A request the caller has ALREADY abandoned takes no queue slot and makes // no network call. Checked here, before enqueue, because a slot is the // scarce resource: ten of them, and a dead request holding one is a live // request waiting for nothing. if (signal?.aborted) { throw createCancelledError('Request cancelled before it was sent'); } // One network attempt, inside the call's abort domain `domain`: the caller's // own signal, or — when deduplicated — the shared work's, which aborts only // once every caller waiting on it has cancelled. const attempt = async (domain: AbortSignal): Promise => { const startTime = Date.now(); // Re-checked per attempt so no retry can issue a network call after the // caller gave up — belt to `retryAsync`'s braces, and the one that holds // if a custom `shouldRetry` ever says yes to a cancellation. if (domain.aborted) { throw createCancelledError('Request cancelled'); } /** * Whether OUR timer fired, so a timeout can be told apart from a * cancellation. Both reach us from `fetch` as an indistinguishable * `AbortError`, and collapsing them is what let the retry predicate treat * an abandoned request as a transient failure. */ let timedOut = false; // This attempt's controller carries its own timeout and inherits the // call-wide abort domain. Declared OUTSIDE the `try` so the `finally` // that releases them can see them — the timer and the listener are // exactly what leaked when they lived inside it. const controller = new AbortController(); const disposeCallLink = linkAbort(domain, controller); const timeoutId = timeout ? setTimeout(() => { timedOut = true; controller.abort(); }, timeout) : null; try { // Build URL with params const fullUrl = this.buildURL(url, params); // Determine if data is FormData using robust detection const isFormData = this.isFormData(data); const isUrlEncoded = this.isUrlSearchParams(data); const isBinary = !isFormData && this.isBinaryBody(data); // Build headers - start with defaults const headers: Record = { Accept: 'application/json', }; // Content-Type describes a body, so a GET (which never sends one) gets // none: `application/json` is not a CORS-safelisted type, and on a GET it // only made the browser preflight a request with nothing to describe. // FormData sets its own multipart type with the boundary. if (method !== 'GET') { if (isUrlEncoded) { headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8'; } else if (isBinary) { // The caller's `Content-Type` (merged below) names the bytes. headers['Content-Type'] = 'application/octet-stream'; } else if (!isFormData) { headers['Content-Type'] = 'application/json'; } } // Add authorization header if available if (authHeader) { headers.Authorization = authHeader; } // Merge custom headers if provided if (config.headers) { for (const [key, value] of Object.entries(config.headers)) { // For FormData, explicitly remove Content-Type if user tries to set it // The browser/fetch API will set it automatically with the boundary if (isFormData && key.toLowerCase() === 'content-type') { this.logger.debug( 'Ignoring Content-Type header for FormData - will be set automatically', ); continue; } headers[key] = value; } } // This is Cloudflare's coarse serving PoP (for example `mad`), never an // IP or coordinate. Set it after caller headers so request code cannot // accidentally or deliberately substitute a different origin. Both are // advisory telemetry, so they ride only on a request the browser // preflights anyway: on an anonymous GET they were the only thing that // made it non-simple, and every distinct URL paid an OPTIONS round trip. if (requiresCorsPreflight(method, Object.entries(headers))) { Object.assign(headers, edgeRegionHeader); Object.assign(headers, activityIdHeader); } // `URLSearchParams` is serialised explicitly rather than handed to // `fetch` as-is: RN's fetch does not consistently encode it, and doing // it here keeps the body identical across every platform. const bodyValue = method !== 'GET' && data ? isFormData || isBinary ? data : isUrlEncoded ? data.toString() : JSON.stringify(data) : undefined; // React Native FormData workaround: // Expo SDK 56's "winter fetch" rejects RN file descriptors `{uri, type, name}` // in FormDataPart conversion (`Unsupported FormDataPart implementation`). // RN's native XMLHttpRequest handles those descriptors correctly, so we // route multipart uploads through XHR on RN only. JSON, text, etc. still // use fetch on every platform. const useXhrForUpload = isFormData && isReactNative() && typeof XMLHttpRequest !== 'undefined'; // XHR cannot disable redirects. Delegated requester proofs belong to // server fetch transports, where redirect:error is enforceable. if ( useXhrForUpload && Object.keys(headers).some((key) => REQUESTER_PROOF_HEADERS.has(key.toLowerCase())) ) { throw new Error('Requester proofs require a transport that rejects redirects'); } const response = useXhrForUpload ? await this.uploadViaXHR( fullUrl, method, headers, bodyValue as FormData, controller.signal, timeout, false, ) : await fetch(fullUrl, { method, headers, body: bodyValue as BodyInit | null | undefined, signal: controller.signal, credentials: 'omit', redirect: 'error', }); // Cleared as early as possible; the `finally` below is what GUARANTEES // it, including on every throw between here and there. if (timeoutId) clearTimeout(timeoutId); // Handle response if (!response.ok) { // On 401, delegate to the installed auth refresh handler and retry // once before giving up. HttpService deliberately does not know any // session routes; the refresh handler owns session rotation. if (response.status === 401 && !config._isAuthRetry && !config.skipAuth) { const refreshed = await this.refreshAccessToken('response-401'); if (refreshed) { // `deduplicate: false` is REQUIRED on the retry. This re-issue runs // while the ORIGINAL request is still in-flight under its dedupe // key; the refreshed token is // for the SAME user, so the identity-scoped key is UNCHANGED — a // deduplicated retry would resolve to the still-pending original // and await itself (deadlock). Opting the retry out of dedupe makes // it a fresh request. // // `bypassQueue: true` is REQUIRED too: this attempt still holds its // queue slot, so a queued retry waits for a slot while holding one. // With every slot held by a 401 (a revoked session under load) no // retry could ever start and the whole client stalled. return this.request({ ...config, _isAuthRetry: true, retry: false, deduplicate: false, bypassQueue: true, }); } // Refresh failed or no token — clear tokens this.tokenStore.clearTokens(); this.notifyTokenChange(); } // Read the error body (may be absent, non-JSON, empty or malformed). // Anything unreadable leaves `errorBody` undefined and degrades to the // status-based message — an error path that throws its own error is // worse than the error it was reporting. let errorBody: unknown; const errorContentType = response.headers.get('content-type'); if (errorContentType?.includes('application/json')) { try { errorBody = await response.json(); } catch (parseError) { // Malformed JSON or empty response - use status text this.logger.warn('Failed to parse error response JSON:', parseError); } } // `parseHttpErrorBody` handles every envelope in use, including the // nested `{ error: { code, message } }` shape — assigning that nested // OBJECT as the message is what produced `"[object Object]"`. const parsed = parseHttpErrorBody(errorBody); const error = new Error( parsed.message ?? `HTTP ${response.status}: ${response.statusText}`, ) as Error & { status?: number; code?: string; details?: Record; response?: { status: number; statusText: string; data?: unknown }; }; error.status = response.status; error.response = { status: response.status, statusText: response.statusText, data: errorBody, }; // Only set `code`/`details` when the server actually sent them. // Assigning `undefined` would still create the property, which changes // how `handleHttpError` classifies the error downstream. if (parsed.code !== undefined) { error.code = parsed.code; } if (parsed.details !== undefined) { error.details = parsed.details; } throw error; } // Handle different response types (optimized - read response once) const contentType = response.headers.get('content-type'); let responseData: unknown; if (config.responseType === 'blob') { responseData = await response.blob(); } else if (contentType?.includes('application/json')) { // Use response.json() directly for better performance try { responseData = await response.json(); // Handle null/undefined responses if (responseData === null || responseData === undefined) { responseData = null; } else { // Unwrap standardized API response format for JSON responseData = this.unwrapResponse(responseData); } } catch (parseError) { // Handle malformed JSON or empty responses gracefully // Note: Once response.json() is called, the body is consumed and cannot be read again // So we check the error type to determine if it's empty or malformed if (parseError instanceof SyntaxError) { this.logger.warn('Failed to parse JSON response (malformed or empty):', parseError); // SyntaxError typically means empty or malformed JSON // For empty responses, return null; for malformed JSON, throw descriptive error responseData = null; // Treat as empty response for safety } else { this.logger.warn('Failed to read response:', parseError); throw new Error('Failed to read response from server'); } } } else if ( contentType && (contentType.includes('application/octet-stream') || contentType.includes('image/') || contentType.includes('video/') || contentType.includes('audio/')) ) { // For binary responses (blobs), return the blob directly without unwrapping responseData = await response.blob(); } else { // For other responses, return as text const text = await response.text(); responseData = text || null; } const duration = Date.now() - startTime; this.updateMetrics(true, duration); this.config.onRequestEnd?.(url, method, duration, true); return responseData as T; } catch (error: unknown) { const duration = Date.now() - startTime; this.updateMetrics(false, duration); this.config.onRequestEnd?.(url, method, duration, false); this.config.onRequestError?.( url, method, error instanceof Error ? error : new Error(String(error)), ); // An abort is the one failure whose CAUSE the error cannot carry on its // own: `fetch` reports a caller cancellation and our own timeout as the // same `AbortError`. `timedOut` is set by the timer callback, so it is // the only thing here that knows which happened — and getting this // wrong is not cosmetic. Both used to collapse to `TIMEOUT` with // `status: 0`, and because `status: 0` is not 4xx the retry predicate // read every cancellation as a transient failure worth another try. // Tested by NAME, not `instanceof Error`. `fetch` rejects an abort with a // `DOMException`, and `instanceof` is realm-bound: the exception can be // constructed in a different realm from the `Error` this module closes // over (a Jest VM context, a worker, an iframe), in which case the check // silently fails and the abort is misreported as a generic 500. That was // happening; a structural check cannot be fooled that way. if (isAbortLike(error)) { if (timedOut) { const timeoutError = new Error(`Request timed out after ${timeout}ms`) as Error & { status?: number; code?: string; timeout?: boolean; }; timeoutError.status = 0; timeoutError.code = ErrorCodes.TIMEOUT; timeoutError.timeout = true; throw timeoutError; } throw createCancelledError('Request cancelled'); } throw handleHttpError(error); } finally { // Both of these leaked before: `clearTimeout` sat on the success path // only, so any throw between the fetch and the return (a 404, a parse // failure) left the timer armed; and nothing ever removed the abort // listener, so a long-lived caller signal collected one per attempt. if (timeoutId) clearTimeout(timeoutId); disposeCallLink(); } }; // Composition: dedupe(retry(queue(attempt))). A queue slot is network // occupancy only — each attempt takes one and releases it — so neither the // retry backoff sleep nor a deduplicated caller waiting on shared work holds // a slot while doing nothing. // // `retryOnTimeout` defaults to false in `retryAsync`: a timeout costs ONE // attempt, a cancellation none. `deadline` bounds whatever retries do happen // by wall clock rather than by attempts x timeout + backoff. const runAttempt = (domain: AbortSignal): Promise => // Control-plane calls the auth lane depends on (`bypassQueue`, e.g. the // device-secret mint) run DIRECTLY — a queued mint could never acquire a // slot when every slot is parked awaiting it. config.bypassQueue ? attempt(domain) : this.requestQueue.enqueue(() => attempt(domain), domain); const execute = (domain: AbortSignal): Promise => retry ? retryAsync(() => runAttempt(domain), { maxRetries, baseDelay: this.config.retryDelay || 1000, retryOnTimeout: config.retryOnTimeout ?? false, deadline: config.deadline !== undefined ? Date.now() + config.deadline : undefined, }) : runAttempt(domain); let result: T; if (deduplicate) { // Calls share only within the same invalidation boundary. The resource // identity is computed once; invalidated work retains its old lease. const dedupeKey = `${lease.key} lease=${lease.id}`; result = await this.deduplicator.deduplicate(dedupeKey, execute, signal); } else { // The call's own abort domain, linked to the caller's signal ONCE, so a // cancellation arrives once and is then true for every attempt, including // ones not yet started. Released however the call ends: a caller signal // that outlives the request (a React Query signal) would otherwise accrue // one listener per request. const callController = new AbortController(); const disposeCallerLink = signal ? linkAbort(signal, callController) : undefined; try { result = await execute(callController.signal); } finally { disposeCallerLink?.(); } } // Cache the result if caching is enabled if (cache && cacheKey && lease.valid && result) { this.cache.set(cacheKey, result, cacheTTL); this.warnIfCacheOversized(); } return result; } /** * Soft cache-size guard. Emits a single throttled telemetry warning when the * identity-scoped response cache grows past {@link CACHE_SOFT_MAX_ENTRIES}. * * This intentionally does NOT evict: the {@link TTLCache} already bounds * memory by TTL (and the global cleanup interval sweeps expired entries), so * an LRU here would only risk thrashing a legitimately warm cache. The point * is observability — if entry count climbs and stays high, an identity tag or * cache key is folding in volatile data (a per-request nonce, an undecodable * rotating token) and the cache is no longer doing its job. Surfacing that via * the logger lets a consumer with `enableLogging` catch the regression in the * field instead of debugging silent memory growth. */ private warnIfCacheOversized(): void { const size = this.cache.size(); if (size <= CACHE_SOFT_MAX_ENTRIES) { return; } const now = Date.now(); if (now < this.cacheSizeWarningSilentUntil) { return; } this.cacheSizeWarningSilentUntil = now + CACHE_SIZE_WARNING_THROTTLE_MS; this.logger.warn( 'Response cache exceeded soft entry limit — possible identity-tag or cache-key bloat', { size, softLimit: CACHE_SOFT_MAX_ENTRIES }, ); } /** * Upload via XMLHttpRequest (React Native FormData workaround). * * Expo SDK 56's "winter fetch" cannot serialize RN file descriptors * (`{uri, type, name}`) — `convertFormDataAsync` rejects them as * `Unsupported FormDataPart implementation`. RN's native XHR streams * the file from disk correctly, so multipart uploads go through XHR * on RN only. * * Returns a standard `Response` so downstream parsing in `request()` * (status checks, 401/403 retries, JSON/blob/text parsing) is identical * to the fetch path. */ private uploadViaXHR( url: string, method: string, headers: Record, body: FormData, abortSignal: AbortSignal, timeout: number, withCredentials: boolean, ): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open(method, url, true); // Oxy authenticates with explicit credentials, never ambient cookies. xhr.withCredentials = withCredentials; // Forward headers but skip Content-Type — XHR sets the multipart // boundary automatically and overriding it breaks the upload. for (const [key, value] of Object.entries(headers)) { if (key.toLowerCase() === 'content-type') continue; try { xhr.setRequestHeader(key, value); } catch (headerError) { // Some headers (e.g. forbidden header names) cannot be set — // log and continue rather than failing the whole upload. this.logger.warn('XHR setRequestHeader failed:', key, headerError); } } xhr.responseType = 'text'; if (timeout > 0) { xhr.timeout = timeout; } const onAbort = (): void => { try { xhr.abort(); } catch { /* xhr already finished */ } }; if (abortSignal.aborted) { reject(new DOMException('The user aborted a request.', 'AbortError')); return; } abortSignal.addEventListener('abort', onAbort); const cleanup = (): void => { abortSignal.removeEventListener('abort', onAbort); }; xhr.onload = (): void => { cleanup(); const responseHeaders = HttpService.parseXHRHeaders(xhr.getAllResponseHeaders()); resolve( new Response(xhr.responseText, { status: xhr.status, statusText: xhr.statusText, headers: responseHeaders, }), ); }; xhr.onerror = (): void => { cleanup(); reject(new TypeError('Network request failed')); }; xhr.ontimeout = (): void => { cleanup(); reject(new DOMException('The request timed out.', 'TimeoutError')); }; xhr.onabort = (): void => { cleanup(); reject(new DOMException('The user aborted a request.', 'AbortError')); }; xhr.send(body); }); } /** * Parse raw header string from `XMLHttpRequest.getAllResponseHeaders()` * into a `Headers`-compatible object. */ private static parseXHRHeaders(rawHeaders: string): Headers { const headers = new Headers(); if (!rawHeaders) return headers; // RFC 7230 line terminator is CRLF; some XHR implementations use LF only. const lines = rawHeaders.trim().split(/\r?\n/); for (const line of lines) { const colonIndex = line.indexOf(':'); if (colonIndex <= 0) continue; const key = line.slice(0, colonIndex).trim(); const value = line.slice(colonIndex + 1).trim(); if (key) { try { headers.append(key, value); } catch { // Invalid header name/value — skip. } } } return headers; } /** * Delimiter that separates the logical `method:url[:data]` portion of a * cache key from its identity suffix. Always APPENDED, never used to parse * a key apart, so the `method:url` prefix stays intact for * `clearCacheByPrefix` sweeps and `clearCacheEntry` base-key matching. * The `clearCacheEntry` callsites all pass fixed, dataless logical keys * (`GET:/users/`, `GET:/session/user/`, * `GET:/auth/grants`), so this readable suffix can never be * ambiguous with a serialized request body. */ private static readonly CACHE_IDENTITY_DELIM = ' id='; /** * Derive a stable, non-sensitive identity discriminator for cache scoping. * * Thin instance wrapper over the pure {@link computeIdentityTag} helper — * binds it to this instance's live access token. See that function's docs for * the full resolution contract (anon fallback, decoded `userId || id`, * token-hash fallback for undecodable tokens). */ private computeIdentityTag(): string { return computeIdentityTag(this.tokenStore.getAccessToken()); } /** * Generate cache key efficiently * Uses a content-addressed hash for large payloads so two requests with * the same shape but different values never collide on the same key * (which would silently serve stale data — e.g. paginated search results, * large object updates). * * The key is identity-scoped: the logical `method:url[:data]` portion is * suffixed with ` id=` so two callers with different * identities (anon vs authed, or two different users) never share an entry. * The identity tag is placed at the END so the key still STARTS with * `method:url`, preserving the prefix-based invalidation in * `clearCacheByPrefix` (e.g. `GET:/session/user/`) and the base-key matching * in `clearCacheEntry`. */ private generateCacheKey(method: string, url: string, data?: unknown): string { return `${this.generateBaseCacheKey(method, url, data)}${HttpService.CACHE_IDENTITY_DELIM}${this.computeIdentityTag()}`; } /** * Build the identity-agnostic portion of a cache key (`method:url[:data]`). * Kept separate so identity scoping is applied in exactly one place * (`generateCacheKey`) and cannot drift between the cache and dedupe paths. */ private generateBaseCacheKey(method: string, url: string, data?: unknown): string { if (!data || (typeof data === 'object' && Object.keys(data).length === 0)) { return `${method}:${url}`; } // For small objects, the full serialization IS the key — fastest and // guaranteed to be content-addressed. const dataStr = JSON.stringify(data); if (dataStr.length < 1000) { return `${method}:${url}:${dataStr}`; } // For large payloads, hash the full serialized string so the key remains // content-addressed (any byte change yields a different hash). Previous // implementation hashed `keys + length` which collided for any two // payloads with the same top-level keys and serialized length. return `${method}:${url}:${fnv1a32(dataStr)}`; } /** * Build full URL with query params */ private buildURL(url: string, params?: Record): string { const base = /^https?:\/\//i.test(url) ? url : `${trimTrailingSlashes(this.baseURL)}/${trimLeadingSlashes(url)}`; if (!params || Object.keys(params).length === 0) { return base; } const searchParams = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null) { searchParams.append(key, String(value)); } } const queryString = searchParams.toString(); return queryString ? `${base}${base.includes('?') ? '&' : '?'}${queryString}` : base; } /** * Get auth header with automatic token refresh */ private async getAuthHeader(): Promise { const accessToken = this.syncAccessTokenFromProvider(); if (!accessToken) { return this.getAnonymousAuthHeader(); } try { const decoded = decodeTokenClaims(accessToken); if (!decoded) throw new Error('opaque token'); const currentTime = Math.floor(Date.now() / 1000); // If the token expires within the refresh lead window, refresh it. if (decoded.exp && decoded.exp - currentTime < TOKEN_REFRESH_LEAD_SECONDS) { // The server already answered this token's refresh with this very token: // it has nothing fresher until `exp`. Use it rather than asking again // (see `noFresherTokenThan`). if (decoded.exp > currentTime && accessToken === this.noFresherTokenThan) { return `Bearer ${accessToken}`; } const refreshed = await this.refreshAccessToken('preflight'); if (refreshed) return `Bearer ${refreshed}`; if (decoded.exp > currentTime) { return `Bearer ${accessToken}`; } // Refresh failed — don't use an expired token (would cause 401 loop) return null; } return `Bearer ${accessToken}`; } catch (error) { this.logger.error('Error processing token:', error); return null; } } /** * The bearer for a request that has no user session behind it, from the * {@link AnonymousAuthProvider} if one is installed. Never throws: a provider * that fails sends the request anonymous, exactly as it went before any * provider existed. */ private async getAnonymousAuthHeader(): Promise { if (!this.anonymousAuthProvider) return null; try { const token = await this.anonymousAuthProvider(); return token ? `Bearer ${token}` : null; } catch (error) { this.logger.debug('Anonymous auth provider failed; sending the request anonymous:', error); return null; } } async refreshAccessToken(reason: AuthRefreshReason): Promise { if (!this.authRefreshHandler) { return null; } // Post-failure cooldown. A 429 names its own window regardless of token // state — the server is reachable and is rationing this client, so retrying // at the expired-token rate would spend the very budget being waited on. // Otherwise a genuinely EXPIRED current token uses a much shorter cooldown // than a still-valid (proactive, near-expiry) one: an expired token is // unusable, so re-mint as soon as the endpoint is reachable again rather // than waiting out the full window while requests carry a stale bearer. All // three are measured from the last failure, so the moment a still-valid // token crosses `exp` mid-cooldown the shorter window applies. const cooldownMs = this.lastRefreshWasRateLimited ? RATE_LIMITED_REFRESH_COOLDOWN_MS : this.isAccessTokenExpired() ? EXPIRED_TOKEN_REFRESH_COOLDOWN_MS : TOKEN_REFRESH_COOLDOWN_MS; if (Date.now() - this.lastRefreshFailureAt < cooldownMs) { return null; } if (!this.tokenRefreshPromise) { // Cleared before the attempt, never after it: the handler reports a 429 // by calling `noteRefreshRateLimited()` from INSIDE this call, so clearing // on the way out would discard the flag it just set. this.lastRefreshWasRateLimited = false; const epoch = this.sessionEpoch; this.tokenRefreshPromise = this.authRefreshHandler(reason) .then((newToken) => { if (epoch !== this.sessionEpoch) { // A guarded refresh handler can itself adopt a new active context. // Acknowledge only an already-installed, exact result; never plant // a stale return value over a concurrent switch/logout. if (newToken && !this.sessionEnded && this.tokenStore.getAccessToken() === newToken) { return newToken; } this.logger.debug('Discarded a token refresh that outlived its session'); return null; } if (!newToken) { this.lastRefreshFailureAt = Date.now(); return null; } // A token is planted again, so there is a session again. this.sessionEnded = false; if (this.tokenStore.getAccessToken() !== newToken) { this.setTokens(newToken); } this.noFresherTokenThan = isInsideRefreshLeadWindow(newToken) ? newToken : null; // A success clears the failure timestamp so the next refresh is never // throttled by a stale cooldown. this.lastRefreshFailureAt = 0; this.lastRefreshWasRateLimited = false; this.logger.debug('Token refreshed via the auth refresh handler'); return newToken; }) .catch((error) => { this.logger.warn('Token refresh failed:', error); this.lastRefreshFailureAt = Date.now(); return null; }) .finally(() => { this.tokenRefreshPromise = null; }); } return this.tokenRefreshPromise; } /** * Report that the refresh/mint endpoint answered 429 Too Many Requests, so the * next attempt waits {@link RATE_LIMITED_REFRESH_COOLDOWN_MS} instead of one of * the token-state cooldowns. * * Called by the refresh handler (`refreshDeviceSecretArm`) from inside the * `authRefreshHandler` call, because the handler's `Promise` * contract cannot carry WHY a refresh failed and 429 is the one failure whose * correct retry interval is set by the server rather than by the token. * * Public because the handler lives in `session/refresh.ts`, not on this class. */ noteRefreshRateLimited(): void { this.lastRefreshWasRateLimited = true; } /** * Whether the server has already answered a refresh of the CURRENT access * token with that same, still-valid token — i.e. it has nothing fresher to * give until the token's `exp`. The proactive scheduler reads this so it waits * for expiry instead of re-asking at its 1s floor. */ isAwaitingCurrentTokenExpiry(): boolean { const token = this.tokenStore.getAccessToken(); return token !== null && token === this.noFresherTokenThan && isInsideRefreshLeadWindow(token); } /** * Whether the CURRENT stored access token is already past its `exp`. Drives * the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}): * a still-valid (near-expiry) token can wait out the full cooldown, but an * expired one must re-mint promptly. Returns `false` for an absent or * opaque/no-`exp` token — no proof it is expired, so keep the conservative * (longer) cooldown and avoid an unnecessary retry loop. */ private isAccessTokenExpired(): boolean { const token = this.tokenStore.getAccessToken(); if (!token) { return false; } const exp = decodeTokenClaims(token)?.exp; return typeof exp === 'number' && exp <= Math.floor(Date.now() / 1000); } /** * PROCESS-WIDE single-flight for the device-secret mint * (`POST /session/device/token`). * * The server does not rotate the presented `deviceSecret` (it echoes it back * as `nextDeviceSecret`; each holder of a shared DeviceSession has its own * stable credential), so this is no longer what keeps the store's secret * valid. It still collapses concurrent callers onto one request and one * persisted write. Every mint lane (the re-mint handler behind * `refreshAccessToken`, the device-first cold boot, the socket token * transport, the tab-focus reconcile) funnels its `refreshDeviceSecretArm` * call through here, so concurrent callers await the SAME in-flight mint and * all receive its result. * * Distinct from {@link tokenRefreshPromise} (which dedups the FULL re-mint * handler incl. the native Commons-identity arm + the failure cooldown): this inner * guard serializes the rotation itself across BOTH the handler and the * handler-independent cold boot, which never runs through `refreshAccessToken`. */ runSingleFlightDeviceSecretMint( mint: () => Promise, ): Promise { if (!this.deviceSecretMintInFlight) { this.deviceSecretMintInFlight = mint().finally(() => { this.deviceSecretMintInFlight = null; }); } return this.deviceSecretMintInFlight; } /** * Unwrap the standardized API response envelope — EXCEPT when the envelope is * a page, in which case it travels whole. * * `{ data: }` is the house success envelope (`sendSuccess`), and * reducing it to `` is what every call site in the SDK expects. But * the reduction DISCARDS every sibling key, silently, and a page's siblings * are the only thing that says where the next page starts. That is how * `GET /accounts/:id/audit` lost its `nextCursor`: the caller received a bare * array, `getNextPageParam` read `undefined`, and pagination was dead past the * first page with nothing to show that it was. * * ## Why the rule is narrow, and not "any sibling key survives" * * "An object carrying `data` plus anything else is not an envelope" is the * tempting general rule, and it is wrong here: this API already answers * `{ data, count }` on ~15 routes, plus `{ data, source }`, `{ data, reason }` * and `{ data, credentialRevoked }`, and a dozen measured Console call sites * type those as the bare payload (`Array`, * `AccountBillingState | null`, …). Preserving those envelopes would hand every * one of them an object where it expects its payload — at runtime only, since * the response type is a call-site assertion. So the rule names PAGINATION * specifically: `data` beside {@link PAGE_ENVELOPE_KEYS} is a page. * * A route whose sibling key genuinely matters to its caller belongs in that * list, or should not be a sibling of `data` at all — the cursor-paginated * surfaces already in the SDK (`{ follows, nextCursor }`, * `{ records, nextCursor }`) sidestep this by never using `data`. */ private unwrapResponse(responseData: unknown): unknown { if (!responseData || typeof responseData !== 'object' || !('data' in responseData)) { // Not the success envelope (or not an object at all) — as-is. return responseData; } // A page travels whole: its cursor/pagination sibling is unrecoverable // information, not decoration. if (PAGE_ENVELOPE_KEYS.some((key) => key in responseData)) { return responseData; } // Regular success envelope: `{ data: ... }` -> the payload. return Array.isArray(responseData) ? responseData : responseData.data; } /** * Update request metrics */ private updateMetrics(success: boolean, duration: number): void { this.requestMetrics.totalRequests++; if (success) { this.requestMetrics.successfulRequests++; } else { this.requestMetrics.failedRequests++; } const alpha = 0.1; this.requestMetrics.averageResponseTime = this.requestMetrics.averageResponseTime * (1 - alpha) + duration * alpha; } /** * Send an authenticated request without consuming its response body. * * Regular {@link request} calls parse the body before returning. Streaming * consumers instead need the original `Response`, while retaining the same * token ownership and one-time 401 refresh behaviour as every SDK request. * No SDK timeout is installed after the connection opens: the caller-owned * abort signal defines the lifetime of a healthy stream. */ async requestAuthenticatedResponse(config: AuthenticatedResponseRequest): Promise { return this.requestRawResponse(config, true); } /** * Raw response for public or authenticated app endpoints. The SDK supplies * its current bearer when available; caller headers never supply authority. * No caching, envelope transformation or transport-error retry is performed. */ async requestResponse(config: ResponseRequest): Promise { return this.requestRawResponse(config, false); } private async requestRawResponse( config: ResponseRequest, requireAuth: boolean, ): Promise { // Validate before obtaining a bearer: an unrelated origin must not trigger // an auth mint, even when the trusted transport accepts absolute URLs. const target = new URL(this.buildURL(config.url)); const base = new URL(this.baseURL); if ( target.origin !== base.origin || !['http:', 'https:'].includes(target.protocol) || target.username || target.password ) { throw new Error('Raw response requests must target the configured API origin'); } if (config.signal?.aborted) throw createCancelledError(); // Snapshot caller-owned options once, including headers, before any await. const request = { ...config, headers: new Headers(config.headers) }; const contextToken = this.syncAccessTokenFromProvider(); const beforeAuthEpoch = this.sessionEpoch; const authHeader = await awaitRequestSignal(this.getAuthHeader(), config.signal); if ( beforeAuthEpoch !== this.sessionEpoch || !sameTokenSession(contextToken, this.tokenStore.getAccessToken()) ) { throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED'); } if (config.signal?.aborted) throw createCancelledError(); if (requireAuth && !authHeader) { throw new OxyAuthenticationError( 'An active Oxy session is required for this request', 'AUTH_REQUIRED', ); } return this.requestResponseAttempt( request, target.href, authHeader, false, this.sessionEpoch, contextToken, ); } private async requestResponseAttempt( config: ResponseRequest, fullUrl: string, authHeader: string | null, isAuthRetry: boolean, epoch: number, contextToken: string | null, ): Promise { const assertCurrent = (): void => { this.syncAccessTokenFromProvider(); if ( epoch !== this.sessionEpoch || !sameTokenSession(contextToken, this.tokenStore.getAccessToken()) ) { throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED'); } }; const startTime = Date.now(); if (config.signal?.aborted) throw createCancelledError(); assertCurrent(); const headers = new Headers(config.headers); if (!headers.has('Accept')) headers.set('Accept', 'application/json'); // Never accept ambient credentials or another authority from a caller. for (const name of ['Authorization', 'Cookie', 'Cookie2', 'Host', 'Proxy-Authorization']) headers.delete(name); if (authHeader) headers.set('Authorization', authHeader); // Telemetry rides only on a request the browser preflights anyway — see // `request()`. const sentHeaders: Array<[string, string]> = []; headers.forEach((value, name) => { sentHeaders.push([name, value]); }); if (requiresCorsPreflight(config.method, sentHeaders)) { const edgeRegionHeader = peekBrowserEdgeRegionHeader(); for (const [name, value] of Object.entries(edgeRegionHeader)) headers.set(name, value); const activityIdHeader = getBrowserActivityIdHeader(); for (const [name, value] of Object.entries(activityIdHeader)) headers.set(name, value); } try { const body = config.method === 'GET' || config.method === 'HEAD' ? undefined : config.body; const init: RequestInit & { duplex?: 'half' } = { method: config.method, headers, body, signal: config.signal, credentials: 'omit', redirect: 'error', // The browser cache is separate from SDK caches and cannot distinguish // account changes unless every receiver supplies correct Vary headers. cache: 'no-store', }; // Node fetch requires this flag for streaming uploads; ordinary and // multipart bodies keep the platform's existing body serialization. if (body && typeof body === 'object' && 'getReader' in body) init.duplex = 'half'; const response = await (config.fetch ?? globalThis.fetch)(fullUrl, init); try { assertCurrent(); } catch (error) { await response.body?.cancel(); throw error; } const replayable = body === undefined || typeof body === 'string'; if ( response.status === 401 && !isAuthRetry && authHeader && replayable && !config.signal?.aborted ) { let refreshed: string | null; try { refreshed = await awaitRequestSignal( this.refreshAccessToken('response-401'), config.signal, ); } catch (error) { void response.body?.cancel().catch(() => undefined); throw error; } try { assertCurrent(); } catch (error) { await response.body?.cancel(); throw error; } if (refreshed) { await response.body?.cancel(); if (config.signal?.aborted) throw createCancelledError(); return this.requestResponseAttempt( config, fullUrl, `Bearer ${refreshed}`, true, epoch, contextToken, ); } this.clearTokens(); } const duration = Date.now() - startTime; this.updateMetrics(response.ok, duration); this.config.onRequestEnd?.(config.url, config.method, duration, response.ok); return response; } catch (error: unknown) { const duration = Date.now() - startTime; this.updateMetrics(false, duration); this.config.onRequestEnd?.(config.url, config.method, duration, false); const normalizedError = config.signal?.aborted || isCancelledError(error) ? createCancelledError() : error instanceof Error ? error : new Error(String(error)); this.config.onRequestError?.(config.url, config.method, normalizedError); if (isCancelledError(normalizedError) || normalizedError instanceof OxyAuthenticationError) throw normalizedError; throw handleHttpError(normalizedError); } } // Convenience methods async get( url: string, config?: Omit, ): Promise> { return this.request({ method: 'GET', url, ...config }); } async post( url: string, data?: unknown, config?: Omit, ): Promise> { return this.request({ method: 'POST', url, data, ...config }); } async put( url: string, data?: unknown, config?: Omit, ): Promise> { return this.request({ method: 'PUT', url, data, ...config }); } async patch( url: string, data?: unknown, config?: Omit, ): Promise> { return this.request({ method: 'PATCH', url, data, ...config }); } async delete( url: string, config?: Omit, ): Promise> { return this.request({ method: 'DELETE', url, ...config }); } // Token management setTokens(accessToken: string): void { const current = this.tokenStore.getAccessToken(); if (current && !sameTokenSession(current, accessToken)) this.sessionEpoch += 1; this.sessionEnded = false; this.tokenStore.setTokens(accessToken); this.notifyTokenChange(); } /** * End the local session: clear the bearer and abandon every re-mint already * in flight, so none of them can plant a token after the sign-out. * * Unlike {@link clearTokens}, this also marks the session as ended and * invalidates pending re-mints even when no bearer is currently present. */ endSession(): void { if (!this.tokenStore.getAccessToken()) this.sessionEpoch += 1; this.sessionEnded = true; this.clearTokens(); } /** * The current context epoch: account/session switches and bearer removal * invalidate pending work; rotation within the same context preserves it. * A re-mint lane that * plants tokens itself (the device-secret arm) compares it across its awaits. */ getSessionEpoch(): number { return this.sessionEpoch; } /** Whether {@link endSession} ran and no token has been planted since. */ hasSessionEnded(): boolean { return this.sessionEnded; } setAuthRefreshHandler(handler: AuthRefreshHandler | null): void { this.authRefreshHandler = handler; } setAccessTokenProvider(provider: AccessTokenProvider | null): void { this.accessTokenProvider = provider; } /** * Authenticate requests that carry no user session with the bearer this * provider returns — in practice a backend's own service token (see * `OxyConfig.serviceIdentity`). A user session always wins: the provider is * consulted only when there is no access token, and never for `skipAuth` * requests. `null` removes it. */ setAnonymousAuthProvider(provider: AnonymousAuthProvider | null): void { this.anonymousAuthProvider = provider; } clearTokens(): void { if (this.tokenStore.getAccessToken()) this.sessionEpoch += 1; this.tokenStore.clearTokens(); // Drop the response cache on logout. The cache is identity-scoped, so a // different user could never read these entries, but a logged-out client // must not keep the previous session's personalized data resident in // memory (privacy + correct logout semantics). We do NOT clear on // `setTokens` because a silent token refresh re-issues a token for the // SAME user — the identity tag is unchanged and the warm cache is still // valid; clearing there would defeat caching as refreshes fire near // every token expiry. this.clearCache(); this.notifyTokenChange(); } /** * Subscribe to access-token changes on this instance. * * Fires on every mutation of the access token — `setTokens`, `clearTokens`, * a successful silent refresh, and the internal 401-driven clear — passing * the resulting token (or `null` when cleared). Returns an unsubscribe * function; call it on teardown to avoid leaks. * * This is the single hook downstream code (e.g. @oxy.so/services' OxyProvider) * uses to keep an external token sink — such as the shared `oxyClient` * singleton — in lockstep with the active session, regardless of which code * path mutated the token. */ addTokenChangeListener(listener: (accessToken: string | null) => void): () => void { this._tokenChangeListeners.add(listener); return () => { this._tokenChangeListeners.delete(listener); }; } /** * Notify all token-change listeners with the current access token. * Listener exceptions are isolated so one bad subscriber cannot break token * propagation to the others or to the calling auth flow. * @internal */ private notifyTokenChange(): void { if (this._tokenChangeListeners.size === 0) return; const accessToken = this.tokenStore.getAccessToken(); for (const listener of this._tokenChangeListeners) { try { listener(accessToken); } catch (error) { this.logger.error('Token change listener threw:', error); } } } getAccessToken(): string | null { return this.tokenStore.getAccessToken(); } hasAccessToken(): boolean { return this.tokenStore.hasAccessToken(); } getBaseURL(): string { return this.baseURL; } // Cache management clearCache(): void { for (const lease of this.readLeases.values()) lease.valid = false; this.readLeases.clear(); this.cache.clear(); } /** * Delete a cache entry by its LOGICAL key (`method:url[:data]`). * * Because the response cache is identity-scoped — stored keys carry an * ` id=` suffix — a caller passing the logical key * `GET:/users/` must invalidate that resource for EVERY identity that * cached it (e.g. `updateProfile` busting a user representation that may be * cached under both the owner's id and a viewer's id). We therefore delete * the exact key (for any pre-existing un-suffixed entries) AND every * identity-scoped variant ` id=*`. */ clearCacheEntry(key: string): void { this.invalidateCache({ keys: [key] }); } /** * Delete every cache entry whose key starts with `prefix`. * * Used by mutations that don't know the exact downstream cache keys — * e.g. `updateProfile` invalidating all `GET:/session/user/*` entries * without having to track every active session ID. Returns the number of * deleted entries (for observability in tests). */ clearCacheByPrefix(prefix: string): number { return this.invalidateCache({ prefixes: [prefix] }); } /** * Invalidate a set of LOGICAL keys (each with every identity-scoped variant, * see `clearCacheEntry`) and key prefixes in ONE pass over the cache. A * mutation touching many resources (a bulk follow) costs one scan, not one * per resource. * @returns Number of entries deleted */ invalidateCache({ keys = [], prefixes = [], }: { keys?: readonly string[]; prefixes?: readonly string[]; }): number { if (keys.length === 0 && prefixes.length === 0) return 0; const exact = new Set(keys); const delim = HttpService.CACHE_IDENTITY_DELIM; const matches = (stored: string): boolean => { const cut = stored.lastIndexOf(delim); if (exact.has(cut === -1 ? stored : stored.slice(0, cut))) return true; for (const prefix of prefixes) { if (stored.startsWith(prefix)) return true; } return false; }; for (const [key, lease] of this.readLeases) { if (matches(key)) { lease.valid = false; this.readLeases.delete(key); } } return this.cache.deleteWhere(matches); } /** * Release this client: its response cache leaves the global cleanup sweep and * is emptied, pending shared calls are forgotten, and the session ends. */ dispose(): void { unregisterCacheFromCleanup(this.cache); this.clearCache(); this.deduplicator.clear(); this.endSession(); } getCacheStats() { const cacheStats = this.cache.getStats(); const total = this.requestMetrics.cacheHits + this.requestMetrics.cacheMisses; return { size: cacheStats.size, hits: this.requestMetrics.cacheHits, misses: this.requestMetrics.cacheMisses, hitRate: total > 0 ? this.requestMetrics.cacheHits / total : 0, }; } getMetrics() { return { ...this.requestMetrics }; } // Test-only utility — clears tokens on this instance __resetTokensForTests(): void { this.sessionEnded = false; this.tokenStore.clearTokens(); } }