Spaces:
Runtime error
Runtime error
| // The env loader MUST be the first import: it populates process.env from .env / the configured data mount | |
| // 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 { 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 { applyGlobalValidation } from './config/app-validation'; | |
| import { requestContextMiddleware } from './common/middleware/request-context.middleware'; | |
| import { injectDashboardCspNonce } from './config/dashboard-csp'; | |
| import { | |
| resolveCorsPolicy, | |
| isSwaggerEnabled, | |
| isUpgradeInsecureRequestsEnabled, | |
| isDashboardCspUpgradeTrapLikely, | |
| 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'; | |
| import { randomBytes } from 'crypto'; | |
| import { readFileSync } from 'fs'; | |
| import { extname, join } from 'path'; | |
| 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, | |
| // Form-encoded webhook providers also sign the exact wire bytes. Use the same capture contract | |
| // as json(); other content types remain unsupported rather than installing a global catch-all. | |
| verify: (req: Request & { rawBody?: Buffer }, _res, buf) => { | |
| req.rawBody = buf; | |
| }, | |
| }), | |
| ); | |
| // 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(); | |
| }); | |
| } | |
| // Give every response a CSP nonce. A bundled dashboard document receives its own value in a meta | |
| // element below; plugin config UIs copy it only onto inline scripts in their opaque sandboxed iframe. | |
| app.use((req: Request, res: Response, next: NextFunction) => { | |
| res.locals.cspNonce = randomBytes(18).toString('base64url'); | |
| next(); | |
| }); | |
| // 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'", (_req, res) => `'nonce-${(res as Response).locals.cspNonce as string}'`], | |
| // `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, <audio>/<video> fall back to default-src 'self' and are blocked. | |
| // Mirror imgSrc so audio/video render the same way images already do. | |
| mediaSrc: ["'self'", 'data:', 'blob:', 'https:'], | |
| connectSrc: ["'self'"], | |
| fontSrc: ["'self'", 'https://fonts.gstatic.com'], | |
| objectSrc: ["'none'"], | |
| // Auto-upgrade HTTP→HTTPS in production, unless CSP_UPGRADE_INSECURE_REQUESTS opts out for an | |
| // HTTP-only private-network deployment (otherwise the browser forces the dashboard to https). (#611) | |
| upgradeInsecureRequests: isUpgradeInsecureRequestsEnabled( | |
| process.env.CSP_UPGRADE_INSECURE_REQUESTS, | |
| process.env.NODE_ENV, | |
| ) | |
| ? [] | |
| : null, | |
| }, | |
| }, | |
| hsts: { | |
| maxAge: 31536000, | |
| includeSubDomains: true, | |
| preload: true, | |
| }, | |
| noSniff: true, | |
| referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, | |
| // Disable for API usage | |
| crossOriginResourcePolicy: { policy: 'cross-origin' }, | |
| }), | |
| ); | |
| // Serve SPA documents dynamically so the nonce embedded in this exact document matches its CSP | |
| // response header. A shared cookie is deliberately avoided: a second dashboard tab could overwrite | |
| // it and make the first tab's srcdoc scripts fail CSP. Assets and Nest-owned routes fall through. | |
| if (dashboardServingEnabled && dashboardBuildPresent) { | |
| const dashboardIndex = readFileSync(join(DASHBOARD_DIST, 'index.html'), 'utf8'); | |
| app.use((req: Request, res: Response, next: NextFunction) => { | |
| const excluded = | |
| req.path.startsWith('/api/') || | |
| req.path === '/api' || | |
| req.path.startsWith('/socket.io/') || | |
| req.path === '/socket.io' || | |
| req.path.startsWith('/mcp/') || | |
| req.path === '/mcp' || | |
| req.path.startsWith('/assets/'); | |
| const documentRequest = | |
| req.method === 'GET' && | |
| !excluded && | |
| ((req.headers.accept ?? '').includes('text/html') || extname(req.path) === ''); | |
| if (!documentRequest) return next(); | |
| res.setHeader('Cache-Control', 'no-store'); | |
| res.type('html').send(injectDashboardCspNonce(dashboardIndex, res.locals.cspNonce as string)); | |
| }); | |
| } | |
| // CORS Configuration (#221 hardening) | |
| const corsPolicy = resolveCorsPolicy(process.env.CORS_ORIGINS, process.env.NODE_ENV); | |
| if (process.env.NODE_ENV === 'production' && corsPolicy.origins.length === 0 && !corsPolicy.allowAnyOrigin) { | |
| console.warn( | |
| '[Bootstrap] No explicit CORS_ORIGINS in production (wildcard "*" is refused): cross-origin browser ' + | |
| 'requests will be blocked. Set CORS_ORIGINS to your dashboard origin(s).', | |
| ); | |
| } | |
| app.enableCors({ | |
| origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => { | |
| // Allow requests with no origin (mobile apps, Postman, server-to-server) | |
| if (!origin) return callback(null, true); | |
| if (corsPolicy.allowAnyOrigin || corsPolicy.origins.includes(origin)) { | |
| callback(null, true); | |
| } else { | |
| // Deny WITHOUT throwing. Throwing here surfaced as a 500 Internal Server Error (#250). | |
| // Returning false simply omits the CORS headers: the browser blocks a true cross-origin | |
| // request itself (correct), while same-origin requests — e.g. the bundled dashboard served | |
| // through the proxy, which the browser never subjects to CORS — keep working. A genuine | |
| // cross-origin dashboard still needs its origin in CORS_ORIGINS. | |
| callback(null, false); | |
| } | |
| }, | |
| credentials: corsPolicy.credentials, | |
| methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], | |
| allowedHeaders: ['Content-Type', 'X-API-Key', 'Authorization', 'X-Request-ID'], | |
| exposedHeaders: ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset'], | |
| maxAge: 86400, // 24 hours | |
| }); | |
| // Shared production/e2e prefix and DTO validation contract. | |
| applyGlobalValidation(app); | |
| // Swagger documentation. ENABLE_SWAGGER wins; otherwise default on outside production, off in | |
| // production (the API schema is reconnaissance surface — production opts in with ENABLE_SWAGGER=true). | |
| const swaggerEnabled = isSwaggerEnabled(process.env.ENABLE_SWAGGER, process.env.NODE_ENV); | |
| if (swaggerEnabled) { | |
| const config = createSwaggerConfig(); | |
| const document = SwaggerModule.createDocument(app, config); | |
| exemptPublicOperations(document); | |
| SwaggerModule.setup('api/docs', app, document); | |
| } | |
| // Protect the Bull Board queue UI (/api/admin/queues). It is mounted by | |
| // @bull-board/nestjs as raw Express middleware that the global ApiKeyGuard | |
| // does not cover; registering this before app.listen() ensures it runs ahead | |
| // of the Bull Board router. Requires a valid ADMIN API key. | |
| const bullBoardAuth = new BullBoardAuthMiddleware(app.get(AuthService), app.get(ConfigService)); | |
| app.use('/api/admin/queues', (req: Request, res: Response, next: NextFunction) => { | |
| void bullBoardAuth.use(req, res, next); | |
| }); | |
| // Apply explicit HTTP server timeouts so they are operator-tunable (REQUEST_TIMEOUT_MS / | |
| // HEADERS_TIMEOUT_MS / KEEPALIVE_TIMEOUT_MS) and observable at boot, instead of Node's implicit | |
| // defaults. Done after the adapter exists and before listen(). Target MUST be the http.Server | |
| // (app.getHttpServer()) — NOT getHttpAdapter().getInstance(), which is the Express APPLICATION | |
| // (a function with no requestTimeout/headersTimeout/keepAliveTimeout props); writing onto it is | |
| // inert. The timeouts only take effect on the real server. | |
| const appliedHttpTimeouts = applyHttpTimeouts( | |
| app.getHttpServer() as HttpTimeoutSink, | |
| app.get(ConfigService).get<HttpTimeoutConfig>('http')!, | |
| ); | |
| bootstrapLogger.log( | |
| `HTTP server timeouts applied: requestTimeout=${appliedHttpTimeouts.requestTimeoutMs}ms ` + | |
| `headersTimeout=${appliedHttpTimeouts.headersTimeoutMs}ms keepAliveTimeout=${appliedHttpTimeouts.keepAliveTimeoutMs}ms`, | |
| ); | |
| const port = process.env.PORT || 2785; | |
| await app.listen(port, '0.0.0.0'); | |
| // Advertise the configured public URL, matching the AuthService banner (auth.service.ts). A bare | |
| // `localhost` literal here contradicted that banner and read as "the UI is pinned to localhost", | |
| // sending #731 chasing BASE_URL/BIND_HOST/API_PORT instead of the real cause. | |
| const publicUrl = process.env.BASE_URL || `http://localhost:${port}`; | |
| console.log(`🚀 OpenWA is running on: ${publicUrl}`); | |
| if (swaggerEnabled) { | |
| console.log(`📚 Swagger docs: ${publicUrl}/api/docs`); | |
| } | |
| // Make the dashboard-serving outcome explicit so a missing build (no UI on `/`) | |
| // is obvious instead of a silent 404. | |
| if (!dashboardServingEnabled) { | |
| console.log('🖥️ Dashboard: serving disabled (SERVE_DASHBOARD=false); API only'); | |
| } else if (dashboardBuildPresent) { | |
| console.log(`🖥️ Dashboard: serving bundled UI at ${publicUrl}`); | |
| } else { | |
| console.warn( | |
| `⚠️ Dashboard: no build at ${DASHBOARD_DIST} - UI disabled (API still serves /api). ` + | |
| 'Run `npm run build:all` to bundle it, or use the Vite dev server (`npm run dev`).', | |
| ); | |
| } | |
| // The upgrade-insecure-requests trap (#731): the browser upgrades the UI's own script fetches to | |
| // https and a non-TLS server can't answer them, so the dashboard renders blank with nothing in the | |
| // server log. We can't tell a TLS proxy from direct HTTP at boot (`trust proxy` is off), so this | |
| // fires for both and the text says who should ignore it. | |
| if ( | |
| isDashboardCspUpgradeTrapLikely({ | |
| nodeEnv: process.env.NODE_ENV, | |
| cspEnv: process.env.CSP_UPGRADE_INSECURE_REQUESTS, | |
| dashboardServed: dashboardServingEnabled && dashboardBuildPresent, | |
| }) | |
| ) { | |
| console.warn( | |
| '⚠️ Dashboard: CSP upgrade-insecure-requests is ON (production default). If this instance is ' + | |
| "reached over plain HTTP, the browser will upgrade the UI's scripts to https:// and the " + | |
| 'dashboard will render blank. Behind a TLS proxy? Ignore this. Serving direct HTTP? Set ' + | |
| 'CSP_UPGRADE_INSECURE_REQUESTS=false.', | |
| ); | |
| } | |
| } | |
| bootstrap().catch((err: unknown) => { | |
| createLogger('Bootstrap').error('Fatal error during bootstrap', err instanceof Error ? err.stack : String(err)); | |
| process.exitCode = 1; | |
| }); | |