From d1395940819e571b113acf716cde8faa3877e20e Mon Sep 17 00:00:00 2001 From: Halima <228595672+limxiy@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:35:42 +0100 Subject: [PATCH] feat(backend): add orchestrator health probes, CORS allowlist, and graceful shutdown Adds a Terminus-based HealthModule: GET /health is a dependency-free liveness probe and GET /health/ready is a readiness probe that pings TypeORM with a bounded timeout and reports 503 when the database is unreachable. The pre-existing combined dependency view moves to GET /health/dependencies so it no longer shadows the liveness route. Locks CORS down to an explicit per-environment allowlist driven by CORS_ORIGINS. Development and test fall back to the two local frontend origins; production falls closed to an empty allowlist and logs a warning, so a misconfigured deploy denies cross-origin traffic instead of allowing it. Credentialed requests are still supported. Adds bounded, idempotent SIGTERM/SIGINT handling that closes the HTTP server, drains idle connections, runs Nest lifecycle hooks, and force-exits only after a configurable watchdog expires. Audits backend/.env.example against every variable the source actually reads, documents where each is read, and marks the reserved/unused entries explicitly. Closes #1793 Closes #1792 Closes #1791 Closes #1790 --- backend/.env.example | 56 +++++- backend/package.json | 1 + backend/src/app.controller.ts | 7 +- backend/src/app.module.ts | 2 + backend/src/common/cors.spec.ts | 58 +++++++ backend/src/common/cors.ts | 60 +++++++ backend/src/common/graceful-shutdown.spec.ts | 149 ++++++++++++++++ backend/src/common/graceful-shutdown.ts | 173 +++++++++++++++++++ backend/src/health/health.controller.spec.ts | 84 +++++++++ backend/src/health/health.controller.ts | 106 ++++++++++++ backend/src/health/health.module.ts | 14 ++ backend/src/main.ts | 49 +++++- 12 files changed, 750 insertions(+), 9 deletions(-) create mode 100644 backend/src/common/cors.spec.ts create mode 100644 backend/src/common/cors.ts create mode 100644 backend/src/common/graceful-shutdown.spec.ts create mode 100644 backend/src/common/graceful-shutdown.ts create mode 100644 backend/src/health/health.controller.spec.ts create mode 100644 backend/src/health/health.controller.ts create mode 100644 backend/src/health/health.module.ts diff --git a/backend/.env.example b/backend/.env.example index f655b283..a5f0ef6a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,11 +1,44 @@ +# Audited environment source of truth +# Audited against the backend source (including auth, database, payments and +# payment adapters, Soroban/Stellar, wallets, credits, retention, common +# infrastructure, and backend/scripts). Sections group variables by the +# subsystem that reads them. +# Values marked [RESERVED/UNUSED] are retained for compatibility but are not +# read by the current backend source. + +# Runtime, HTTP security, and orchestrator probes +# PORT and NODE_ENV are read by src/main.ts and the auth cookie settings. PORT=6000 NODE_ENV=development +# Exact, comma-separated browser origins. Whitespace is ignored. In development +# and test, an empty value falls back to localhost:3000 and localhost:3001; in +# production an unset or empty value is an empty allowlist and denies all +# cross-origin requests. Read by src/main.ts. +CORS_ORIGINS=http://localhost:3000,http://localhost:3001 +# Bounded TypeORM readiness ping timeout in milliseconds. Read by +# src/health/health.controller.ts; invalid values use 3000 and values are capped +# at 30000. +HEALTH_CHECK_TIMEOUT_MS=3000 +# Maximum time to drain HTTP requests and Nest lifecycle hooks before forcing +# process exit. Read by src/main.ts via common/graceful-shutdown.ts; invalid or +# non-positive values use the 30000ms default. +GRACEFUL_SHUTDOWN_TIMEOUT_MS=30000 + +# Database +# Read by the TypeORM application configuration in src/app.module.ts and by +# src/database/data-source.ts for migrations and audit tooling. DATABASE_NAME=your_database_name DATABASE_PASSWORD=your_database_password DATABASE_USERNAME=your_database_username DATABASE_PORT=5432 DATABASE_HOST=your_database_host + +# Auth / JWT +# JWT_SECRET is read by src/auth/auth.module.ts. JWT_SECRET=your_jwt_secret_here +# [RESERVED/UNUSED] The current backend issues and verifies one JWT and does +# not read these legacy expiration names; retained so older setup files remain +# recognizable. JWT_ACCESS_EXPIRATION=86400000 JWT_REFRESH_EXPIRATION=604800000 @@ -14,7 +47,8 @@ JWT_REFRESH_EXPIRATION=604800000 # The guard + socket gateway verify these tokens with JWT_SECRET. # Email Configuration -# Production SMTP Settings (Gmail, SendGrid, AWS SES, etc.) +# Production SMTP settings read by ReconciliationService when it sends a +# manual-review queue alert (Gmail, SendGrid, AWS SES, etc.). SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_SECURE=false @@ -22,24 +56,29 @@ SMTP_USER=your_email@gmail.com SMTP_PASS=your_app_password SMTP_FROM_EMAIL=noreply@yourdomain.com -# Mailtrap (Development/Testing Only) +# [RESERVED/UNUSED] Mailtrap (Development/Testing Only). No backend source +# currently reads these names; outbound mail uses the SMTP_* variables above. MAILTRAP_HOST=smtp.mailtrap.io MAILTRAP_PORT=2525 MAILTRAP_USER=your_mailtrap_user MAILTRAP_PASS=your_mailtrap_pass -# Company Information +# Company and frontend information +# COMPANY_NAME and SUPPORT_EMAIL are read by the reconciliation alert email; +# FRONTEND_URL is the separate Socket.IO gateway origin in payments.gateway.ts. COMPANY_NAME=ManageHub SUPPORT_EMAIL=support@yourdomain.com FRONTEND_URL=http://localhost:3000 # Redis Configuration (for Bull Queue) +# Read by the Bull root configuration and AppService's dependency check. REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= REDIS_DB=0 -# Email Rate Limiting +# [RESERVED/UNUSED] No current backend source reads this legacy rate-limit +# setting; retained as a placeholder for a future mail policy. EMAIL_RATE_LIMIT_PER_HOUR=100 # Email is sent over plain SMTP (nodemailer) via the vars above; no single @@ -50,8 +89,10 @@ EMAIL_RATE_LIMIT_PER_HOUR=100 # On-chain escrow for long-term (MONTHLY/QUARTERLY/YEARLY) bookings. # Leave SOROBAN_ENABLED=false (or unset) to skip escrow cleanly — no Stellar # config is required and long-term bookings confirm normally. -# When SOROBAN_ENABLED=true, ALL of the variables below are required or the -# app will refuse to start with an error naming the missing variable(s). +# When SOROBAN_ENABLED=true, the required STELLAR_* identity keys are checked +# by payments/soroban/soroban-config.ts; the app refuses to start with an error +# naming any missing required key. Poll and RPC settings are read by the +# Soroban rail and submission processor. SOROBAN_ENABLED=false STELLAR_ESCROW_CONTRACT_ID=your_stellar_escrow_contract_id STELLAR_SECRET_KEY=your_stellar_secret_key @@ -69,7 +110,8 @@ STELLAR_RPC_URLS=https://soroban-testnet.stellar.org,https://rpc-futurenet.stell SOROBAN_POLL_TIMEOUT_MS=15000 SOROBAN_POLL_INTERVAL_MS=2000 # Scheduled Jobs -# Minutes a PENDING booking may wait for payment before it is auto-cancelled +# [RESERVED/UNUSED] No current backend source reads this legacy booking TTL; +# payment expiry is configured by PAYMENT_INITIATED_TTL_MINUTES below. BOOKING_PAYMENT_TTL_MINUTES=120 # Payments module (issue #1570) diff --git a/backend/package.json b/backend/package.json index cd1b50cf..995e01bd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -42,6 +42,7 @@ "@nestjs/platform-socket.io": "^10.4.22", "@nestjs/schedule": "^6.0.1", "@nestjs/swagger": "^7.4.2", + "@nestjs/terminus": "^10.2.3", "@nestjs/throttler": "^6.4.0", "@nestjs/typeorm": "^11.0.0", "@nestjs/websockets": "^10.4.22", diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts index ae7c0d5d..fd135a56 100644 --- a/backend/src/app.controller.ts +++ b/backend/src/app.controller.ts @@ -10,7 +10,12 @@ export class AppController { private readonly metrics: MetricsService, ) {} - @Get('health') + /** + * Legacy combined dependency view retained for operators that used the old + * health route. Orchestrator probes use the dedicated liveness and readiness + * routes in HealthController, so this route must not shadow `/health`. + */ + @Get('health/dependencies') async getHealth(@Res({ passthrough: true }) res: Response) { const health = await this.appService.getHealth(); if (health.status !== 'ok') { diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 7bc577db..d527f233 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -14,6 +14,7 @@ import { WalletsModule } from './wallets/wallets.module'; import { CreditsModule } from './credits/credits.module'; import { RetentionModule } from './retention/retention.module'; import { AdminAuditModule } from './admin-audit/admin-audit.module'; +import { HealthModule } from './health/health.module'; import { MetricsService } from './common/metrics.service'; import { MetricsController } from './common/metrics.controller'; import { RequestContextMiddleware } from './common/request-context.middleware'; @@ -76,6 +77,7 @@ import { RequestContextMiddleware } from './common/request-context.middleware'; // Structured audit trail for admin actions (issue #1612). Consumed by // the payments and credits admin controllers; read via /admin/audit. AdminAuditModule, + HealthModule, ], controllers: [AppController, MetricsController], providers: [ diff --git a/backend/src/common/cors.spec.ts b/backend/src/common/cors.spec.ts new file mode 100644 index 00000000..cf1583b3 --- /dev/null +++ b/backend/src/common/cors.spec.ts @@ -0,0 +1,58 @@ +import { + DEVELOPMENT_AND_TEST_CORS_ORIGINS, + isOriginAllowed, + parseAllowedOrigins, + resolveAllowedOrigins, +} from './cors'; + +describe('CORS origin helpers', () => { + it('parses comma-separated origins while trimming and dropping empty entries', () => { + expect( + parseAllowedOrigins( + ' https://app.example.com, ,https://admin.example.com, ', + ), + ).toEqual([ + 'https://app.example.com', + 'https://admin.example.com', + ]); + }); + + it('uses the documented local origins for development and test defaults', () => { + expect(resolveAllowedOrigins({ NODE_ENV: 'development' })).toEqual([ + ...DEVELOPMENT_AND_TEST_CORS_ORIGINS, + ]); + expect(resolveAllowedOrigins({ NODE_ENV: 'test' })).toEqual([ + ...DEVELOPMENT_AND_TEST_CORS_ORIGINS, + ]); + }); + + it('resolves to an empty allowlist in production when configuration is empty', () => { + expect(resolveAllowedOrigins({ NODE_ENV: 'production' })).toEqual([]); + expect( + resolveAllowedOrigins({ + NODE_ENV: 'production', + CORS_ORIGINS: ' , ', + }), + ).toEqual([]); + }); + + it('prefers configured origins over environment defaults', () => { + expect( + resolveAllowedOrigins({ + NODE_ENV: 'development', + CORS_ORIGINS: 'https://app.example.com', + }), + ).toEqual(['https://app.example.com']); + }); + + it('allows originless requests but only exact allowlisted origins', () => { + const allowed = ['https://app.example.com']; + + expect(isOriginAllowed(undefined, allowed)).toBe(true); + expect(isOriginAllowed('https://app.example.com', allowed)).toBe(true); + expect(isOriginAllowed('https://app.example.com.evil.test', allowed)).toBe( + false, + ); + expect(isOriginAllowed('https://other.example.com', allowed)).toBe(false); + }); +}); diff --git a/backend/src/common/cors.ts b/backend/src/common/cors.ts new file mode 100644 index 00000000..23093f88 --- /dev/null +++ b/backend/src/common/cors.ts @@ -0,0 +1,60 @@ +/** + * Origins used by the local frontend during development and automated tests. + * + * Keep the development defaults together so a change to the documented local + * frontend ports cannot accidentally make one environment more permissive + * than the other. + */ +export const DEVELOPMENT_AND_TEST_CORS_ORIGINS = [ + 'http://localhost:3000', + 'http://localhost:3001', +] as const; + +/** + * Parse a comma-separated CORS_ORIGINS value into exact origins. + * + * Whitespace around entries is insignificant, and empty entries are ignored + * so a trailing comma cannot accidentally broaden or confuse the allowlist. + */ +export function parseAllowedOrigins(value: string | undefined): string[] { + return (value ?? '') + .split(',') + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0); +} + +/** + * Resolve the CORS allowlist using the explicit configuration first and the + * environment-specific safe default second. + * + * Development and test may use the two local frontend origins when no value + * is configured. Every other environment, including production, resolves to + * an empty allowlist when CORS_ORIGINS is missing or empty so a deployment + * fails closed rather than inheriting a wildcard policy. + */ +export function resolveAllowedOrigins(env: NodeJS.ProcessEnv): string[] { + const configuredOrigins = parseAllowedOrigins(env.CORS_ORIGINS); + if (configuredOrigins.length > 0) { + return configuredOrigins; + } + + if (env.NODE_ENV === 'development' || env.NODE_ENV === 'test') { + return [...DEVELOPMENT_AND_TEST_CORS_ORIGINS]; + } + + return []; +} + +/** + * Decide whether a request origin may receive credentialed CORS headers. + * + * Requests without an Origin header are deliberately allowed: they are + * same-origin/non-browser clients, curl, or health checkers rather than a + * browser cross-origin request. Present origins must match one exact entry. + */ +export function isOriginAllowed( + origin: string | undefined, + allowedOrigins: readonly string[], +): boolean { + return !origin || allowedOrigins.includes(origin); +} diff --git a/backend/src/common/graceful-shutdown.spec.ts b/backend/src/common/graceful-shutdown.spec.ts new file mode 100644 index 00000000..bf5210f7 --- /dev/null +++ b/backend/src/common/graceful-shutdown.spec.ts @@ -0,0 +1,149 @@ +import { + DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS, + installGracefulShutdown, + resolveGracefulShutdownTimeout, +} from './graceful-shutdown'; + +type SignalHandler = (signal: NodeJS.Signals) => Promise; + +describe('graceful shutdown', () => { + let handlers: Partial>; + + beforeEach(() => { + jest.useFakeTimers(); + handlers = {}; + jest + .spyOn(process, 'once') + .mockImplementation( + ((signal: NodeJS.Signals, handler: SignalHandler) => { + handlers[signal] = handler; + return process; + }) as any, + ); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('closes the server, waits for app.close, and exits successfully', async () => { + let resolveClose!: () => void; + const closePromise = new Promise((resolve) => { + resolveClose = resolve; + }); + const app = { close: jest.fn(() => closePromise) }; + const server = { + close: jest.fn(), + closeIdleConnections: jest.fn(), + }; + const logger = { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + const exit = jest.fn(); + const dispose = installGracefulShutdown({ + app, + server, + logger, + exit, + timeoutMs: 1_000, + }); + + const shutdown = handlers.SIGTERM!('SIGTERM'); + await Promise.resolve(); + + expect(server.close).toHaveBeenCalledTimes(1); + expect(server.closeIdleConnections).toHaveBeenCalledTimes(1); + expect(app.close).toHaveBeenCalledTimes(1); + expect(exit).not.toHaveBeenCalled(); + + resolveClose(); + await shutdown; + + expect(exit).toHaveBeenCalledWith(0); + expect(logger.log).toHaveBeenCalledWith( + 'Graceful shutdown completed.', + ); + dispose(); + }); + + it('forces exit when app.close does not finish before the watchdog', async () => { + const app = { + close: jest.fn(() => new Promise(() => undefined)), + }; + const server = { + close: jest.fn(), + closeIdleConnections: jest.fn(), + }; + const logger = { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + const exit = jest.fn(); + const dispose = installGracefulShutdown({ + app, + server, + logger, + exit, + timeoutMs: 1_000, + }); + + void handlers.SIGINT!('SIGINT'); + await Promise.resolve(); + jest.advanceTimersByTime(1_000); + + expect(server.close).toHaveBeenCalledTimes(1); + expect(server.closeIdleConnections).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledWith(1); + expect(logger.error).toHaveBeenCalledWith( + 'Graceful shutdown exceeded 1000ms; forcing process exit.', + ); + dispose(); + }); + + it('ignores a duplicate signal while already draining', async () => { + let resolveClose!: () => void; + const closePromise = new Promise((resolve) => { + resolveClose = resolve; + }); + const app = { close: jest.fn(() => closePromise) }; + const server = { close: jest.fn() }; + const logger = { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + const exit = jest.fn(); + const dispose = installGracefulShutdown({ + app, + server, + logger, + exit, + }); + + const firstShutdown = handlers.SIGTERM!('SIGTERM'); + await Promise.resolve(); + await handlers.SIGTERM!('SIGTERM'); + + expect(app.close).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + 'Received SIGTERM while graceful shutdown is already draining; ignoring it.', + ); + + resolveClose(); + await firstShutdown; + dispose(); + }); + + it('uses the safe default for malformed timeout configuration', () => { + expect(resolveGracefulShutdownTimeout(undefined)).toBe( + DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS, + ); + expect(resolveGracefulShutdownTimeout('not-a-number')).toBe( + DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS, + ); + }); +}); diff --git a/backend/src/common/graceful-shutdown.ts b/backend/src/common/graceful-shutdown.ts new file mode 100644 index 00000000..8305519f --- /dev/null +++ b/backend/src/common/graceful-shutdown.ts @@ -0,0 +1,173 @@ +export const DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 30_000; + +export interface GracefulShutdownApp { + close(): Promise; +} + +export interface GracefulShutdownServer { + close(callback?: (error?: Error) => void): unknown; + closeIdleConnections?: () => void; +} + +export interface GracefulShutdownLogger { + log(message: string): void; + warn(message: string): void; + error(message: string): void; +} + +type TimerHandle = ReturnType; + +export interface GracefulShutdownOptions { + app: GracefulShutdownApp; + server: GracefulShutdownServer; + logger: GracefulShutdownLogger; + timeoutMs?: number; + exit?: (code: number) => void; + scheduleTimeout?: (callback: () => void, delayMs: number) => TimerHandle; + cancelTimeout?: (timer: TimerHandle) => void; +} + +/** + * Resolve the watchdog duration from configuration while keeping a safe + * default for missing, malformed, zero, or negative values. + */ +export function resolveGracefulShutdownTimeout( + value: string | undefined, +): number { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS; + } + return Math.max(1, Math.floor(parsed)); +} + +function closeHttpServer(server: GracefulShutdownServer): Promise { + return new Promise((resolve, reject) => { + try { + const result = server.close(() => resolve()); + if ( + result && + typeof (result as { then?: unknown }).then === 'function' + ) { + void (result as PromiseLike).then( + () => resolve(), + (error) => reject(error), + ); + return; + } + if ( + !result || + typeof (result as { once?: unknown }).once !== 'function' + ) { + resolve(); + } + } catch (error) { + reject(error); + } + }); +} + +/** + * Install bounded, idempotent process-signal handling for a Nest HTTP server. + * + * A second signal is deliberately ignored after the first one starts the + * drain: the watchdog remains the single escalation path, so a duplicate + * orchestrator signal cannot run Nest lifecycle hooks twice. The disposer is + * primarily useful for tests and for an embedding application that owns the + * process lifecycle itself. + */ +export function installGracefulShutdown( + options: GracefulShutdownOptions, +): () => void { + const timeoutMs = + options.timeoutMs && + Number.isFinite(options.timeoutMs) && + options.timeoutMs > 0 + ? options.timeoutMs + : DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS; + const scheduleTimeout = + options.scheduleTimeout ?? + ((callback: () => void, delayMs: number) => setTimeout(callback, delayMs)); + const cancelTimeout = + options.cancelTimeout ?? + ((timer: TimerHandle) => clearTimeout(timer)); + const exit = options.exit ?? ((code: number) => process.exit(code)); + + let draining = false; + let watchdog: TimerHandle | undefined; + let forceExitRequested = false; + + const handleSignal = async (signal: NodeJS.Signals): Promise => { + if (draining) { + options.logger.warn( + `Received ${signal} while graceful shutdown is already draining; ignoring it.`, + ); + return; + } + + draining = true; + // Re-arm this one signal so a duplicate delivery reaches the guard above + // instead of falling through to Node's default immediate termination. + process.once(signal, handleSignal); + options.logger.log( + `Received ${signal}; graceful shutdown started — draining in-flight requests.`, + ); + + watchdog = scheduleTimeout(() => { + forceExitRequested = true; + options.logger.error( + `Graceful shutdown exceeded ${timeoutMs}ms; forcing process exit.`, + ); + exit(1); + }, timeoutMs); + // Node 18+ exposes unref; the optional call also keeps injected test + // timers portable while ensuring the watchdog never keeps a healthy drain + // open by itself. + const unrefableWatchdog = watchdog as unknown as { unref?: () => void }; + unrefableWatchdog.unref?.(); + + try { + const serverClosed = closeHttpServer(options.server); + options.server.closeIdleConnections?.(); + await serverClosed; + await options.app.close(); + } catch (error) { + if (watchdog) { + cancelTimeout(watchdog); + watchdog = undefined; + } + options.logger.error( + `Graceful shutdown failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + if (!forceExitRequested) { + exit(1); + } + return; + } + + if (forceExitRequested) { + return; + } + + if (watchdog) { + cancelTimeout(watchdog); + watchdog = undefined; + } + options.logger.log('Graceful shutdown completed.'); + exit(0); + }; + + process.once('SIGTERM', handleSignal); + process.once('SIGINT', handleSignal); + + return () => { + process.removeListener('SIGTERM', handleSignal); + process.removeListener('SIGINT', handleSignal); + if (!draining && watchdog) { + cancelTimeout(watchdog); + watchdog = undefined; + } + }; +} diff --git a/backend/src/health/health.controller.spec.ts b/backend/src/health/health.controller.spec.ts new file mode 100644 index 00000000..0b3fd105 --- /dev/null +++ b/backend/src/health/health.controller.spec.ts @@ -0,0 +1,84 @@ +import { Test } from '@nestjs/testing'; +import { HealthCheckService, TypeOrmHealthIndicator } from '@nestjs/terminus'; +import { HealthController } from './health.controller'; + +describe('HealthController', () => { + let controller: HealthController; + let healthCheckService: { check: jest.Mock }; + let typeOrmHealthIndicator: { pingCheck: jest.Mock }; + const originalHealthCheckTimeout = process.env.HEALTH_CHECK_TIMEOUT_MS; + + beforeEach(async () => { + const check = jest.fn(async (indicators: Array<() => any>) => { + const results = await Promise.all( + indicators.map((indicator) => indicator()), + ); + const details = Object.assign({}, ...results); + return { status: 'ok', info: details, error: {}, details }; + }); + const pingCheck = jest + .fn() + .mockResolvedValue({ database: { status: 'up' } }); + + const moduleRef = await Test.createTestingModule({ + controllers: [HealthController], + providers: [ + { + provide: HealthCheckService, + useValue: { check }, + }, + { + provide: TypeOrmHealthIndicator, + useValue: { pingCheck }, + }, + ], + }).compile(); + + controller = moduleRef.get(HealthController); + healthCheckService = { check }; + typeOrmHealthIndicator = { pingCheck }; + jest.spyOn(process, 'uptime').mockReturnValue(42.25); + }); + + afterEach(() => { + if (originalHealthCheckTimeout === undefined) { + delete process.env.HEALTH_CHECK_TIMEOUT_MS; + } else { + process.env.HEALTH_CHECK_TIMEOUT_MS = originalHealthCheckTimeout; + } + jest.restoreAllMocks(); + }); + + it('returns a dependency-free liveness envelope with process metadata', async () => { + const result = await controller.liveness(); + + expect(result).toMatchObject({ + status: 'ok', + service: 'managehub-backend', + uptimeSeconds: 42.25, + }); + expect(result.info).toMatchObject({ service: { status: 'up' } }); + expect(typeOrmHealthIndicator.pingCheck).not.toHaveBeenCalled(); + }); + + it('pings TypeORM with the configured bounded readiness timeout', async () => { + process.env.HEALTH_CHECK_TIMEOUT_MS = '1250'; + + await controller.readiness(); + + expect(typeOrmHealthIndicator.pingCheck).toHaveBeenCalledWith('database', { + timeout: 1250, + }); + expect(healthCheckService.check).toHaveBeenCalledTimes(1); + }); + + it('uses the default timeout when the readiness timeout is invalid', async () => { + process.env.HEALTH_CHECK_TIMEOUT_MS = 'not-a-number'; + + await controller.readiness(); + + expect(typeOrmHealthIndicator.pingCheck).toHaveBeenCalledWith('database', { + timeout: 3000, + }); + }); +}); diff --git a/backend/src/health/health.controller.ts b/backend/src/health/health.controller.ts new file mode 100644 index 00000000..4ba450ab --- /dev/null +++ b/backend/src/health/health.controller.ts @@ -0,0 +1,106 @@ +import { Controller, Get, HttpStatus } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + HealthCheck, + HealthCheckService, + HealthIndicator, + TypeOrmHealthIndicator, +} from '@nestjs/terminus'; +import type { + HealthCheckResult, + HealthIndicatorResult, +} from '@nestjs/terminus'; + +const DEFAULT_HEALTH_CHECK_TIMEOUT_MS = 3_000; +const MAX_HEALTH_CHECK_TIMEOUT_MS = 30_000; + +/** A dependency-free indicator used to answer the process liveness probe. */ +class ServiceLivenessIndicator extends HealthIndicator { + check(): HealthIndicatorResult { + return this.getStatus('service', true); + } +} + +/** + * Health probes for container orchestrators. + * + * `GET /health` is a liveness probe: it only verifies that the Nest process + * can execute a health indicator and must not inspect the database. A + * database blip must not make an orchestrator kill an otherwise healthy pod. + * `GET /health/ready` is the readiness probe: it performs a real TypeORM + * `SELECT 1` ping and Terminus turns an unreachable database into HTTP 503. + * + * JwtAuthGuard is intentionally absent so probes, curl, and other + * unauthenticated health checkers can call these routes. The global + * ThrottlerGuard (APP_GUARD) still applies, which is desirable for a public + * endpoint. + */ +@ApiTags('health') +@Controller('health') +export class HealthController { + private readonly livenessIndicator = new ServiceLivenessIndicator(); + + constructor( + private readonly healthCheckService: HealthCheckService, + private readonly typeOrmHealthIndicator: TypeOrmHealthIndicator, + ) {} + + @Get() + @HealthCheck() + @ApiOperation({ + summary: 'Liveness probe', + description: + 'Confirms the Nest process is alive without checking external dependencies.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'The process is alive.', + }) + async liveness() { + const result = await this.healthCheckService.check([ + () => this.livenessIndicator.check(), + ]); + + return { + ...result, + service: 'managehub-backend', + uptimeSeconds: process.uptime(), + timestamp: new Date().toISOString(), + }; + } + + @Get('ready') + @HealthCheck() + @ApiOperation({ + summary: 'Readiness probe', + description: + 'Checks the primary TypeORM connection and reports unavailable dependencies as 503.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'The service is ready to receive traffic.', + }) + @ApiResponse({ + status: HttpStatus.SERVICE_UNAVAILABLE, + description: 'The database is unreachable or the readiness check timed out.', + }) + readiness(): Promise { + return this.healthCheckService.check([ + () => + this.typeOrmHealthIndicator.pingCheck('database', { + timeout: this.healthCheckTimeoutMs(), + }), + ]); + } + + private healthCheckTimeoutMs(): number { + const configured = Number(process.env.HEALTH_CHECK_TIMEOUT_MS); + if (!Number.isFinite(configured) || configured <= 0) { + return DEFAULT_HEALTH_CHECK_TIMEOUT_MS; + } + return Math.min( + Math.max(1, Math.floor(configured)), + MAX_HEALTH_CHECK_TIMEOUT_MS, + ); + } +} diff --git a/backend/src/health/health.module.ts b/backend/src/health/health.module.ts new file mode 100644 index 00000000..5f031f47 --- /dev/null +++ b/backend/src/health/health.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TerminusModule } from '@nestjs/terminus'; +import { HealthController } from './health.controller'; + +/** + * Keeps probe wiring isolated from business modules. TerminusModule supplies + * HealthCheckService and TypeOrmHealthIndicator; the root TypeORM module + * remains the source of the database connection that the indicator pings. + */ +@Module({ + imports: [TerminusModule], + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/backend/src/main.ts b/backend/src/main.ts index 2e8d4826..69656cbb 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,8 +1,14 @@ import { NestFactory } from '@nestjs/core'; -import { ValidationPipe } from '@nestjs/common'; +import { Logger, ValidationPipe } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { AppModule } from './app.module'; import { AllExceptionsFilter } from './common/http-exception.filter'; +import { isOriginAllowed, resolveAllowedOrigins } from './common/cors'; +import { + GracefulShutdownServer, + installGracefulShutdown, + resolveGracefulShutdownTimeout, +} from './common/graceful-shutdown'; import { randomUUID } from 'crypto'; import { NextFunction, Request, Response } from 'express'; @@ -40,6 +46,37 @@ async function bootstrap() { ); app.useGlobalFilters(new AllExceptionsFilter()); + const logger = new Logger('Bootstrap'); + const allowedOrigins = resolveAllowedOrigins(process.env); + if (process.env.NODE_ENV === 'production' && allowedOrigins.length === 0) { + logger.warn( + 'CORS_ORIGINS is unset or empty in production; all cross-origin requests will be denied.', + ); + } + + app.enableCors({ + origin: (origin, callback) => { + if (isOriginAllowed(origin, allowedOrigins)) { + callback(null, true); + return; + } + + const rejection = `CORS origin "${origin}" is not allowlisted`; + logger.warn(rejection); + callback(new Error(rejection), false); + }, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'OPTIONS'], + allowedHeaders: [ + 'Content-Type', + 'Authorization', + 'Idempotency-Key', + 'X-Payment-Signature', + 'X-Request-Id', + 'X-Correlation-Id', + ], + }); + const config = new DocumentBuilder() .setTitle('ManageHub API') .setDescription( @@ -73,5 +110,15 @@ async function bootstrap() { const port = process.env.PORT ?? 6000; await app.listen(port); + + const server = app.getHttpServer() as GracefulShutdownServer; + installGracefulShutdown({ + app, + server, + logger, + timeoutMs: resolveGracefulShutdownTimeout( + process.env.GRACEFUL_SHUTDOWN_TIMEOUT_MS, + ), + }); } bootstrap();