/** * HTTP client wrapper for API calls * Includes Request ID propagation for debugging across services */ import { authMessageFromStatus, AUTH_ERROR_MESSAGES } from '@ciphera-net/ui' import { generateRequestId, getRequestIdHeader, setLastRequestId } from '@/lib/utils/requestId' /** Request timeout in ms; network errors surface as user-facing "Network error, please try again." */ const FETCH_TIMEOUT_MS = 30_000 export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082' export const AUTH_URL = process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:3000' export const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003' export const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || 'https://auth-api.ciphera.net' export function getLoginUrl(redirectPath = '/auth/callback') { const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`) return `${AUTH_URL}/login?client_id=pulse-app&redirect_uri=${redirectUri}&response_type=code` } export function getSignupUrl(redirectPath = '/auth/callback') { const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`) return `${AUTH_URL}/signup?client_id=pulse-app&redirect_uri=${redirectUri}&response_type=code` } // * ============================================================================ // * CSRF Token Handling // * ============================================================================ /** * Get CSRF token from the csrf_token cookie (non-httpOnly) * This is needed for state-changing requests to the Auth API */ function getCSRFToken(): string | null { if (typeof document === 'undefined') return null const cookies = document.cookie.split(';') for (const cookie of cookies) { const [name, value] = cookie.trim().split('=') if (name === 'csrf_token') { return decodeURIComponent(value) } } return null } /** * Check if a request method requires CSRF protection * State-changing methods (POST, PUT, DELETE, PATCH) need CSRF tokens */ function isStateChangingMethod(method: string): boolean { const stateChangingMethods = ['POST', 'PUT', 'DELETE', 'PATCH'] return stateChangingMethods.includes(method.toUpperCase()) } export class ApiError extends Error { status: number data?: Record constructor(message: string, status: number, data?: Record) { super(message) this.status = status this.data = data } } // * Mutex for token refresh let isRefreshing = false type RefreshSubscriber = { onSuccess: () => void; onFailure: (err: unknown) => void } let refreshSubscribers: RefreshSubscriber[] = [] function subscribeToTokenRefresh(onSuccess: () => void, onFailure: (err: unknown) => void) { refreshSubscribers.push({ onSuccess, onFailure }) } function onRefreshed() { refreshSubscribers.forEach((s) => s.onSuccess()) refreshSubscribers = [] } function onRefreshFailed(err: unknown) { refreshSubscribers.forEach((s) => { try { s.onFailure(err) } catch { // ignore } }) refreshSubscribers = [] } // * ============================================================================ // * Request Deduplication & Caching // * ============================================================================ /** Cache TTL in milliseconds (2 seconds) */ const CACHE_TTL_MS = 2_000 /** Stores in-flight requests for deduplication */ interface PendingRequest { promise: Promise timestamp: number } const pendingRequests = new Map() /** Stores cached responses */ interface CachedResponse { data: unknown timestamp: number } const responseCache = new Map() /** * Generate a unique key for a request based on endpoint and options */ function getRequestKey(endpoint: string, options: RequestInit): string { const method = options.method || 'GET' const body = options.body || '' return `${method}:${endpoint}:${body}` } /** * Clean up expired entries from pending requests and response cache */ function cleanupExpiredEntries(): void { const now = Date.now() // * Clean up stale pending requests (older than 30 seconds) for (const [key, pending] of pendingRequests.entries()) { if (now - pending.timestamp > 30_000) { pendingRequests.delete(key) } } // * Clean up stale cached responses (older than CACHE_TTL_MS) for (const [key, cached] of responseCache.entries()) { if (now - cached.timestamp > CACHE_TTL_MS) { responseCache.delete(key) } } } /** * Base API client with error handling, request deduplication, and short-term caching */ async function apiRequest( endpoint: string, options: RequestInit = {} ): Promise { // * Skip deduplication for non-GET requests (mutations should always execute) const method = options.method || 'GET' const shouldDedupe = method === 'GET' if (shouldDedupe) { // * Clean up expired entries periodically if (pendingRequests.size > 100 || responseCache.size > 100) { cleanupExpiredEntries() } const requestKey = getRequestKey(endpoint, options) // * Check if we have a recent cached response (within 2 seconds) const cached = responseCache.get(requestKey) if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { return cached.data as T } // * Check if there's an identical request in flight const pending = pendingRequests.get(requestKey) if (pending && Date.now() - pending.timestamp < 30000) { return pending.promise as Promise } } // * Determine base URL const isAuthRequest = endpoint.startsWith('/auth') const baseUrl = isAuthRequest ? AUTH_API_URL : API_URL // * Handle legacy endpoints that already include /api/ prefix const url = endpoint.startsWith('/api/') ? `${baseUrl}${endpoint}` : `${baseUrl}/api/v1${endpoint}` // * Generate and store request ID for tracing const requestId = generateRequestId() setLastRequestId(requestId) const headers: Record = { 'Content-Type': 'application/json', [getRequestIdHeader()]: requestId, } // * Merge any additional headers from options if (options.headers) { const additionalHeaders = options.headers as Record Object.entries(additionalHeaders).forEach(([key, value]) => { headers[key] = value }) } // * We rely on HttpOnly cookies, so no manual Authorization header injection. // * We MUST set credentials: 'include' for the browser to send cookies cross-origin (or same-site). // * Add CSRF token for all state-changing requests (Pulse API and Auth API). // * Both backends enforce the double-submit cookie pattern server-side. if (isStateChangingMethod(method)) { const csrfToken = getCSRFToken() if (csrfToken) { headers['X-CSRF-Token'] = csrfToken } } const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) const signal = options.signal ?? controller.signal // * Create the request promise const requestPromise = (async (): Promise => { let response: Response try { response = await fetch(url, { ...options, headers, credentials: 'include', // * IMPORTANT: Send cookies signal, }) clearTimeout(timeoutId) } catch (e) { clearTimeout(timeoutId) if (e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError')) { throw new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0) } throw e } if (!response.ok) { if (response.status === 401) { // * Attempt Token Refresh if 401 if (typeof window !== 'undefined') { // * Prevent infinite loop: Don't refresh if the failed request WAS a refresh request (unlikely via apiRequest but safe to check) if (!endpoint.includes('/auth/refresh')) { if (isRefreshing) { // * If refresh is already in progress, wait for it to complete (or fail) return new Promise((resolve, reject) => { subscribeToTokenRefresh( () => { // * Retry with fresh headers after refresh completes const retryHeaders: Record = { 'Content-Type': 'application/json', [getRequestIdHeader()]: generateRequestId(), } if (options.headers) { Object.entries(options.headers as Record).forEach(([key, value]) => { retryHeaders[key] = value }) } if (isStateChangingMethod(method)) { const csrfToken = getCSRFToken() if (csrfToken) retryHeaders['X-CSRF-Token'] = csrfToken } fetch(url, { ...options, headers: retryHeaders, credentials: 'include' }) .then(async (retryResponse) => { if (retryResponse.ok) { resolve(await retryResponse.json()) } else { reject(new ApiError(authMessageFromStatus(retryResponse.status), retryResponse.status)) } }) .catch((e) => reject(e)) }, (err) => reject(err) ) }) } isRefreshing = true try { // * Call our Next.js API route to handle refresh securely const refreshRes = await fetch('/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', }) if (refreshRes.ok) { // * Refresh successful, cookies updated onRefreshed() // * Retry original request with fresh headers const retryHeaders: Record = { 'Content-Type': 'application/json', [getRequestIdHeader()]: generateRequestId(), } if (options.headers) { Object.entries(options.headers as Record).forEach(([key, value]) => { retryHeaders[key] = value }) } if (isStateChangingMethod(method)) { const csrfToken = getCSRFToken() if (csrfToken) retryHeaders['X-CSRF-Token'] = csrfToken } const retryResponse = await fetch(url, { ...options, headers: retryHeaders, credentials: 'include', }) if (retryResponse.ok) { return retryResponse.json() } // * Retry failed — throw with the retry response status, not the original 401 const retryBody = await retryResponse.json().catch(() => ({})) throw new ApiError(authMessageFromStatus(retryResponse.status), retryResponse.status, retryBody) } else { const sessionExpiredMsg = authMessageFromStatus(401) onRefreshFailed(new ApiError(sessionExpiredMsg, 401)) localStorage.removeItem('user') throw new ApiError(sessionExpiredMsg, 401) } } catch (e) { if (e instanceof ApiError) throw e const err = e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError') ? new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0) : e onRefreshFailed(err) throw err } finally { isRefreshing = false } } } } const errorBody = await response.json().catch(() => ({})) const message = authMessageFromStatus(response.status) throw new ApiError(message, response.status, errorBody) } if (response.status === 204) { return undefined as T } return response.json() })() // * For GET requests, track the promise for deduplication and cache the result if (shouldDedupe) { const requestKey = getRequestKey(endpoint, options) // * Store in pending requests pendingRequests.set(requestKey, { promise: requestPromise as Promise, timestamp: Date.now(), }) // * Clean up pending request and cache the result when done requestPromise .then((data) => { // * Cache successful response responseCache.set(requestKey, { data, timestamp: Date.now(), }) // * Remove from pending pendingRequests.delete(requestKey) return data }) .catch((error) => { // * Remove from pending on error too pendingRequests.delete(requestKey) throw error }) } return requestPromise } export const authFetch = apiRequest export default apiRequest