From 197861af64fa1c80e1f8ec61684d78caca590921 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Nov 2025 02:39:53 +0000 Subject: [PATCH 1/4] fix: Remove trailing slash from API base URL to prevent double-slash URLs The VITE_API_URL env var can have a trailing slash which causes double-slash URLs like //api/users/me when paths start with /. This was causing requests to fail or go to incorrect endpoints. Fixes both api.ts (apiFetch utility) and api-client.ts (Eden Treaty client). --- apps/core/src/lib/api-client.ts | 13 +++-- apps/core/src/utils/api.ts | 90 ++++++++++++++++++--------------- 2 files changed, 60 insertions(+), 43 deletions(-) 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..408e163 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,89 @@ 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 url = + input.startsWith("http://") || input.startsWith("https://") + ? input + : `${getApiBaseUrl()}${input.startsWith("/") ? input : `/${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(); +} From 72df026dc30fc33816fb57f15dfe5370d140d4d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Nov 2025 05:37:57 +0000 Subject: [PATCH 2/4] fix: Correct Elysia CORS callback to return boolean instead of array The CORS origin callback was incorrectly returning an array of allowed origins. According to Elysia CORS plugin documentation, the callback must return a boolean indicating if the request's origin is allowed. Changes: - Get incoming origin from request.headers.get("origin") - Compare against allowed origins list - Return true/false (boolean) instead of string[] - Add logging for rejected origins to aid debugging - Allow requests without origin header (same-origin/non-browser) This fixes CORS preflight failures where no Access-Control-Allow-Origin header was being returned. --- apps/core/server/api-elysia.ts | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/core/server/api-elysia.ts b/apps/core/server/api-elysia.ts index 122e944..401eee0 100644 --- a/apps/core/server/api-elysia.ts +++ b/apps/core/server/api-elysia.ts @@ -252,7 +252,15 @@ const app = new Elysia() .use(securityHeaders) .use( cors({ - origin: (request) => { + 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 const allowedOrigins: string[] = []; @@ -276,16 +284,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"], From 50642f698acfefacb0d1616017c2e676aac28c6b Mon Sep 17 00:00:00 2001 From: dEXploarer Date: Thu, 27 Nov 2025 03:17:54 -0500 Subject: [PATCH 3/4] Update apps/core/src/utils/api.ts Co-authored-by: codiumai-pr-agent-free[bot] <138128286+codiumai-pr-agent-free[bot]@users.noreply.github.com> --- apps/core/src/utils/api.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/core/src/utils/api.ts b/apps/core/src/utils/api.ts index 408e163..e4d7d48 100644 --- a/apps/core/src/utils/api.ts +++ b/apps/core/src/utils/api.ts @@ -30,10 +30,13 @@ export async function apiFetch( // 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 baseUrl = getApiBaseUrl(); const url = input.startsWith("http://") || input.startsWith("https://") ? input - : `${getApiBaseUrl()}${input.startsWith("/") ? input : `/${input}`}`; + : baseUrl && !input.startsWith("/") + ? `${baseUrl}/${input}` + : `${baseUrl}${input}`; const fetchWithTimeout = async (): Promise => { const controller = new AbortController(); From 1ed10cd1f1fef707bcbb9c1a0392ae86907b59e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 08:20:35 +0000 Subject: [PATCH 4/4] fix: Normalize CORS allowed origins by removing trailing slashes Browser Origin headers never include trailing slashes, but environment variables (FRONTEND_URL, CORS_ALLOWED_ORIGINS) might be configured with them. This mismatch would cause legitimate requests to be rejected. Added normalizeOrigin() helper to strip trailing slashes from configured origins before comparison with the incoming request origin. --- apps/core/server/api-elysia.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/core/server/api-elysia.ts b/apps/core/server/api-elysia.ts index 401eee0..48cd570 100644 --- a/apps/core/server/api-elysia.ts +++ b/apps/core/server/api-elysia.ts @@ -261,17 +261,21 @@ const app = new Elysia() return true; } - // Build allowed origins list + // 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