// MUST be the very first import — see apps/portal-bff/src/observability/tracing.ts // for the reasoning. Anything `import`ed above this line bypasses the // OpenTelemetry auto-instrumentations and is silently un-traced. import './observability/tracing'; import { RequestMethod, ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import cookieParser from 'cookie-parser'; import helmet from 'helmet'; import { Logger } from 'nestjs-pino'; import { AppModule } from './app/app.module'; import { readCorsAllowlist } from './config/check-cors-allowlist'; import { assertDatabaseUrl } from './config/check-database-url'; import { assertEntraConfig } from './config/check-entra-config'; import { assertJwksConfig } from './config/check-jwks-config'; import { assertRedisConfig } from './config/check-redis-config'; import { assertLogUserIdSalt } from './config/check-log-user-id-salt'; import { assertOboCacheEncryptionKey } from './config/check-obo-cache-encryption-key'; import { assertSessionEncryptionKey } from './config/check-session-encryption-key'; import { assertSessionSecret } from './config/check-session-secret'; import { createRateLimitMiddleware, readRateLimitConfig } from './security/rate-limit.middleware'; import { CSRF_MIDDLEWARE } from './security/security.token'; import { StructuredErrorFilter } from './security/structured-error.filter'; import type { NextFunction, Request, Response } from 'express'; import { ADMIN_SESSION_MIDDLEWARE, SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE, SESSION_MIDDLEWARE, type RequestHandler, } from './session/session.token'; // Fail fast on a malformed DATABASE_URL (most often a special char in // the password that needs URL-encoding) rather than letting Prisma // surface a cryptic "invalid connection string" error mid-request. assertDatabaseUrl(); // Same family of pre-flight check for the Entra app-registration env // vars (per ADR-0009). Missing / placeholder values fail here rather // than deep inside the first auth request. assertEntraConfig(); // SESSION_SECRET signs the auth-flow cookies (pre-auth state + // PKCE verifier today, session cookie next). const sessionSecret = assertSessionSecret(); // REDIS_URL is the shared session / cache backend (ADR-0010). Boot- // time guard so a malformed URL fails before `ioredis` enters its // reconnect loop. assertRedisConfig(); // SESSION_ENCRYPTION_KEY is the AES-256-GCM key for session payload // at-rest encryption (ADR-0010). Same fail-fast policy as the other // pre-flight validators — a missing / weak key here would only // surface on the first authenticated request otherwise. assertSessionEncryptionKey(); // LOG_USER_ID_SALT — per-environment SHA-256 salt for the actor-id // hash that joins audit rows (ADR-0013) and Pino log lines // (ADR-0012). Mandatory at boot. assertLogUserIdSalt(); // OBO_CACHE_ENCRYPTION_KEY — dedicated AES-256-GCM key for the OBO // downstream-token cache (ADR-0014 §"Token cache (for OBO)"). MUST // differ from SESSION_ENCRYPTION_KEY — the validator refuses an // identical value as defense in depth against copy-paste accidents. assertOboCacheEncryptionKey(); // BFF_JWKS_PRIVATE_KEY_PATH + BFF_JWKS_KID — signing material for // the ADR-0014 signed-assertion strategy. Reads the PEM file once // here so a missing / unreadable / weak key fails the boot rather // than the first downstream call. The same parsed config is // re-used by `DownstreamModule`'s factory at app construction. assertJwksConfig(); async function bootstrap() { // `bufferLogs: true` holds early-bootstrap log lines until the // Pino-based Logger is wired in below, so we don't lose anything // emitted before `app.useLogger()`. const app = await NestFactory.create(AppModule, { bufferLogs: true }); app.useLogger(app.get(Logger)); // Global exception filter — normalises every 4xx/5xx response to // `{ error: { code, message, traceId } }`. The Nest default // serialises HttpException's getResponse() at the top level, // which leaks the class name on 500s and produces an // inconsistent shape across exception types. Registering early // (before request middleware mounts) ensures even errors thrown // during route setup are caught. app.useGlobalFilters(new StructuredErrorFilter(app.get(Logger))); // Security headers (phase-2). Defaults from `helmet()` are good // for an API server returning JSON: X-Frame-Options=SAMEORIGIN, // X-Content-Type-Options=nosniff, Referrer-Policy=no-referrer, // X-Powered-By removed, etc. CSP defaults apply too but the BFF // doesn't render HTML, so they're inert here. // // Three overrides for our specific shape: // - HSTS only in production (dev runs on plain HTTP). // - crossOriginResourcePolicy: 'cross-origin' so the SPA on its // own origin can read JSON from the BFF without being blocked // by Spectre-class CORP protections. // - contentSecurityPolicy: false in dev — Helmet's default CSP // blocks `connect-src` from anything but 'self', which is // fine for HTML pages but irrelevant for JSON responses and // noisy in browser devtools. app.use( helmet({ hsts: process.env['NODE_ENV'] === 'production', crossOriginResourcePolicy: { policy: 'cross-origin' }, contentSecurityPolicy: process.env['NODE_ENV'] === 'production', }), ); // CORS allowlist — env-driven via `CORS_ALLOWED_ORIGINS`, parsed // and validated at boot. No hardcoded localhost fallback: getting // CORS wrong silently is exactly the kind of "works in dev, breaks // in prod" issue this validator is meant to catch. app.enableCors({ origin: [...readCorsAllowlist()], allowedHeaders: [ 'Content-Type', 'Accept', 'Authorization', 'X-CSRF-Token', 'traceparent', 'tracestate', ], credentials: true, }); app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, }), ); // Cookie parsing for the auth flow (per ADR-0009). `SESSION_SECRET` // signs the pre-auth cookie and the post-login session id cookie; // signed cookies are read from `req.signedCookies`, unsigned from // `req.cookies`. app.use(cookieParser(sessionSecret)); // Session middlewares (per ADR-0010 + ADR-0020 §"Sessions — distinct // from `portal-shell`"). Two parallel express-session instances: // // - `SESSION_MIDDLEWARE` carries `portal_session` / Redis prefix // `session:` and binds to every path EXCEPT `/api/admin/*`. // - `ADMIN_SESSION_MIDDLEWARE` carries `portal_admin_session` / // Redis prefix `session:admin:` and binds to `/api/admin/*` only. // // The dispatch is a tiny wrapper that picks one or the other per // request — running both would have the second overwrite `req.session` // from the first, collapsing the two surfaces. Mounted after // `cookieParser` so the session-id cookie is parsed by the time the // selected middleware reads it. const userSession = app.get(SESSION_MIDDLEWARE); const adminSession = app.get(ADMIN_SESSION_MIDDLEWARE); app.use((req: Request, res: Response, next: NextFunction) => { if (req.path.startsWith('/api/admin')) { return adminSession(req, res, next); } return userSession(req, res, next); }); // Absolute-timeout enforcement (ADR-0010 §"TTL policy"). Runs on // every request that survives `express-session`; if the session // is past its 12 h hard ceiling, destroy it + clear the cookie + // drop the per-user index entry, then let the request continue // anonymously (route-level guards turn it into a 401 where // needed). app.use(app.get(SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE)); // Rate limiting (ADR-0015 §"DoS mitigation" + phase-2 follow-up). // Mounted after the session middleware so the bucket key falls // back to the session id for authenticated requests (preventing // a single attacker from rotating sessions to dodge the limit) // and to the remote IP otherwise. Default 120/min general, 10/min // on `/auth/login` and `/auth/callback` to slow brute-force / // replay attempts. `/api/health` is skipped — orchestrator polls // shouldn't burn the user quota. app.use(createRateLimitMiddleware(readRateLimitConfig())); // Double-submit CSRF (ADR-0009 §"CSRF defense"). Mounted after // the session middleware so `req.session.csrfToken` is available // for comparison with the `X-CSRF-Token` request header. Skips // safe methods (GET / HEAD / OPTIONS), anonymous requests, and // the auth entry routes that *mint* the token (`/auth/login`, // `/auth/callback`). app.use(app.get(CSRF_MIDDLEWARE)); const globalPrefix = 'api'; // `/.well-known/*` is reserved by RFC 8615 for bare-root metadata // endpoints. The BFF's JWKS controller (ADR-0014 signed-assertion // strategy) lives at `/.well-known/jwks.json` so downstream // services pointing at the standard location find it. Excluding // the prefix lets Nest's router resolve the route at the root. app.setGlobalPrefix(globalPrefix, { exclude: [{ path: '.well-known/jwks.json', method: RequestMethod.GET }], }); const port = process.env['PORT'] ?? 3000; await app.listen(port); app .get(Logger) .log(`Application is running on: http://localhost:${port}/${globalPrefix}`, 'Bootstrap'); } bootstrap();