diff --git a/apps/core/server/api-elysia.ts b/apps/core/server/api-elysia.ts index 122e944..48cd570 100644 --- a/apps/core/server/api-elysia.ts +++ b/apps/core/server/api-elysia.ts @@ -252,18 +252,30 @@ const app = new Elysia() .use(securityHeaders) .use( cors({ - origin: (request) => { - // Build allowed origins list + origin: ({ request }) => { + // Get the incoming origin from the request + const incomingOrigin = request.headers.get("origin"); + + // No origin header means same-origin request or non-browser client + if (!incomingOrigin) { + return true; + } + + // Build allowed origins list (normalize by removing trailing slashes) + // Browser Origin headers never include trailing slashes, but env vars might + const normalizeOrigin = (origin: string) => + origin.endsWith("/") ? origin.slice(0, -1) : origin; + const allowedOrigins: string[] = []; // Add FRONTEND_URL if configured (required in production) if (env.FRONTEND_URL) { - allowedOrigins.push(env.FRONTEND_URL); + allowedOrigins.push(normalizeOrigin(env.FRONTEND_URL)); } // Add any additional CORS_ALLOWED_ORIGINS if (env.CORS_ALLOWED_ORIGINS && env.CORS_ALLOWED_ORIGINS.length > 0) { - allowedOrigins.push(...env.CORS_ALLOWED_ORIGINS); + allowedOrigins.push(...env.CORS_ALLOWED_ORIGINS.map(normalizeOrigin)); } // In development, allow localhost origins @@ -276,16 +288,30 @@ const app = new Elysia() ); } - // If no origins configured in production, reject (don't fall back to wildcard) + // If no origins configured in production, reject if (allowedOrigins.length === 0) { logger.warn( - { context: "cors" }, - "No CORS origins configured - rejecting cross-origin requests", + { context: "cors", origin: incomingOrigin }, + "No CORS origins configured - rejecting cross-origin request", ); return false; } - return allowedOrigins; + // Check if incoming origin is in allowed list + const isAllowed = allowedOrigins.includes(incomingOrigin); + + if (!isAllowed) { + logger.warn( + { + context: "cors", + origin: incomingOrigin, + allowed: allowedOrigins, + }, + "CORS request from unauthorized origin rejected", + ); + } + + return isAllowed; }, credentials: true, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], diff --git a/apps/core/src/lib/api-client.ts b/apps/core/src/lib/api-client.ts index 3a2fb43..3a67702 100644 --- a/apps/core/src/lib/api-client.ts +++ b/apps/core/src/lib/api-client.ts @@ -20,13 +20,20 @@ import { getAuthToken } from "@/utils/auth-token-store"; // Get API base URL // In development: Use http://localhost:3004 (direct connection to local backend) // In production: Use VITE_API_URL if set, otherwise relative path /api -const API_BASE_URL = import.meta.env.DEV +// Remove trailing slash to prevent double-slash URLs (e.g., //api/users/me) +const rawBaseUrl = import.meta.env.DEV ? "http://localhost:3004" // Dev mode: Direct connection to local backend - : (import.meta.env.VITE_API_URL || "/api"); // Production: Use VITE_API_URL or relative path + : import.meta.env.VITE_API_URL || "/api"; // Production: Use VITE_API_URL or relative path +const API_BASE_URL = rawBaseUrl.endsWith("/") + ? rawBaseUrl.slice(0, -1) + : rawBaseUrl; // Debug logging if (import.meta.env.DEV) { - console.log("[API Client] Dev mode - connecting to local backend, API_BASE_URL:", API_BASE_URL); + console.log( + "[API Client] Dev mode - connecting to local backend, API_BASE_URL:", + API_BASE_URL, + ); } /** diff --git a/apps/core/src/utils/api.ts b/apps/core/src/utils/api.ts index ddb02c8..e4d7d48 100644 --- a/apps/core/src/utils/api.ts +++ b/apps/core/src/utils/api.ts @@ -1,9 +1,9 @@ -import { retryWithBackoff, RetryOptions } from './retry' -import { getAuthToken } from './auth-token-store' +import { retryWithBackoff, RetryOptions } from "./retry"; +import { getAuthToken } from "./auth-token-store"; export interface RequestOptions extends RequestInit { - timeoutMs?: number - retry?: RetryOptions | boolean + timeoutMs?: number; + retry?: RetryOptions | boolean; } // Get API base URL for constructing full URLs @@ -16,79 +16,92 @@ const getApiBaseUrl = (): string => { } // In production, use VITE_API_URL if set, otherwise relative URLs - return import.meta.env.VITE_API_URL || ""; -} + // Remove trailing slash to prevent double-slash URLs (e.g., //api/users/me) + const baseUrl = import.meta.env.VITE_API_URL || ""; + return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; +}; + +export async function apiFetch( + input: string, + init: RequestOptions = {}, +): Promise { + const { timeoutMs = 15000, signal, retry: retryConfig, ...rest } = init; -export async function apiFetch(input: string, init: RequestOptions = {}): Promise { - const { timeoutMs = 15000, signal, retry: retryConfig, ...rest } = init - // Construct full URL if input is a relative path // If input is already absolute (http:// or https://), use it as-is // Otherwise, prepend base URL (empty string in dev/prod means relative URL) - const url = input.startsWith('http://') || input.startsWith('https://') - ? input - : `${getApiBaseUrl()}${input.startsWith('/') ? input : `/${input}`}` - + const baseUrl = getApiBaseUrl(); + const url = + input.startsWith("http://") || input.startsWith("https://") + ? input + : baseUrl && !input.startsWith("/") + ? `${baseUrl}/${input}` + : `${baseUrl}${input}`; + const fetchWithTimeout = async (): Promise => { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(new DOMException('Timeout', 'AbortError')), timeoutMs) + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(new DOMException("Timeout", "AbortError")), + timeoutMs, + ); try { // Get auth token and add to headers // Only set Authorization from global token if not already explicitly provided - const token = getAuthToken() - + const token = getAuthToken(); + // Convert rest.headers to a plain object, handling Headers objects, arrays, and plain objects - const headers: Record = {} - + const headers: Record = {}; + if (rest.headers) { if (rest.headers instanceof Headers) { // Headers object - iterate over entries rest.headers.forEach((value, key) => { - headers[key] = value - }) + headers[key] = value; + }); } else if (Array.isArray(rest.headers)) { // Array of [key, value] tuples rest.headers.forEach(([key, value]) => { - headers[key] = value - }) + headers[key] = value; + }); } else { // Plain object - Object.assign(headers, rest.headers) + Object.assign(headers, rest.headers); } } - + // Only set Authorization header from global token if it's not already set // This allows explicit Authorization headers (e.g., from accessToken parameter) to take precedence // Check both 'Authorization' and 'authorization' for case-insensitivity - const hasAuthHeader = headers['Authorization'] || headers['authorization'] + const hasAuthHeader = + headers["Authorization"] || headers["authorization"]; if (token && !hasAuthHeader) { - headers['Authorization'] = `Bearer ${token}` + headers["Authorization"] = `Bearer ${token}`; } const response = await fetch(url, { ...rest, headers, - signal: signal ?? controller.signal - }) - return response + signal: signal ?? controller.signal, + }); + return response; } finally { - clearTimeout(timeout) + clearTimeout(timeout); } - } + }; // Apply retry logic if enabled if (retryConfig) { - const retryOptions = retryConfig === true ? {} : retryConfig - const result = await retryWithBackoff(fetchWithTimeout, retryOptions) - + const retryOptions = retryConfig === true ? {} : retryConfig; + const result = await retryWithBackoff(fetchWithTimeout, retryOptions); + if (result.success && result.data) { - return result.data + return result.data; } - - throw result.error || new Error('Request failed after retries') + + throw result.error || new Error("Request failed after retries"); } // No retry - direct fetch - return fetchWithTimeout() -} \ No newline at end of file + return fetchWithTimeout(); +}