// The env loader MUST be the first import: it populates process.env from .env / data/.env.generated // before any other module is evaluated, so modules that read process.env at import time (e.g. the // webhook Worker's @Processor connection) see the configured values rather than pre-dotenv defaults. import './config/load-env'; import { NestFactory } from '@nestjs/core'; import { ValidationPipe, ShutdownSignal } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { SwaggerModule } from '@nestjs/swagger'; import helmet from 'helmet'; import { AppModule, DASHBOARD_DIST, dashboardServingEnabled, dashboardBuildPresent } from './app.module'; import { ShutdownService } from './common/services/shutdown.service'; import { LoggerService, LogLevel, createLogger } from './common/services/logger.service'; import { createSwaggerConfig, exemptPublicOperations } from './config/swagger.config'; import { registerUncaughtExceptionMonitor } from './config/process-error-monitor'; import { applyHttpTimeouts, HttpTimeoutConfig, HttpTimeoutSink } from './config/http-timeouts'; import { requestContextMiddleware } from './common/middleware/request-context.middleware'; import { resolveCorsPolicy, isSwaggerEnabled, isValidationErrorDetailEnabled, isUpgradeInsecureRequestsEnabled, resolveBodyLimit, assertNoDefaultSecretsInProduction, isApiKeyPepperMissingInProduction, } from './config/bootstrap-security'; import { BullBoardAuthMiddleware } from './common/security/bull-board-auth.middleware'; import { AuthService } from './modules/auth/auth.service'; import { Request, Response, NextFunction, json, urlencoded } from 'express'; async function bootstrap() { // Apply the operator-configured log verbosity (LOG_LEVEL) before anything logs. Unset/invalid → INFO. const requestedLevel = process.env.LOG_LEVEL?.trim().toLowerCase(); if (requestedLevel && (Object.values(LogLevel) as string[]).includes(requestedLevel)) { LoggerService.setLogLevel(requestedLevel as LogLevel); } // Backstop for promise rejections that escaped a local handler (e.g. a fire-and-forget engine-event // dispatch). Node terminates the process on an unhandled rejection by default; for a long-running // self-hosted gateway we'd rather log it and stay up than let one stray rejection kill all sessions. const bootstrapLogger = createLogger('Bootstrap'); process.on('unhandledRejection', (reason: unknown) => { bootstrapLogger.error('Unhandled promise rejection', reason instanceof Error ? reason.stack : String(reason)); }); // A synchronous throw from a non-promise context (e.g. a sync timer callback) is fatal — Node prints a // raw stack to stderr, bypassing the structured log pipeline, and exits(1). Route the stack through the // logger WITHOUT swallowing the exception, so the crash-and-restart posture is unchanged (see the helper). registerUncaughtExceptionMonitor(bootstrapLogger); // Fail fast: never start production with default/placeholder secrets. assertNoDefaultSecretsInProduction({ nodeEnv: process.env.NODE_ENV, databaseType: process.env.DATABASE_TYPE, databasePassword: process.env.DATABASE_PASSWORD, postgresBuiltIn: process.env.POSTGRES_BUILTIN, databaseHost: process.env.DATABASE_HOST, storageType: process.env.STORAGE_TYPE, minioBuiltIn: process.env.MINIO_BUILTIN, s3Endpoint: process.env.S3_ENDPOINT, // Mirror storage.service's canonical-with-legacy fallback so the guard inspects the var the app // actually uses (it reads S3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEY first). s3AccessKey: process.env.S3_ACCESS_KEY_ID || process.env.S3_ACCESS_KEY, s3SecretKey: process.env.S3_SECRET_ACCESS_KEY || process.env.S3_SECRET_KEY, apiMasterKey: process.env.API_MASTER_KEY, allowDevApiKey: process.env.ALLOW_DEV_API_KEY, redisPassword: process.env.REDIS_PASSWORD, }); // Advisory (not enforced): without API_KEY_PEPPER, stored API-key hashes use plain SHA-256. Enabling // a pepper re-hashes keys and invalidates existing ones, so we only nudge the operator (see api-key-hash.ts). if (isApiKeyPepperMissingInProduction(process.env.NODE_ENV, process.env.API_KEY_PEPPER)) { bootstrapLogger.warn( 'API_KEY_PEPPER is not set in production: stored API-key hashes use plain SHA-256. ' + 'Set API_KEY_PEPPER and re-issue keys to enable HMAC hashing.', ); } // Disable Nest's default body parser so we can set an explicit size cap below. const app = await NestFactory.create(AppModule, { bodyParser: false }); // Cap request body size (DoS hardening). Media sends carry base64 in the JSON body, // so the default is generous; tune with BODY_SIZE_LIMIT. const bodyLimit = resolveBodyLimit(process.env.BODY_SIZE_LIMIT); // The `verify` callback stashes the EXACT bytes json() received on req.rawBody, byte-identical to // what a provider signed, so the @Public ingress controller can HMAC-verify over the raw body // (JSON.stringify(req.body) is NOT byte-identical). Cheap for every route; non-ingress routes ignore it. app.use( json({ limit: bodyLimit, verify: (req: Request & { rawBody?: Buffer }, _res, buf) => { req.rawBody = buf; }, }), ); app.use(urlencoded({ extended: true, limit: bodyLimit })); // Assign a request id to every inbound request (X-Request-ID), echo it on the response, and run // the whole downstream chain inside its scope so every log line + audit row carries it. app.use(requestContextMiddleware); // Let Nest own every shutdown signal EXCEPT SIGTERM/SIGINT — those we route through the bounded // drain below, so a load balancer / orchestrator observes readiness=503 and stops routing BEFORE // teardown begins. (enableShutdownHooks with an EMPTY array registers ALL signals; this filtered // list is non-empty, so the exclusion is honoured.) app.enableShutdownHooks( Object.values(ShutdownSignal).filter(s => s !== ShutdownSignal.SIGTERM && s !== ShutdownSignal.SIGINT), ); // Wire up graceful shutdown service const shutdownService = app.get(ShutdownService); shutdownService.setShutdownCallback(async () => { await app.close(); }); // On SIGTERM/SIGINT: drain gracefully. shutdown() flips readiness to 503 immediately (the LB stops // routing), keeps serving in-flight requests for a bounded grace, then runs app.close() (the SAME // Nest lifecycle hooks Nest's own handler would run) and exits deterministically. A SECOND signal // forces an immediate exit — a dev double-Ctrl+C, or an operator not willing to wait out a wedged // teardown. The gate is a dedicated "a signal already arrived" flag, NOT isShuttingDown() (which an // admin restart also sets) — so a first real signal during an admin-restart grace still drains // gracefully instead of hard-exiting. let signalReceived = false; for (const signal of ['SIGTERM', 'SIGINT'] as const) { process.on(signal, () => { if (signalReceived) { process.exit(130); } signalReceived = true; shutdownService.shutdown(); }); } // Enhanced Security Headers app.use( helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], // The bundled dashboard pulls webfonts from Google Fonts (CSS from fonts.googleapis.com, // font files from fonts.gstatic.com). Now that NestJS serves the dashboard under this CSP, // allow those origins or the @import'd fonts are blocked and the UI falls back to system fonts. styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], scriptSrc: ["'self'"], // `blob:` is needed for the outgoing image-attachment preview, which the dashboard renders // from a URL.createObjectURL(file) blob before the message is sent (Chats.tsx). imgSrc: ["'self'", 'data:', 'blob:', 'https:'], // Chat media (voice notes, video) is served to the dashboard as data: URIs. Without an // explicit media-src,