From 6b51104010eb3b3b98d2c95a4fc1e34e8ea51de1 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 20:46:06 +0900 Subject: [PATCH] feat: validate runtime configuration before mount --- package.json | 3 +- pnpm-lock.yaml | 8 ++ public/config.json | 13 +++ src/bootstrap/load-runtime-config.js | 104 ++++++++++++++++++++ src/bootstrap/main.jsx | 43 ++++++-- src/bootstrap/runtime-config-schema.js | 66 +++++++++++++ src/contracts/env.js | 54 ++++++++++ tests/runtime-schema/runtime-config.test.js | 73 ++++++++++++++ 8 files changed, 356 insertions(+), 8 deletions(-) create mode 100644 public/config.json create mode 100644 src/bootstrap/load-runtime-config.js create mode 100644 src/bootstrap/runtime-config-schema.js create mode 100644 src/contracts/env.js create mode 100644 tests/runtime-schema/runtime-config.test.js diff --git a/package.json b/package.json index e9cb437..66ee01d 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ }, "dependencies": { "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "zod": "4.4.3" }, "devDependencies": { "@axe-core/playwright": "4.12.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2304627..c89142c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: react-dom: specifier: 19.2.8 version: 19.2.8(react@19.2.8) + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@axe-core/playwright': specifier: 4.12.1 @@ -1584,6 +1587,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@adobe/css-tools@4.5.0': {} @@ -2866,3 +2872,5 @@ snapshots: yargs-parser: 21.1.1 yocto-queue@0.1.0: {} + + zod@4.4.3: {} diff --git a/public/config.json b/public/config.json new file mode 100644 index 0000000..9cd3dbe --- /dev/null +++ b/public/config.json @@ -0,0 +1,13 @@ +{ + "APP_ENV": "local", + "API_BASE_URL": "http://localhost:8080", + "REQUEST_TIMEOUT_MS": 10000, + "MAX_RETRY_ATTEMPTS": 2, + "TELEMETRY_ENABLED": false, + "AUTH_MODE": "external", + "CONFIG_SCHEMA_VERSION": "1", + "API_CONTRACT_VERSION": "1", + "RELEASE_MANIFEST_URL": "/release-manifest.json", + "BUILD_ID": "local-build", + "RELEASE_ID": "local-release" +} diff --git a/src/bootstrap/load-runtime-config.js b/src/bootstrap/load-runtime-config.js new file mode 100644 index 0000000..286b2b0 --- /dev/null +++ b/src/bootstrap/load-runtime-config.js @@ -0,0 +1,104 @@ +import { assertSafeConfigNames, getBuildConfig } from "../contracts/env.js"; +import { validateRuntimeConfig } from "./runtime-config-schema.js"; + +export class BootConfigError extends Error { + /** + * @param {string} code + * @param {{ buildId: string, configSchemaVersion?: string, releaseId?: string }} safe + */ + constructor(code, safe) { + super("Runtime configuration could not be loaded"); + this.name = "BootConfigError"; + this.kind = "BOOT_CONFIG_FAILURE"; + this.code = code; + this.safe = Object.freeze({ + kind: this.kind, + code, + buildId: safe.buildId, + configSchemaVersion: safe.configSchemaVersion, + releaseId: safe.releaseId, + supportReference: `${safe.buildId}:${code}`, + }); + } +} + +/** + * @param {{ + * fetcher?: typeof fetch, + * buildConfig?: ReturnType, + * now?: () => number + * }} [options] + */ +export async function loadRuntimeConfig(options = {}) { + const fetcher = options.fetcher ?? fetch; + const buildConfig = options.buildConfig ?? getBuildConfig(); + const now = options.now ?? performance.now.bind(performance); + const startedAt = now(); + + let response; + try { + response = await fetcher(buildConfig.runtimeConfigUrl, { + cache: "no-store", + headers: { Accept: "application/json" }, + }); + } catch { + throw new BootConfigError("CONFIG_FETCH_FAILED", { + buildId: buildConfig.buildId, + }); + } + + if (!response.ok) { + throw new BootConfigError("CONFIG_HTTP_FAILED", { + buildId: buildConfig.buildId, + }); + } + + let rawConfig; + try { + rawConfig = await response.json(); + } catch { + throw new BootConfigError("CONFIG_JSON_INVALID", { + buildId: buildConfig.buildId, + }); + } + + if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) { + throw new BootConfigError("CONFIG_SHAPE_INVALID", { + buildId: buildConfig.buildId, + }); + } + + try { + assertSafeConfigNames(rawConfig); + } catch { + throw new BootConfigError("CONFIG_SECRET_NAME_REJECTED", { + buildId: buildConfig.buildId, + }); + } + + const validated = validateRuntimeConfig(rawConfig); + if (!validated.success) { + throw new BootConfigError("CONFIG_SCHEMA_INVALID", { + buildId: buildConfig.buildId, + configSchemaVersion: + typeof rawConfig.CONFIG_SCHEMA_VERSION === "string" + ? rawConfig.CONFIG_SCHEMA_VERSION + : undefined, + releaseId: typeof rawConfig.RELEASE_ID === "string" ? rawConfig.RELEASE_ID : undefined, + }); + } + + if (validated.data.BUILD_ID && validated.data.BUILD_ID !== buildConfig.buildId) { + throw new BootConfigError("CONFIG_BUILD_MISMATCH", { + buildId: buildConfig.buildId, + configSchemaVersion: validated.data.CONFIG_SCHEMA_VERSION, + releaseId: validated.data.RELEASE_ID, + }); + } + + return Object.freeze({ + config: validated.data, + build: buildConfig, + validationDurationMs: now() - startedAt, + }); +} diff --git a/src/bootstrap/main.jsx b/src/bootstrap/main.jsx index 9652834..5d40e93 100644 --- a/src/bootstrap/main.jsx +++ b/src/bootstrap/main.jsx @@ -1,11 +1,24 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -function BootstrapShell() { +import { BootConfigError, loadRuntimeConfig } from "./load-runtime-config.js"; + +/** @param {{ environment: string }} props */ +function BootstrapShell({ environment }) { return (

Clean Architecture Frontend

-

런타임 계약을 불러오는 중입니다.

+

{environment} 런타임 계약이 검증되었습니다.

+
+ ); +} + +/** @param {{ supportReference: string }} props */ +function BootErrorShell({ supportReference }) { + return ( +
+

애플리케이션을 시작할 수 없습니다.

+

지원 참조: {supportReference}

); } @@ -16,8 +29,24 @@ if (!rootElement) { throw new Error("Missing #root mount element"); } -createRoot(rootElement).render( - - - , -); +const root = createRoot(rootElement); + +async function boot() { + try { + const runtime = await loadRuntimeConfig(); + root.render( + + + , + ); + } catch (error) { + const supportReference = + error instanceof BootConfigError + ? error.safe.supportReference + : "boot:unknown"; + + root.render(); + } +} + +void boot(); diff --git a/src/bootstrap/runtime-config-schema.js b/src/bootstrap/runtime-config-schema.js new file mode 100644 index 0000000..9de4cb7 --- /dev/null +++ b/src/bootstrap/runtime-config-schema.js @@ -0,0 +1,66 @@ +import { z } from "zod"; + +const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/); + +export const runtimeConfigSchema = z + .object({ + APP_ENV: z.enum(["local", "development", "staging", "production"]), + API_BASE_URL: z.url(), + REQUEST_TIMEOUT_MS: z.int().min(100).max(60_000).default(10_000), + MAX_RETRY_ATTEMPTS: z.int().min(0).max(2).default(2), + TELEMETRY_ENABLED: z.boolean(), + TELEMETRY_ENDPOINT: z.url().optional(), + AUTH_MODE: z.literal("external"), + CONFIG_SCHEMA_VERSION: version, + API_CONTRACT_VERSION: version, + RELEASE_MANIFEST_URL: z.string().min(1).default("/release-manifest.json"), + RELEASE_ID: z.string().min(1).optional(), + BUILD_ID: z.string().min(1).optional(), + }) + .strict() + .superRefine((config, context) => { + if (config.TELEMETRY_ENABLED && !config.TELEMETRY_ENDPOINT) { + context.addIssue({ + code: "custom", + path: ["TELEMETRY_ENDPOINT"], + message: "required when telemetry is enabled", + }); + } + + const local = config.APP_ENV === "local" || config.APP_ENV === "development"; + const endpointEntries = + /** @type {Array<[string, string | undefined]>} */ ([ + ["API_BASE_URL", config.API_BASE_URL], + ["TELEMETRY_ENDPOINT", config.TELEMETRY_ENDPOINT], + ]); + + for (const [key, value] of endpointEntries) { + if (value && !local && new URL(value).protocol !== "https:") { + context.addIssue({ + code: "custom", + path: [key], + message: "HTTPS is required outside local environments", + }); + } + } + }); + +/** @param {unknown} value */ +export function validateRuntimeConfig(value) { + const result = runtimeConfigSchema.safeParse(value); + + if (!result.success) { + return { + success: /** @type {false} */ (false), + issues: result.error.issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + })), + }; + } + + return { + success: /** @type {true} */ (true), + data: structuredClone(result.data), + }; +} diff --git a/src/contracts/env.js b/src/contracts/env.js new file mode 100644 index 0000000..8b94302 --- /dev/null +++ b/src/contracts/env.js @@ -0,0 +1,54 @@ +const forbiddenConfigName = /(SECRET|PASSWORD|PRIVATE_KEY|TOKEN)/i; + +export const ENV_REGISTRY = Object.freeze({ + VITE_BUILD_ID: build("public-metadata", true, null), + VITE_COMMIT_SHA: build("public-metadata", false, "local"), + VITE_ROUTER_BASE_PATH: build("compile-time", true, "/"), + VITE_RUNTIME_CONFIG_URL: build("compile-time", true, "/config.json"), + APP_ENV: runtime("public", true, null), + API_BASE_URL: runtime("public-sensitive", true, null), + REQUEST_TIMEOUT_MS: runtime("public", false, 10_000), + MAX_RETRY_ATTEMPTS: runtime("public", false, 2), + TELEMETRY_ENABLED: runtime("public", true, false), + TELEMETRY_ENDPOINT: runtime("public-sensitive", false, null), + AUTH_MODE: runtime("public", true, "external"), + CONFIG_SCHEMA_VERSION: runtime("public", true, null), + API_CONTRACT_VERSION: runtime("public", true, null), + RELEASE_MANIFEST_URL: runtime("public", true, "/release-manifest.json"), +}); + +/** + * @param {string} classification + * @param {boolean} required + * @param {unknown} defaultValue + */ +function build(classification, required, defaultValue) { + return Object.freeze({ phase: "build", classification, required, defaultValue }); +} + +/** + * @param {string} classification + * @param {boolean} required + * @param {unknown} defaultValue + */ +function runtime(classification, required, defaultValue) { + return Object.freeze({ phase: "runtime", classification, required, defaultValue }); +} + +/** @param {Record} config */ +export function assertSafeConfigNames(config) { + for (const name of Object.keys(config)) { + if (forbiddenConfigName.test(name)) { + throw new Error(`Forbidden client configuration key: ${name}`); + } + } +} + +export function getBuildConfig(environment = import.meta.env) { + const buildId = environment.VITE_BUILD_ID || "local-build"; + const commitSha = environment.VITE_COMMIT_SHA || "local"; + const routerBasePath = environment.VITE_ROUTER_BASE_PATH || "/"; + const runtimeConfigUrl = environment.VITE_RUNTIME_CONFIG_URL || "/config.json"; + + return Object.freeze({ buildId, commitSha, routerBasePath, runtimeConfigUrl }); +} diff --git a/tests/runtime-schema/runtime-config.test.js b/tests/runtime-schema/runtime-config.test.js new file mode 100644 index 0000000..114694e --- /dev/null +++ b/tests/runtime-schema/runtime-config.test.js @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { loadRuntimeConfig } from "../../src/bootstrap/load-runtime-config.js"; +import { validateRuntimeConfig } from "../../src/bootstrap/runtime-config-schema.js"; +import { assertSafeConfigNames } from "../../src/contracts/env.js"; + +const validConfig = { + APP_ENV: "local", + API_BASE_URL: "http://localhost:8080", + REQUEST_TIMEOUT_MS: 10_000, + MAX_RETRY_ATTEMPTS: 2, + TELEMETRY_ENABLED: false, + AUTH_MODE: "external", + CONFIG_SCHEMA_VERSION: "1", + API_CONTRACT_VERSION: "1", + RELEASE_MANIFEST_URL: "/release-manifest.json", + BUILD_ID: "build-a", +}; + +describe("runtime configuration boundary", () => { + it.each([ + [{ ...validConfig, API_BASE_URL: undefined }, "required key"], + [{ ...validConfig, REQUEST_TIMEOUT_MS: 0 }, "integer range"], + [{ ...validConfig, MAX_RETRY_ATTEMPTS: 3 }, "retry cap"], + [{ ...validConfig, TELEMETRY_ENABLED: "false" }, "ambiguous boolean"], + [{ ...validConfig, CONFIG_SCHEMA_VERSION: "next" }, "version"], + [{ ...validConfig, UNKNOWN_KEY: true }, "unknown key"], + ])("rejects invalid config: %s (%s)", (candidate) => { + expect(validateRuntimeConfig(candidate).success).toBe(false); + }); + + it("rejects secret-like names before schema validation", () => { + expect(() => assertSafeConfigNames({ CLIENT_SECRET: "not-safe" })).toThrow( + "Forbidden client configuration key", + ); + }); + + it("validates a fetched config under the 500ms budget excluding network", async () => { + let current = 100; + const result = await loadRuntimeConfig({ + buildConfig: { + buildId: "build-a", + commitSha: "local", + routerBasePath: "/", + runtimeConfigUrl: "/config.json", + }, + fetcher: async () => new Response(JSON.stringify(validConfig)), + now: () => (current += 2), + }); + + expect(result.validationDurationMs).toBeLessThanOrEqual(500); + expect(result.config.API_BASE_URL).toBe("http://localhost:8080"); + }); + + it("returns only safe boot fields on failure", async () => { + await expect( + loadRuntimeConfig({ + buildConfig: { + buildId: "build-a", + commitSha: "local", + routerBasePath: "/", + runtimeConfigUrl: "/config.json", + }, + fetcher: async () => new Response("{"), + }), + ).rejects.toMatchObject({ + safe: { + kind: "BOOT_CONFIG_FAILURE", + buildId: "build-a", + }, + }); + }); +});