qwen_2.5_model / src /main.ts
Muhammad Noman
Deploy OpenWA to Hugging Face Spaces
46252cd
Raw
History Blame Contribute Delete
17.4 kB
// 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;
});