diff --git a/.env.example b/.env.example index 41d87a8c..a859ec2a 100644 --- a/.env.example +++ b/.env.example @@ -48,3 +48,14 @@ INFOQUEST_API_KEY=your-infoquest-api-key # Set to "false" to disable Swagger UI, ReDoc, and OpenAPI schema in production # GATEWAY_ENABLE_DOCS=false + +# ── Frontend SSR → Gateway wiring ───────────────────────────────────────────── +# The Next.js server uses these to reach the Gateway during SSR (auth checks, +# /api/* rewrites). They default to localhost values that match `make dev` and +# `make start`, so most local users do not need to set them. +# +# Override only when the Gateway is not on localhost:8001 (e.g. when the +# frontend and gateway run on different hosts, in containers with a service +# alias, or behind a different port). docker-compose already sets these. +# DEER_FLOW_INTERNAL_GATEWAY_BASE_URL=http://localhost:8001 +# DEER_FLOW_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:2026 diff --git a/frontend/.env.example b/frontend/.env.example index 19cce747..c5d397e9 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -14,3 +14,8 @@ # Only set these if you need to connect to backend services directly # NEXT_PUBLIC_BACKEND_BASE_URL="http://localhost:8001" # NEXT_PUBLIC_LANGGRAPH_BASE_URL="http://localhost:2024" + +# Server-only Gateway wiring used by SSR (auth checks, /api/* rewrites). +# Defaults to localhost — only override for non-local deployments. +# DEER_FLOW_INTERNAL_GATEWAY_BASE_URL="http://localhost:8001" +# DEER_FLOW_TRUSTED_ORIGINS="http://localhost:3000,http://localhost:2026" diff --git a/frontend/src/core/auth/gateway-config.ts b/frontend/src/core/auth/gateway-config.ts index 61c6ae85..1439473a 100644 --- a/frontend/src/core/auth/gateway-config.ts +++ b/frontend/src/core/auth/gateway-config.ts @@ -12,12 +12,11 @@ let _cached: GatewayConfig | null = null; export function getGatewayConfig(): GatewayConfig { if (_cached) return _cached; - const isDev = process.env.NODE_ENV === "development"; - const rawUrl = process.env.DEER_FLOW_INTERNAL_GATEWAY_BASE_URL?.trim(); const internalGatewayUrl = - rawUrl?.replace(/\/+$/, "") ?? - (isDev ? "http://localhost:8001" : undefined); + rawUrl && rawUrl.length > 0 + ? rawUrl.replace(/\/+$/, "") + : "http://127.0.0.1:8001"; const rawOrigins = process.env.DEER_FLOW_TRUSTED_ORIGINS?.trim(); const trustedOrigins = rawOrigins @@ -25,9 +24,7 @@ export function getGatewayConfig(): GatewayConfig { .split(",") .map((s) => s.trim()) .filter(Boolean) - : isDev - ? ["http://localhost:3000"] - : undefined; + : ["http://localhost:3000"]; _cached = gatewayConfigSchema.parse({ internalGatewayUrl, trustedOrigins }); return _cached; diff --git a/frontend/tests/unit/core/auth/gateway-config.test.ts b/frontend/tests/unit/core/auth/gateway-config.test.ts new file mode 100644 index 00000000..4fa116a1 --- /dev/null +++ b/frontend/tests/unit/core/auth/gateway-config.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const ENV_KEYS = [ + "NODE_ENV", + "DEER_FLOW_INTERNAL_GATEWAY_BASE_URL", + "DEER_FLOW_TRUSTED_ORIGINS", +] as const; + +type EnvSnapshot = Partial< + Record<(typeof ENV_KEYS)[number], string | undefined> +>; + +function snapshotEnv(): EnvSnapshot { + const snapshot: EnvSnapshot = {}; + for (const key of ENV_KEYS) { + snapshot[key] = process.env[key]; + } + return snapshot; +} + +function setEnv(key: (typeof ENV_KEYS)[number], value: string | undefined) { + // NODE_ENV is typed as a readonly literal union, so we go through the + // index signature to keep the test compiler-friendly across cases. + const env = process.env as Record; + if (value === undefined) { + delete env[key]; + } else { + env[key] = value; + } +} + +function restoreEnv(snapshot: EnvSnapshot) { + for (const key of ENV_KEYS) { + setEnv(key, snapshot[key]); + } +} + +async function loadFreshConfig() { + vi.resetModules(); + return await import("@/core/auth/gateway-config"); +} + +describe("getGatewayConfig", () => { + let saved: EnvSnapshot; + + beforeEach(() => { + saved = snapshotEnv(); + setEnv("DEER_FLOW_INTERNAL_GATEWAY_BASE_URL", undefined); + setEnv("DEER_FLOW_TRUSTED_ORIGINS", undefined); + }); + + afterEach(() => { + restoreEnv(saved); + }); + + test("returns localhost defaults when env is unset in development", async () => { + setEnv("NODE_ENV", "development"); + + const { getGatewayConfig } = await loadFreshConfig(); + const cfg = getGatewayConfig(); + + expect(cfg.internalGatewayUrl).toBe("http://127.0.0.1:8001"); + expect(cfg.trustedOrigins).toEqual(["http://localhost:3000"]); + }); + + test("returns localhost defaults when env is unset in production (regression: issue #2705)", async () => { + setEnv("NODE_ENV", "production"); + + const { getGatewayConfig } = await loadFreshConfig(); + + expect(() => getGatewayConfig()).not.toThrow(); + const cfg = getGatewayConfig(); + expect(cfg.internalGatewayUrl).toBe("http://127.0.0.1:8001"); + expect(cfg.trustedOrigins).toEqual(["http://localhost:3000"]); + }); + + test("uses env values verbatim when set, regardless of NODE_ENV", async () => { + setEnv("NODE_ENV", "production"); + setEnv("DEER_FLOW_INTERNAL_GATEWAY_BASE_URL", "https://gw.example.com/"); + setEnv( + "DEER_FLOW_TRUSTED_ORIGINS", + "https://app.example.com, https://admin.example.com", + ); + + const { getGatewayConfig } = await loadFreshConfig(); + const cfg = getGatewayConfig(); + + expect(cfg.internalGatewayUrl).toBe("https://gw.example.com"); + expect(cfg.trustedOrigins).toEqual([ + "https://app.example.com", + "https://admin.example.com", + ]); + }); + + test("trims and filters empty entries in trustedOrigins", async () => { + setEnv("NODE_ENV", "production"); + setEnv("DEER_FLOW_INTERNAL_GATEWAY_BASE_URL", "https://gw.example.com"); + setEnv( + "DEER_FLOW_TRUSTED_ORIGINS", + " https://a.example , ,https://b.example ", + ); + + const { getGatewayConfig } = await loadFreshConfig(); + const cfg = getGatewayConfig(); + + expect(cfg.trustedOrigins).toEqual([ + "https://a.example", + "https://b.example", + ]); + }); +});