diff --git a/backend/src/lib/config.ts b/backend/src/lib/config.ts index eb30e1a..234f7ed 100644 --- a/backend/src/lib/config.ts +++ b/backend/src/lib/config.ts @@ -2,35 +2,116 @@ import { z } from 'zod'; import { baseBackendConfigSchema } from '@bytelyst/backend-config'; import { PRODUCT_ID } from './product-config.js'; -const envSchema = baseBackendConfigSchema.extend({ +export const DEFAULT_DEV_JWT_SECRET = 'dev-secret-do-not-use-in-prod'; + +function envBoolean(defaultValue: boolean) { + return z.preprocess((value) => { + if (value === undefined || value === '') return undefined; + if (typeof value !== 'string') return value; + + const normalized = value.trim().toLowerCase(); + if (['true', '1', 'yes', 'on'].includes(normalized)) return true; + if (['false', '0', 'no', 'off'].includes(normalized)) return false; + return value; + }, z.boolean().default(defaultValue)); +} + +function isBlank(value: string | undefined): boolean { + return value === undefined || value.trim().length === 0; +} + +function addConfigIssue(ctx: z.RefinementCtx, path: string, message: string): void { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [path], + message, + }); +} + +export const envSchema = baseBackendConfigSchema.extend({ PORT: baseBackendConfigSchema.shape.PORT.default(4016), SERVICE_NAME: baseBackendConfigSchema.shape.SERVICE_NAME.default('notelett-backend'), DB_PROVIDER: baseBackendConfigSchema.shape.DB_PROVIDER.default('memory'), - JWT_SECRET: z.string().default('dev-secret-do-not-use-in-prod'), + JWT_SECRET: z.preprocess( + value => (typeof value === 'string' && value.trim() === '' ? undefined : value), + z.string().default(DEFAULT_DEV_JWT_SECRET) + ), COSMOS_DATABASE: baseBackendConfigSchema.shape.COSMOS_DATABASE.default('bytelyst'), PRODUCT_ID: z.string().default(PRODUCT_ID), PLATFORM_SERVICE_URL: z.string().default('http://localhost:4003'), EXTRACTION_SERVICE_URL: z.string().default('http://localhost:4005'), MCP_SERVER_URL: z.string().default('http://localhost:4007'), - TELEMETRY_ENABLED: z.coerce.boolean().default(false), - FEATURE_FLAGS_ENABLED: z.coerce.boolean().default(false), + TELEMETRY_ENABLED: envBoolean(false), + FEATURE_FLAGS_ENABLED: envBoolean(false), // ── LLM (@bytelyst/llm) ── LLM_PROVIDER: z.enum(['azure', 'openai', 'mock']).default('mock'), LLM_DEFAULT_MODEL: z.string().default('gpt-4o-mini'), LLM_VISION_MODEL: z.string().default('gpt-4o'), LLM_EMBEDDING_MODEL: z.string().default('text-embedding-3-small'), // ── Field Encryption (@bytelyst/field-encrypt) ── - FIELD_ENCRYPT_ENABLED: z.coerce.boolean().default(true), + FIELD_ENCRYPT_ENABLED: envBoolean(true), FIELD_ENCRYPT_KEY_PROVIDER: z.enum(['akv', 'env', 'memory']).default('memory'), FIELD_ENCRYPT_KEY: z.string().default(''), FIELD_ENCRYPT_MEK_NAME: z.string().default('notelett-mek'), AZURE_KEYVAULT_URL: z.string().default(''), // ── Palace (MemPalace) ── - PALACE_ENABLED: z.coerce.boolean().default(true), - PALACE_EXTRACTION_ENABLED: z.coerce.boolean().default(true), + PALACE_ENABLED: envBoolean(true), + PALACE_EXTRACTION_ENABLED: envBoolean(true), PALACE_WAKE_UP_BUDGET: z.coerce.number().int().min(100).default(600), PALACE_RELEVANCE_HALF_LIFE_DAYS: z.coerce.number().int().min(1).default(90), PALACE_DEDUP_THRESHOLD: z.coerce.number().min(0).max(1).default(0.90), +}).superRefine((env, ctx) => { + const isProduction = env.NODE_ENV === 'production'; + + if (env.FIELD_ENCRYPT_ENABLED && env.FIELD_ENCRYPT_KEY_PROVIDER === 'env' && isBlank(env.FIELD_ENCRYPT_KEY)) { + addConfigIssue(ctx, 'FIELD_ENCRYPT_KEY', 'FIELD_ENCRYPT_KEY is required when FIELD_ENCRYPT_KEY_PROVIDER=env'); + } + + if (env.FIELD_ENCRYPT_ENABLED && env.FIELD_ENCRYPT_KEY_PROVIDER === 'akv' && isBlank(env.AZURE_KEYVAULT_URL)) { + addConfigIssue(ctx, 'AZURE_KEYVAULT_URL', 'AZURE_KEYVAULT_URL is required when FIELD_ENCRYPT_KEY_PROVIDER=akv'); + } + + if (!isProduction) { + return; + } + + if (env.JWT_SECRET === DEFAULT_DEV_JWT_SECRET) { + addConfigIssue(ctx, 'JWT_SECRET', 'Production JWT_SECRET must not use the development default'); + } + + if (env.JWT_SECRET.length < 32) { + addConfigIssue(ctx, 'JWT_SECRET', 'Production JWT_SECRET must be at least 32 characters'); + } + + if (env.DB_PROVIDER === 'memory') { + addConfigIssue(ctx, 'DB_PROVIDER', 'Production DB_PROVIDER must be cosmos'); + } + + if (env.DB_PROVIDER === 'cosmos') { + if (isBlank(env.COSMOS_ENDPOINT)) { + addConfigIssue(ctx, 'COSMOS_ENDPOINT', 'COSMOS_ENDPOINT is required in production'); + } + if (isBlank(env.COSMOS_KEY)) { + addConfigIssue(ctx, 'COSMOS_KEY', 'COSMOS_KEY is required in production'); + } + if (isBlank(env.COSMOS_DATABASE)) { + addConfigIssue(ctx, 'COSMOS_DATABASE', 'COSMOS_DATABASE is required in production'); + } + } + + if (!env.FIELD_ENCRYPT_ENABLED) { + addConfigIssue(ctx, 'FIELD_ENCRYPT_ENABLED', 'Field encryption must be enabled in production'); + } + + if (env.FIELD_ENCRYPT_KEY_PROVIDER === 'memory') { + addConfigIssue(ctx, 'FIELD_ENCRYPT_KEY_PROVIDER', 'Production FIELD_ENCRYPT_KEY_PROVIDER must be akv or env'); + } }); -export const config = envSchema.parse(process.env); +export type BackendConfig = z.infer; + +export function parseConfig(env: Record = process.env): BackendConfig { + return envSchema.parse(env); +} + +export const config = parseConfig(process.env as Record);