From 62031a0ab1d663b5a956f8abb8e700bd9c829ea9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 9 Jul 2026 09:49:45 -0700 Subject: [PATCH] add a CLIENT_CONFIG to the client, serve from BE with env vars --- client/index.html | 1 + client/public/config.js | 4 ++++ .../components/InviteTeamMemberDialog.tsx | 3 ++- client/src/Utils/ApiClient.ts | 4 +++- client/src/Utils/logger.ts | 4 +++- client/src/Utils/runtimeConfig.ts | 18 ++++++++++++++++++ client/src/Utils/statusPageUrl.ts | 5 ++++- server/src/app.ts | 7 +++++++ server/src/config/envValidation.ts | 10 ++++++++-- .../app-settings/app-settings.service.ts | 8 +++++++- .../domain/app-settings/app-settings.type.ts | 11 +++++++++++ .../test/unit/services/settingsService.test.ts | 13 +++++++++++++ 12 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 client/public/config.js create mode 100644 client/src/Utils/runtimeConfig.ts diff --git a/client/index.html b/client/index.html index 4f949edbb..5f48a1bd3 100644 --- a/client/index.html +++ b/client/index.html @@ -12,6 +12,7 @@ /> Checkmate + diff --git a/client/public/config.js b/client/public/config.js new file mode 100644 index 000000000..30686d2e0 --- /dev/null +++ b/client/public/config.js @@ -0,0 +1,4 @@ +// Fallback so /config.js always resolves (e.g. under the Vite dev server). In +// the Docker image the server shadows this path and renders the real config +// from its CLIENT_CONFIG_* environment variables. +window.__CHECKMATE_CONFIG__ = {}; diff --git a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx index b3e08fe67..d41bf3ca3 100644 --- a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx +++ b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx @@ -10,8 +10,9 @@ import { useInviteForm } from "@/Hooks/useInviteForm"; import type { InviteFormData } from "@/Validation/invite"; import { usePost } from "@/Hooks/UseApi"; import { LAYOUT } from "@/Utils/Theme/constants"; +import { runtimeConfig } from "@/Utils/runtimeConfig"; -const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST; +const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST; interface InviteResponse { token: string; diff --git a/client/src/Utils/ApiClient.ts b/client/src/Utils/ApiClient.ts index d88dca6b1..c0468ed0e 100644 --- a/client/src/Utils/ApiClient.ts +++ b/client/src/Utils/ApiClient.ts @@ -1,8 +1,10 @@ import axios from "axios"; import type { AxiosError } from "axios"; import type { AxiosRequestConfig, AxiosResponse } from "axios"; +import { runtimeConfig } from "@/Utils/runtimeConfig"; -const BASE_URL = import.meta.env.VITE_APP_API_BASE_URL || "/api/v1"; +const BASE_URL = + runtimeConfig.apiBaseUrl || import.meta.env.VITE_APP_API_BASE_URL || "/api/v1"; const api = axios.create({ baseURL: BASE_URL, diff --git a/client/src/Utils/logger.ts b/client/src/Utils/logger.ts index 4a97dbecb..74be87918 100644 --- a/client/src/Utils/logger.ts +++ b/client/src/Utils/logger.ts @@ -1,4 +1,5 @@ import type { LogLevel } from "@/Types/Log"; +import { runtimeConfig } from "@/Utils/runtimeConfig"; interface LogContext { [key: string]: any; @@ -25,7 +26,8 @@ interface ErrorLogData { userAgent: string; } -const configuredLevel: LogLevel = import.meta.env.VITE_APP_LOG_LEVEL || "error"; +const configuredLevel: LogLevel = + runtimeConfig.logLevel || import.meta.env.VITE_APP_LOG_LEVEL || "error"; const configuredPriority = LOG_LEVEL_PRIORITY[configuredLevel]; const shouldLog = (level: LogLevel): boolean => { diff --git a/client/src/Utils/runtimeConfig.ts b/client/src/Utils/runtimeConfig.ts new file mode 100644 index 000000000..2d5e4be94 --- /dev/null +++ b/client/src/Utils/runtimeConfig.ts @@ -0,0 +1,18 @@ +import type { LogLevel } from "@/Types/Log"; + +// Served by GET /config.js (rendered from server env) or a static config.js +// next to index.html; loaded before the bundle in index.html. +export interface RuntimeConfig { + apiBaseUrl?: string; + clientHost?: string; + logLevel?: LogLevel; +} + +declare global { + interface Window { + __CHECKMATE_CONFIG__?: RuntimeConfig; + } +} + +export const runtimeConfig: RuntimeConfig = + (typeof window !== "undefined" && window.__CHECKMATE_CONFIG__) || {}; diff --git a/client/src/Utils/statusPageUrl.ts b/client/src/Utils/statusPageUrl.ts index d5628d6bc..65109c198 100644 --- a/client/src/Utils/statusPageUrl.ts +++ b/client/src/Utils/statusPageUrl.ts @@ -1,6 +1,9 @@ import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage"; +import { runtimeConfig } from "@/Utils/runtimeConfig"; -const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST as string | undefined; +const CLIENT_HOST = + runtimeConfig.clientHost || + (import.meta.env.VITE_APP_CLIENT_HOST as string | undefined); const getClientOriginProtocol = (): string => { const source = diff --git a/server/src/app.ts b/server/src/app.ts index 2ed1c2f01..c363db5d2 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -52,6 +52,13 @@ export const createApp = ({ app.use(createStatusPageDocumentCsp(allowedOrigin)); + // Client runtime config; registered before express.static so it shadows the + // fallback config.js in the client build output + const clientConfigScript = `window.__CHECKMATE_CONFIG__ = ${JSON.stringify(envSettings.clientConfig).replace(/ { + res.set("Cache-Control", "no-cache").type("application/javascript").send(clientConfigScript); + }); + app.use(express.static(frontendPath)); app.use(express.json()); diff --git a/server/src/config/envValidation.ts b/server/src/config/envValidation.ts index 05471e690..0bfaa01ce 100644 --- a/server/src/config/envValidation.ts +++ b/server/src/config/envValidation.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { DbTypes, QueueModes } from "@/domain/app-settings/app-settings.type.js"; +import { DbTypes, LogLevels, QueueModes } from "@/domain/app-settings/app-settings.type.js"; import { booleanCoercion } from "@/api/validation/shared.js"; import { ILogger } from "@/utils/logger.js"; @@ -9,7 +9,7 @@ const envSchema = z.object({ // Port for worker health checks HEALTH_PORT: z.string().default("52346"), NODE_ENV: z.enum(["development", "production", "test"]).default("development"), - LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("debug"), + LOG_LEVEL: z.enum(LogLevels).default("debug"), // Database DB_CONNECTION_STRING: z.string().min(1, "Database connection string is required"), @@ -25,6 +25,12 @@ const envSchema = z.object({ // Client Configuration CLIENT_HOST: z.string().url("CLIENT_HOST must be a valid URL"), + // Client runtime config overrides, rendered into GET /config.js; unset keys are + // omitted and the client uses its same-origin defaults + CLIENT_CONFIG_API_BASE_URL: z.string().optional(), + CLIENT_CONFIG_CLIENT_HOST: z.string().url("CLIENT_CONFIG_CLIENT_HOST must be a valid URL").optional(), + CLIENT_CONFIG_LOG_LEVEL: z.enum(LogLevels).optional(), + // Optional ORIGIN: z.string().optional(), diff --git a/server/src/domain/app-settings/app-settings.service.ts b/server/src/domain/app-settings/app-settings.service.ts index 8beb65d84..92a54702e 100755 --- a/server/src/domain/app-settings/app-settings.service.ts +++ b/server/src/domain/app-settings/app-settings.service.ts @@ -1,5 +1,5 @@ import { ISettingsRepository } from "@/domain/app-settings/app-settings-repository.interface.js"; -import { Settings, SettingsUpdate, type DbType, type QueueMode } from "@/domain/app-settings/app-settings.type.js"; +import { Settings, SettingsUpdate, type ClientRuntimeConfig, type DbType, type QueueMode } from "@/domain/app-settings/app-settings.type.js"; import { AppError } from "@/utils/AppError.js"; import { ValidatedEnv } from "@/config/envValidation.js"; import type { StringValue } from "ms"; @@ -16,6 +16,7 @@ export type EnvConfig = { queueMode: QueueMode; queuePrimaryProcesses: boolean; statusPageThemesEnabled: boolean; + clientConfig: ClientRuntimeConfig; }; export interface ISettingsService { @@ -44,6 +45,11 @@ export class SettingsService implements ISettingsService { queueMode: env.QUEUE_MODE, queuePrimaryProcesses: env.QUEUE_PRIMARY_PROCESSES, statusPageThemesEnabled: env.STATUS_PAGE_THEMES_ENABLED, + clientConfig: { + ...(env.CLIENT_CONFIG_API_BASE_URL && { apiBaseUrl: env.CLIENT_CONFIG_API_BASE_URL }), + ...(env.CLIENT_CONFIG_CLIENT_HOST && { clientHost: env.CLIENT_CONFIG_CLIENT_HOST }), + ...(env.CLIENT_CONFIG_LOG_LEVEL && { logLevel: env.CLIENT_CONFIG_LOG_LEVEL }), + }, }; } diff --git a/server/src/domain/app-settings/app-settings.type.ts b/server/src/domain/app-settings/app-settings.type.ts index 8cb52e93d..8233ced63 100644 --- a/server/src/domain/app-settings/app-settings.type.ts +++ b/server/src/domain/app-settings/app-settings.type.ts @@ -4,6 +4,17 @@ export type DbType = (typeof DbTypes)[number]; export const QueueModes = ["primary", "worker"] as const; export type QueueMode = (typeof QueueModes)[number]; +export const LogLevels = ["error", "warn", "info", "debug"] as const; +export type LogLevel = (typeof LogLevels)[number]; + +// Rendered into GET /config.js as window.__CHECKMATE_CONFIG__; keys left unset +// fall back to the client's same-origin defaults. +export type ClientRuntimeConfig = { + apiBaseUrl?: string; + clientHost?: string; + logLevel?: LogLevel; +}; + export interface SettingsThresholds { cpu?: number; memory?: number; diff --git a/server/test/unit/services/settingsService.test.ts b/server/test/unit/services/settingsService.test.ts index af58b5705..43ea7fe18 100644 --- a/server/test/unit/services/settingsService.test.ts +++ b/server/test/unit/services/settingsService.test.ts @@ -83,6 +83,19 @@ describe("SettingsService", () => { dbConnectionString: "mongodb://localhost:27017/test_db", dbType: "mongodb", statusPageThemesEnabled: false, + clientConfig: {}, + }); + }); + + it("maps only set CLIENT_CONFIG_* env vars into clientConfig", () => { + const { service } = createService({ + CLIENT_CONFIG_API_BASE_URL: "https://api.example.com/api/v1", + CLIENT_CONFIG_LOG_LEVEL: "warn", + }); + + expect(service.loadSettings().clientConfig).toEqual({ + apiBaseUrl: "https://api.example.com/api/v1", + logLevel: "warn", }); });