import { resolve } from 'path'; import { resolvePostgresConnection } from './managed-service-connections'; type EnvConfig = Record; // The 'main' (auth/audit) connection is always this fixed SQLite file (not env-overridable). const MAIN_DB_PATH = './data/main.sqlite'; /** * Fail-fast environment validation. Wired as ConfigModule's `validate` * callback so a misconfigured deployment is rejected at BOOT instead of silently * coercing (e.g. a `DATABASE_TYPE=postgre` typo falling back to SQLite) or failing on * the first query. Hand-rolled to avoid adding a `joi` dependency; same guarantees: * - DATABASE_TYPE must be a known value (no silent SQLite fallback on a typo) * - Postgres requires host/username/password * - PORT / DATABASE_PORT / REDIS_PORT must be valid integer ports */ export function validateEnv(config: EnvConfig): EnvConfig { const errors: string[] = []; const str = (key: string): string | undefined => { const value = config[key]; return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; }; const dbType = str('DATABASE_TYPE'); const databaseUrl = str('DATABASE_URL'); if (databaseUrl) { try { resolvePostgresConnection(config as NodeJS.ProcessEnv); } catch (error) { errors.push(error instanceof Error ? error.message : String(error)); } } // DATABASE_URL wins over the image's DATABASE_TYPE=sqlite default. This lets a Docker Space // stay zero-config on SQLite yet switch its data connection to managed PostgreSQL via a Secret. const effectiveDbType = databaseUrl ? 'postgres' : dbType; if (dbType && dbType !== 'sqlite' && dbType !== 'postgres') { errors.push(`DATABASE_TYPE must be "sqlite" or "postgres" (got "${dbType}")`); } // Whitelist the registered engine/storage ids so a typo fails fast at boot instead of silently // falling back to the default (engine.factory swallows an unknown ENGINE_TYPE → legacy wwebjs; // STORAGE_TYPE → local). Values must match the ids registered in engine.factory / configuration. const checkEnum = (key: string, allowed: string[]): void => { const value = str(key); if (value !== undefined && !allowed.includes(value)) { errors.push(`${key} must be one of ${allowed.map(v => `"${v}"`).join(', ')} (got "${value}")`); } }; checkEnum('ENGINE_TYPE', ['whatsapp-web.js', 'baileys']); checkEnum('STORAGE_TYPE', ['local', 's3']); if (effectiveDbType === 'postgres') { if (!databaseUrl) { for (const key of ['DATABASE_HOST', 'DATABASE_USERNAME', 'DATABASE_PASSWORD']) { if (!str(key)) { errors.push(`${key} is required when DATABASE_TYPE=postgres`); } } } // The Postgres data connection always runs migrations (app.module.ts hardcodes migrationsRun=true). // An opted-in DATABASE_SYNCHRONIZE=true makes TypeORM re-sync the schema from entities on every // boot, which immediately DROPS the migration-created `body_ts` generated tsvector column (the // Message entity doesn't declare it) → /search returns 501 on every restart. Prod default is // synchronize=false; reject only the breaking combo. Read raw (no trim) to match the exact // `=== 'true'` comparison at configuration.ts so the guard fires precisely when synchronize would // actually be enabled downstream. if (config['DATABASE_SYNCHRONIZE'] === 'true') { errors.push( 'DATABASE_SYNCHRONIZE=true is not allowed with DATABASE_TYPE=postgres: the Postgres data connection always runs migrations, and synchronize would drop the migration-created body_ts tsvector column that /search depends on (returns 501 on every restart). Set DATABASE_SYNCHRONIZE=false (the production default) and manage the schema via migrations.', ); } // POSTGRES_SCHEMA is optional (defaults to 'public' in configuration.ts). When set, validate it // is a legal, non-reserved Postgres identifier so a typo / injection-ish value fails fast at boot // rather than reaching CREATE TABLE ""."..." (or a search_path SET) at migration time. const pgSchema = str('POSTGRES_SCHEMA'); if (pgSchema !== undefined) { if (!/^[A-Za-z_][A-Za-z0-9_]{0,62}$/.test(pgSchema)) { errors.push( `POSTGRES_SCHEMA must be a valid Postgres identifier (a letter or underscore, then letters/digits/underscores, max 63 chars; got ${JSON.stringify(pgSchema)})`, ); } else if (pgSchema.toLowerCase().startsWith('pg_')) { errors.push(`POSTGRES_SCHEMA must not use the reserved "pg_" prefix (got ${JSON.stringify(pgSchema)})`); } } } else { // SQLite (explicit or default): DATABASE_NAME is a file path for the 'data' connection. It must // not resolve to the 'main' DB file — two TypeORM connections on one SQLite file run separate // migration ledgers + synchronize policies against the same tables, risking schema divergence and // lock contention. (Postgres DATABASE_NAME is a bare db name, so this never applies there.) const dataDbName = str('DATABASE_NAME'); if (dataDbName && resolve(dataDbName) === resolve(MAIN_DB_PATH)) { errors.push(`DATABASE_NAME must not point at the main database file (${MAIN_DB_PATH}); use a separate file`); } // Reject a bare name with no path separator and no .sqlite/.db suffix — the exact signature of a // PostgreSQL DATABASE_NAME (e.g. 'openwa') leaking into a SQLite run (#677). That bare name becomes // the SQLite file PATH, opening a file named 'openwa' under the read-only app rootfs → // SQLITE_CANTOPEN boot-loop. A genuine SQLite path always has a separator or a file suffix. if (dataDbName && !dataDbName.includes('/') && !dataDbName.includes('\\') && !/\.(sqlite|db)$/i.test(dataDbName)) { errors.push( `DATABASE_NAME must be a file path under the data volume for SQLite (e.g. ./data/openwa.sqlite); got ${JSON.stringify( dataDbName, )}. A bare name is the PostgreSQL DB name — leave DATABASE_NAME unset for SQLite to use the default ./data/openwa.sqlite.`, ); } } const checkPort = (key: string): void => { const raw = str(key); if (raw === undefined) return; const n = Number(raw); if (!Number.isInteger(n) || n < 1 || n > 65535) { errors.push(`${key} must be an integer port in [1, 65535] (got "${raw}")`); } }; checkPort('PORT'); checkPort('DATABASE_PORT'); checkPort('REDIS_PORT'); // Other numeric knobs: a non-integer (e.g. `RATE_LIMIT_SHORT_LIMIT=abc`) parses to NaN downstream, // which silently disables the corresponding limit/timeout. Reject at boot instead of coercing. const checkNonNegativeInt = (key: string): void => { const raw = str(key); if (raw === undefined) return; const n = Number(raw); if (!Number.isInteger(n) || n < 0) { errors.push(`${key} must be a non-negative integer (got "${raw}")`); } }; for (const key of [ 'RATE_LIMIT_SHORT_TTL', 'RATE_LIMIT_MEDIUM_TTL', 'RATE_LIMIT_LONG_TTL', 'WEBHOOK_RETRY_DELAY', 'DATABASE_POOL_SIZE', 'DATABASE_STATEMENT_TIMEOUT_MS', 'DATABASE_IDLE_TIMEOUT_MS', 'DATABASE_CONNECTION_TIMEOUT_MS', 'REDIS_CONNECT_TIMEOUT_MS', 'MAX_CONCURRENT_SESSIONS', // 0 = unlimited 'INGRESS_INSTANCE_TTL', 'WEBHOOK_DISPATCH_MAX_QUEUED', ]) { checkNonNegativeInt(key); } // Some knobs are nonsensical at 0 and contradict the "non-negative" intent: a rate-limit LIMIT of 0 // disables that tier's throttling (a self-DoS), and a webhook timeout of 0 aborts every delivery // immediately. Require a positive integer for these. const checkPositiveInt = (key: string): void => { const raw = str(key); if (raw === undefined) return; const n = Number(raw); if (!Number.isInteger(n) || n < 1) { errors.push(`${key} must be a positive integer (got "${raw}")`); } }; for (const key of [ 'RATE_LIMIT_SHORT_LIMIT', 'RATE_LIMIT_MEDIUM_LIMIT', 'RATE_LIMIT_LONG_LIMIT', 'WEBHOOK_TIMEOUT', 'INGRESS_INSTANCE_LIMIT', 'REQUEST_TIMEOUT_MS', 'HEADERS_TIMEOUT_MS', 'KEEPALIVE_TIMEOUT_MS', 'WEBHOOK_DISPATCH_CONCURRENCY', ]) { checkPositiveInt(key); } // Boolean feature flags read at module-eval time (app.module.ts) with a bare `=== 'true'` / // `!== 'false'` comparison: a typo (`True`, `1`, `yes`) or trailing whitespace/CR silently // (dis)ables the feature. Validate the RAW value — NOT a trimmed one — so `'true '` / `'true\r'` // (a Windows-edited env file forwarded verbatim by `docker run --env-file`) is rejected too rather // than passing validation while every read site reads it as false. Blank (a compose `${KEY:-}` // forward) stays legal: it behaves as unset at every read site. const checkBool = (key: string): void => { const raw = config[key]; if (raw === undefined) return; if (typeof raw !== 'string') { errors.push(`${key} must be "true" or "false"`); return; } if (raw.trim() === '') return; if (raw !== 'true' && raw !== 'false') { errors.push(`${key} must be "true" or "false" (got ${JSON.stringify(raw)})`); } }; for (const key of [ 'QUEUE_ENABLED', 'MCP_ENABLED', 'SERVE_DASHBOARD', 'AUTO_START_SESSIONS', 'STORE_EPHEMERAL_MESSAGES', 'RESOLVE_LID_TO_PHONE', 'SIMULATE_TYPING', 'SEARCH_ENABLED', ]) { checkBool(key); } // SEARCH_PROVIDER enum: 'auto' selects the built-in DB full-text provider at runtime, 'builtin-fts' // pins it explicitly, 'none' keeps the module and route mounted but registers no provider, so // /search returns 501; use SEARCH_ENABLED=false to omit the module entirely (route 404). Plugin ids // become selectable once the provider registry lands. Reject a typo at boot rather than silently // falling back to auto. // Raw read (not `str(...)`) so an untrimmed bogus value like `'auto '` is rejected, matching the // raw-value philosophy of `checkBool` above; also lets unit tests drive the check via the `config` // object instead of reaching into `process.env`. const provider = config['SEARCH_PROVIDER'] as string | undefined; if (provider !== undefined && provider !== '' && !['auto', 'builtin-fts', 'none'].includes(provider)) { errors.push(`SEARCH_PROVIDER must be one of: auto, builtin-fts, none (got ${JSON.stringify(provider)})`); } if (errors.length > 0) { throw new Error(`Invalid environment configuration:\n - ${errors.join('\n - ')}`); } return config; }