import { z } from 'zod'; /** * Environment contract for the relay. Validation happens once at boot: a * missing/typo'd variable must fail loudly at startup, never mid-conversation. */ /** `.default()` has to precede `.transform()` in zod v4, hence the factories. */ const boolish = (def: string) => z .string() .default(def) .transform((v) => ['1', 'true', 'yes', 'on'].includes(v.trim().toLowerCase())); const csv = (def: string) => z .string() .default(def) .transform((v) => v .split(',') .map((s) => s.trim().toUpperCase()) .filter(Boolean), ); const schema = z.object({ NODE_ENV: z.enum(['development', 'test', 'production']).default('production'), PORT: z.coerce.number().int().positive().default(3000), RELAY_MODE: z.enum(['dev', 'prod']).default('prod'), DATABASE_URL: z.string().min(1).default('file:../data/eks_relay.db'), // --- Chatwoot --- CHATWOOT_BASE_URL: z.string().url(), CHATWOOT_API_TOKEN: z.string().min(1), CHATWOOT_ACCOUNT_ID: z.coerce.number().int().positive().default(1), CHATWOOT_TICKET_LABEL: z.string().min(1).default('ticket'), CHATWOOT_SPAM_LABEL: z.string().default('spam'), CHATWOOT_APPLY_SPAM_LABEL: boolish('false'), CHATWOOT_UNASSIGN_ON_TICKET: boolish('false'), /// Webchat / API inbox — reserved for the later web widget adapter. CHATWOOT_API_INBOX_ID: z.string().default(''), CHATWOOT_API_IDENTITY_VALIDATION_TOKEN: z.string().default(''), // --- Flowise --- FLOWISE_PREDICT_URL: z.string().url(), FLOWISE_API_KEY: z.string().default(''), FLOWISE_BASE_URL: z.string().default(''), FLOWISE_TIMEOUT_MS: z.coerce.number().int().positive().default(90_000), // --- WooCommerce / WordPress --- WOOCOMMERCE_BASE_URL: z.string().url(), WOOCOMMERCE_CONSUMER_KEY: z.string().min(1), WOOCOMMERCE_CONSUMER_SECRET: z.string().min(1), WP_STORE_API_SECRET: z.string().default(''), STORE_CURRENCIES: csv('PLN,EUR,AED,CZK,HUF,DKK,SEK,NOK,RON,BGN,GBP'), // --- Relay auth --- RELAY_SHARED_SECRET: z.string().min(8), ADMIN_TOKEN: z.string().default(''), // --- Behaviour --- TENANT_ID: z.string().default('easyklima'), DEFAULT_LANGUAGE: z.string().default('pl'), TICKET_NUMBER_PREFIX: z.string().default('EKS'), WORKER_ENABLED: boolish('true'), WORKER_POLL_MS: z.coerce.number().int().positive().default(1_000), WORKER_MAX_ATTEMPTS: z.coerce.number().int().positive().default(3), SPAM_GATE_ENABLED: boolish('true'), HTTP_TIMEOUT_MS: z.coerce.number().int().positive().default(30_000), // --- Logging --- LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), LOG_PII: boolish('false'), }); export type Config = z.infer; let cached: Config | null = null; export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { const parsed = schema.safeParse(env); if (!parsed.success) { // Only names of the offending variables — never their values. const issues = parsed.error.issues .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`) .join('; '); throw new Error(`Invalid environment configuration: ${issues}`); } return parsed.data; } export function config(): Config { cached ??= loadConfig(); return cached; } /** Test hook: inject a config without touching process.env. */ export function setConfigForTests(cfg: Config): void { cached = cfg; }