/** * T1 · Data-plane config — env → frozen DataPlaneConfig (INV9 fail-fast, no secret logged). * * Startup MUST fail if TLS server material, the AGENT_CA_CERT_PATH mTLS trust-anchor, or * BASE_DOMAIN is absent (a missing CA would silently degrade "mTLS" to accept-any-cert, * Finding-4). Zod validates at the boundary; the returned object is frozen (immutable). */ import { z } from 'zod' const MAX_FRAME_BYTES_DEFAULT = 1024 * 1024 // 1 MiB (§4.1 payloadLen ceiling) const INITIAL_WINDOW_BYTES_DEFAULT = 256 * 1024 // 256 KiB (§4.1 credit window) const HEARTBEAT_INTERVAL_MS_DEFAULT = 15_000 // §4.1: PING every 15s const ROUTE_TTL_MS_DEFAULT = 45_000 // Redis route:{host_id} TTL const BIND_HOST_DEFAULT = '0.0.0.0' const BIND_PORT_DEFAULT = 8443 const AGENT_BIND_PORT_DEFAULT = 8444 export interface DataPlaneConfig { readonly baseDomain: string readonly bindHost: string readonly bindPort: number readonly agentBindPort: number readonly tlsCertPath: string readonly tlsKeyPath: string readonly agentCaCertPath: string readonly agentCaChainPath: string readonly relayNodeId: string readonly maxFrameBytes: number readonly initialWindowBytes: number readonly heartbeatIntervalMs: number readonly routeTtlMs: number } const requiredPath = z.string().min(1) const port = z.coerce.number().int().gt(0).max(65535) const positiveInt = z.coerce.number().int().positive() const ConfigSchema = z.object({ baseDomain: z.string().min(1), bindHost: z.string().min(1).default(BIND_HOST_DEFAULT), bindPort: port.default(BIND_PORT_DEFAULT), agentBindPort: port.default(AGENT_BIND_PORT_DEFAULT), tlsCertPath: requiredPath, tlsKeyPath: requiredPath, agentCaCertPath: requiredPath, agentCaChainPath: requiredPath, relayNodeId: z.string().min(1), maxFrameBytes: positiveInt.default(MAX_FRAME_BYTES_DEFAULT), initialWindowBytes: positiveInt.default(INITIAL_WINDOW_BYTES_DEFAULT), heartbeatIntervalMs: positiveInt.default(HEARTBEAT_INTERVAL_MS_DEFAULT), routeTtlMs: positiveInt.default(ROUTE_TTL_MS_DEFAULT), }) /** * Load + validate the data-plane config from `env`. Throws a clear, secret-free error * (fail-fast, INV9) when a required key is missing or a numeric key is non-numeric. * `agentCaChainPath` falls back to `agentCaCertPath` when unset (single-tier CA). */ export function loadDataPlaneConfig(env: NodeJS.ProcessEnv): DataPlaneConfig { const parsed = ConfigSchema.safeParse({ baseDomain: env.BASE_DOMAIN, bindHost: env.BIND_HOST, bindPort: env.BIND_PORT, agentBindPort: env.AGENT_BIND_PORT, tlsCertPath: env.TLS_CERT_PATH, tlsKeyPath: env.TLS_KEY_PATH, agentCaCertPath: env.AGENT_CA_CERT_PATH, agentCaChainPath: env.AGENT_CA_CHAIN_PATH ?? env.AGENT_CA_CERT_PATH, relayNodeId: env.RELAY_NODE_ID, maxFrameBytes: env.MAX_FRAME_BYTES, initialWindowBytes: env.INITIAL_WINDOW_BYTES, heartbeatIntervalMs: env.HEARTBEAT_INTERVAL_MS, routeTtlMs: env.ROUTE_TTL_MS, }) if (!parsed.success) { // Report only WHICH keys are wrong — never echo values (INV9: no secret in logs). const fields = parsed.error.issues.map((i) => i.path.join('.')).join(', ') throw new Error(`invalid data-plane config: ${fields}`) } return Object.freeze(parsed.data) }