From aded753de3981ecda82145439e92e2a5d0db758b Mon Sep 17 00:00:00 2001 From: Xinmin Zeng <135568692+fancyboi999@users.noreply.github.com> Date: Tue, 5 May 2026 16:27:29 +0800 Subject: [PATCH] fix(frontend): restore localhost fallback for getGatewayConfig in prod mode (#2705) (#2718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(frontend): unify gateway-config localhost fallback for prod (#2705) `getGatewayConfig()` only fell back to localhost defaults when `NODE_ENV === "development"`, while `next.config.js` always falls back to `127.0.0.1:8001`. Running `make start` (which sets NODE_ENV=production via `next start`) without `DEER_FLOW_INTERNAL_GATEWAY_BASE_URL` / `DEER_FLOW_TRUSTED_ORIGINS` therefore caused zod to throw inside SSR layouts and surfaced as a 500. Drop the NODE_ENV gating and use localhost defaults everywhere — the "force explicit config in prod" intent should be enforced by deployment templates (docker-compose already sets both vars), not by request-time crashes. Document the two vars in both .env.example files and add unit coverage for the dev/prod env-unset paths. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Update internalGatewayUrl in gateway config tests --------- Co-authored-by: Willem Jiang Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .env.example | 11 ++ frontend/.env.example | 5 + frontend/src/core/auth/gateway-config.ts | 11 +- .../unit/core/auth/gateway-config.test.ts | 111 ++++++++++++++++++ 4 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 frontend/tests/unit/core/auth/gateway-config.test.ts diff --git a/.env.example b/.env.example index 41d87a8c7..a859ec2a5 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 19cce7478..c5d397e90 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 61c6ae850..1439473a5 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 000000000..4fa116a1a --- /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", + ]); + }); +});