import process from 'node:process' import type { AdminDefaultUser, RuntimeConfig } from '../types/runtime-config.js' type RuntimeEnvironment = { NODE_ENV?: string CI?: string } type ValidationOptions = { env?: RuntimeEnvironment } export type RuntimeConfigValidationIssue = { path: string message: string } export class RuntimeConfigValidationError extends Error { issues: RuntimeConfigValidationIssue[] constructor(issues: RuntimeConfigValidationIssue[]) { super(formatRuntimeConfigValidationMessage(issues)) this.name = 'RuntimeConfigValidationError' this.issues = issues } } export function assertRuntimeConfigValid( config: RuntimeConfig, options: ValidationOptions = {}, ): void { const issues = validateRuntimeConfig(config, options) if (issues.length > 0) { throw new RuntimeConfigValidationError(issues) } } export function validateRuntimeConfig( config: RuntimeConfig, { env = process.env }: ValidationOptions = {}, ): RuntimeConfigValidationIssue[] { const issues: RuntimeConfigValidationIssue[] = [] const productionLike = isProductionLike(env) requireInteger(issues, 'server.port', config.server?.port, { min: 1, max: 65535 }) const trustProxy = config.server?.trustProxy if (trustProxy !== undefined && trustProxy !== null && !isValidTrustProxy(trustProxy)) { issues.push({ path: 'server.trustProxy', message: 'server.trustProxy 仅支持 true/false、非负整数或 loopback/uniquelocal/linklocal', }) } requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 }) requireOptionalInteger(issues, 'logging.maxFileSizeMb', config.logging?.maxFileSizeMb, { min: 1 }) requireOptionalInteger(issues, 'logging.maxFiles', config.logging?.maxFiles, { min: 2 }) requireOptionalInteger(issues, 'database.idleTimeoutMs', config.database?.idleTimeoutMs, { min: 1, }) requireOptionalInteger( issues, 'database.connectionTimeoutMs', config.database?.connectionTimeoutMs, { min: 1 }, ) requireOptionalInteger( issues, 'database.statementTimeoutMs', config.database?.statementTimeoutMs, { min: 1, }, ) requireOptionalInteger( issues, 'database.slowQueryThresholdMs', config.database?.slowQueryThresholdMs, { min: 0 }, ) requireInteger(issues, 'storage.maxUploadSizeMb', config.storage?.maxUploadSizeMb, { min: 1, max: 100, }) if (config.retention) { requireInteger(issues, 'retention.rawPayloadDays', config.retention.rawPayloadDays, { min: 1 }) requireInteger(issues, 'retention.taskEventDays', config.retention.taskEventDays, { min: 1 }) requireInteger(issues, 'retention.webhookEventDays', config.retention.webhookEventDays, { min: 1, }) requireInteger(issues, 'retention.auditLogDays', config.retention.auditLogDays, { min: 1 }) } const storageMode = normalizeStorageMode(config.storage?.mode) if (!storageMode) { issues.push({ path: 'storage.mode', message: '仅支持 minio、dual 或 oss', }) } requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 0 }) requireInteger(issues, 'admin.sessionIdleTtlHours', config.admin?.sessionIdleTtlHours, { min: 1 }) requireInteger(issues, 'admin.sessionAbsoluteTtlHours', config.admin?.sessionAbsoluteTtlHours, { min: 1, }) requireInteger(issues, 'worker.sessionIdleTtlHours', config.worker?.sessionIdleTtlHours, { min: 1, }) requireInteger(issues, 'worker.sessionAbsoluteTtlHours', config.worker?.sessionAbsoluteTtlHours, { min: 1, }) validateOptionalHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl) validateOptionalStorageEndpoint(issues, 'storage.endpoint', config.storage?.endpoint) validateOptionalStorageEndpoint(issues, 'storage.oss.endpoint', config.storage?.oss?.endpoint) validateRequiredString(issues, 'data.root', config.data?.root) if (productionLike) { validateRequiredString(issues, 'database.url', config.database?.url) if (storageMode === 'minio' || storageMode === 'dual') { validateStorageCredentials(issues, 'storage', config.storage) } if (storageMode === 'oss' || storageMode === 'dual') { validateStorageCredentials(issues, 'storage.oss', config.storage?.oss) } validateRequiredHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl) validateSecret(issues, 'admin.sessionSecret', config.admin?.sessionSecret, { minLength: 32 }) validateAdminDefaultUsers(issues, config.admin?.defaultUsers) const allowedOrigins = config.cors?.allowedOrigins if (Array.isArray(allowedOrigins)) { if (allowedOrigins.length === 0 || allowedOrigins.includes('*')) { issues.push({ path: 'cors.allowedOrigins', message: '生产环境必须配置显式 CORS 域名白名单,不能使用 *', }) } else { allowedOrigins.forEach((origin, index) => { if (!isHttpUrl(String(origin || '').trim())) { issues.push({ path: `cors.allowedOrigins[${index}]`, message: '必须是 http 或 https 来源 URL', }) } }) } } } return issues } function normalizeStorageMode(value: unknown): 'minio' | 'dual' | 'oss' | '' { const mode = String(value || 'minio') .trim() .toLowerCase() return mode === 'minio' || mode === 'dual' || mode === 'oss' ? mode : '' } function validateStorageCredentials( issues: RuntimeConfigValidationIssue[], configPath: string, storage: | { endpoint?: unknown bucket?: unknown accessKeyId?: unknown secretAccessKey?: unknown } | null | undefined, ): void { validateRequiredString(issues, `${configPath}.endpoint`, storage?.endpoint) validateOptionalStorageEndpoint(issues, `${configPath}.endpoint`, storage?.endpoint) validateRequiredString(issues, `${configPath}.bucket`, storage?.bucket) validateRequiredString(issues, `${configPath}.accessKeyId`, storage?.accessKeyId) validateSecret(issues, `${configPath}.secretAccessKey`, storage?.secretAccessKey, { minLength: 8, }) } export function isProductionLike(env: RuntimeEnvironment = process.env): boolean { return env.NODE_ENV === 'production' } function validateAdminDefaultUsers( issues: RuntimeConfigValidationIssue[], users: AdminDefaultUser[] | undefined, ): void { if (!Array.isArray(users) || users.length === 0) { issues.push({ path: 'admin.defaultUsers', message: '生产环境必须配置至少一个后台默认用户', }) return } users.forEach((user, index) => { validateRequiredString(issues, `admin.defaultUsers[${index}].username`, user?.username) validateSecret(issues, `admin.defaultUsers[${index}].password`, user?.password, { minLength: 8, }) validateRequiredString(issues, `admin.defaultUsers[${index}].role`, user?.role) }) } function validateRequiredString( issues: RuntimeConfigValidationIssue[], configPath: string, value: unknown, ): void { if (!String(value || '').trim()) { issues.push({ path: configPath, message: '必须配置非空字符串', }) } } function validateSecret( issues: RuntimeConfigValidationIssue[], configPath: string, value: unknown, { minLength }: { minLength: number }, ): void { const text = String(value || '').trim() if (!text) { issues.push({ path: configPath, message: '必须配置密钥', }) return } if (text.length < minLength) { issues.push({ path: configPath, message: `长度不能少于 ${minLength} 个字符`, }) } if (/change-me|example|password|secret/i.test(text)) { issues.push({ path: configPath, message: '不能使用示例占位值', }) } } function validateOptionalHttpUrl( issues: RuntimeConfigValidationIssue[], configPath: string, value: unknown, ): void { const text = String(value || '').trim() if (!text) { return } if (!isHttpUrl(text)) { issues.push({ path: configPath, message: '必须是 http 或 https URL', }) } } function validateRequiredHttpUrl( issues: RuntimeConfigValidationIssue[], configPath: string, value: unknown, ): void { validateRequiredString(issues, configPath, value) validateOptionalHttpUrl(issues, configPath, value) } function validateOptionalStorageEndpoint( issues: RuntimeConfigValidationIssue[], configPath: string, value: unknown, ): void { const text = String(value || '').trim() if (!text) { return } if (/^https?:\/\//i.test(text)) { validateOptionalHttpUrl(issues, configPath, text) return } if (!/^[a-z0-9.-]+(?::\d{1,5})?$/i.test(text)) { issues.push({ path: configPath, message: '必须是 http/https URL 或 host:port', }) } } function requireInteger( issues: RuntimeConfigValidationIssue[], configPath: string, value: unknown, { min, max }: { min?: number; max?: number } = {}, ): void { const numberValue = Number(value) if (!Number.isInteger(numberValue)) { issues.push({ path: configPath, message: '必须是整数', }) return } if (typeof min === 'number' && numberValue < min) { issues.push({ path: configPath, message: `不能小于 ${min}`, }) } if (typeof max === 'number' && numberValue > max) { issues.push({ path: configPath, message: `不能大于 ${max}`, }) } } function requireOptionalInteger( issues: RuntimeConfigValidationIssue[], configPath: string, value: unknown, options: { min?: number; max?: number } = {}, ): void { if (value === undefined || value === null || value === '') { return } requireInteger(issues, configPath, value, options) } function isValidTrustProxy(value: unknown): boolean { if (typeof value === 'boolean') return true if (typeof value === 'number') return Number.isInteger(value) && value >= 0 return ['loopback', 'uniquelocal', 'linklocal'].includes(String(value)) } function isHttpUrl(value: string): boolean { try { const url = new URL(value) return url.protocol === 'http:' || url.protocol === 'https:' } catch { return false } } function formatRuntimeConfigValidationMessage(issues: RuntimeConfigValidationIssue[]): string { const lines = issues.map((issue) => `- ${issue.path}: ${issue.message}`) return `运行时配置校验失败:\n${lines.join('\n')}` }