From d8f4572156cbf49cbe90d724359d081681e2b703 Mon Sep 17 00:00:00 2001 From: FEDDIE Date: Fri, 26 Jun 2026 20:11:17 +0000 Subject: [PATCH] feat(backend): implement graceful shutdown, secrets management, API versioning, and Swagger docs (#992, #993, #995, #996) - Enhance graceful shutdown with scheduled job stopping, background worker registry, and proper shutdown hook wiring - Add SecretsConfigService for centralized secret validation and log redaction - Add deprecation policy endpoint and versioning middleware tests - Complete Swagger @ApiOperation decorators on all controller methods - Add combined Swagger doc at /api/docs alongside per-version docs - Add shutdown test script and unit tests Co-Authored-By: Claude Sonnet 4.6 --- backend/.env.example | 115 ++++++----- backend/scripts/test-graceful-shutdown.sh | 117 +++++++++++ backend/src/common/common.module.ts | 13 +- .../graceful-shutdown.interceptor.ts | 21 +- .../request-logging.interceptor.ts | 29 ++- .../graceful-shutdown.service.spec.ts | 114 +++++++++++ .../services/graceful-shutdown.service.ts | 100 ++++++++-- .../services/secrets-config.service.spec.ts | 64 +++++++ .../common/services/secrets-config.service.ts | 181 ++++++++++++++++++ .../versioning/versioning.controller.ts | 79 +++++++- .../versioning/versioning.middleware.spec.ts | 96 ++++++++++ backend/src/main.ts | 47 +++-- backend/src/modules/admin/admin.controller.ts | 15 ++ .../transactions/transactions.controller.ts | 10 + backend/src/modules/user/user.controller.ts | 26 +++ 15 files changed, 940 insertions(+), 87 deletions(-) create mode 100755 backend/scripts/test-graceful-shutdown.sh create mode 100644 backend/src/common/services/graceful-shutdown.service.spec.ts create mode 100644 backend/src/common/services/secrets-config.service.spec.ts create mode 100644 backend/src/common/services/secrets-config.service.ts create mode 100644 backend/src/common/versioning/versioning.middleware.spec.ts diff --git a/backend/.env.example b/backend/.env.example index fcfea417e..9913d5a79 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,66 +1,87 @@ -PORT=3001 -NODE_ENV=development +# ============================================================================== +# Nestera Backend — Environment Variables +# ============================================================================== +# Copy this file to .env and fill in the values. +# NEVER commit the .env file — it is listed in .gitignore. +# +# Secret Rotation Procedure: +# 1. Generate the new secret value. +# 2. Set the new value in your secret store / hosting env vars. +# 3. Restart the application (graceful shutdown will drain in-flight requests). +# 4. For JWT_SECRET: existing tokens signed with the old secret will fail +# validation — coordinate a rotation window or use a dual-key strategy. +# ============================================================================== -# Stellar Network -STELLAR_NETWORK=testnet +# ── Core ────────────────────────────────────────────────────────────────────── +NODE_ENV=development # development | production | test +PORT=3001 # HTTP listen port + +# ── Database ────────────────────────────────────────────────────────────────── +# Provide EITHER DATABASE_URL (takes precedence) OR the DB_* variables. +DATABASE_URL= # e.g. postgres://user:pass@host:5432/nestera +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=nestera +DB_USER=postgres +DB_PASS= # REQUIRED in production — load from secret store + +# ── Authentication (REQUIRED) ──────────────────────────────────────────────── +JWT_SECRET= # Min 10 chars. NEVER commit this value. +JWT_EXPIRATION=1h # Token lifetime (e.g. 1h, 7d) + +# ── Stellar / Soroban (REQUIRED) ───────────────────────────────────────────── +STELLAR_NETWORK=testnet # testnet | mainnet SOROBAN_RPC_URL=https://soroban-testnet.stellar.org HORIZON_URL=https://horizon-testnet.stellar.org -STELLAR_WEBHOOK_SECRET=your_webhook_secret_here +SOROBAN_RPC_FALLBACK_URLS=https://soroban-testnet.stellar.org +HORIZON_FALLBACK_URLS=https://horizon-testnet.stellar.org +CONTRACT_ID= # Soroban contract ID +STELLAR_WEBHOOK_SECRET= # Min 16 chars. HMAC key for webhook verification. STELLAR_EVENT_POLL_INTERVAL=10000 -# Soroban Testnet – RPC Fallback URLs (comma-separated, in priority order) -# Uncomment and populate with backup nodes to enable automatic failover. -SOROBAN_RPC_FALLBACK_URLS=https://rpc-backup1.stellar.org,https://rpc-backup2.stellar.org -HORIZON_FALLBACK_URLS=https://horizon-backup1.stellar.org,https://horizon-backup2.stellar.org - -# RPC Retry Configuration +# ── RPC Retry ──────────────────────────────────────────────────────────────── RPC_MAX_RETRIES=3 RPC_RETRY_DELAY=1000 RPC_TIMEOUT=10000 -# Contract -CONTRACT_ID=YOUR_DEPLOYED_CONTRACT_ID +# ── Redis (optional) ───────────────────────────────────────────────────────── +REDIS_URL= # e.g. redis://localhost:6379 -# ── Database ────────────────────────────────────────────────────────────────── -# Option A – URL-based (takes precedence; typical for cloud/container deployments) -DATABASE_URL=postgresql://user:password@localhost:5432/nestera - -# Option B – Host-based (used when DATABASE_URL is absent; useful for Kubernetes secrets) -# DB_HOST=localhost -# DB_PORT=5432 -# DB_NAME=nestera -# DB_USER=user -# DB_PASS=password - -# JWT -JWT_SECRET=your_super_secret_key_here -JWT_EXPIRATION=1h - -# Redis (optional) -REDIS_URL=redis://localhost:6379 - -# Mail (SMTP) +# ── Mail / SMTP (optional) ─────────────────────────────────────────────────── MAIL_HOST=smtp.example.com MAIL_PORT=587 -MAIL_USER=your_email@example.com -MAIL_PASS=your_email_password +MAIL_USER= +MAIL_PASS= # SMTP password — load from secret store MAIL_FROM="Nestera" -# Hospital Integration -HOSPITAL_1_ENDPOINT=https://api.hospital1.com -HOSPITAL_2_ENDPOINT=https://api.hospital2.com -HOSPITAL_3_ENDPOINT=https://api.hospital3.com + +# ── KYC Provider (optional) ────────────────────────────────────────────────── +KYC_PROVIDER_BASE_URL= +KYC_PROVIDER_API_KEY= # API key — load from secret store +KYC_PII_ENCRYPTION_KEY= # Min 16 chars. AES key for PII at rest. + +# ── Database Backup / S3 (optional) ────────────────────────────────────────── +BACKUP_S3_BUCKET= +BACKUP_S3_REGION=us-east-1 +BACKUP_AWS_ACCESS_KEY_ID= # AWS credentials — load from secret store +BACKUP_AWS_SECRET_ACCESS_KEY= # AWS credentials — load from secret store +# 64 hex characters = 32-byte AES-256 key. Generate with: openssl rand -hex 32 +BACKUP_ENCRYPTION_KEY= +BACKUP_RETENTION_DAYS=30 +BACKUP_TMP_DIR=/tmp + +# ── Hospital Integration (optional) ────────────────────────────────────────── +HOSPITAL_1_ENDPOINT= +HOSPITAL_2_ENDPOINT= +HOSPITAL_3_ENDPOINT= HOSPITAL_MAX_RETRIES=3 HOSPITAL_RETRY_DELAY=1000 HOSPITAL_REQUEST_TIMEOUT=10000 HOSPITAL_CIRCUIT_BREAKER_THRESHOLD=5 HOSPITAL_CIRCUIT_BREAKER_TIMEOUT=60000 -# ── Database Backup ─────────────────────────────────────────────────────────── -BACKUP_S3_BUCKET=nestera-db-backups -BACKUP_S3_REGION=us-east-1 -BACKUP_AWS_ACCESS_KEY_ID=your_aws_access_key_id -BACKUP_AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key -# 64 hex characters = 32-byte AES-256 key. Generate with: openssl rand -hex 32 -BACKUP_ENCRYPTION_KEY=your_64_char_hex_encryption_key_here_replace_this_value_now -BACKUP_RETENTION_DAYS=30 -BACKUP_TMP_DIR=/tmp +# ── Balance Sync (optional) ────────────────────────────────────────────────── +BALANCE_CACHE_TTL_SECONDS=300 +BALANCE_POLL_INTERVAL_MS=5000 +BALANCE_RECONNECT_INIT_MS=1000 +BALANCE_RECONNECT_MAX_MS=60000 +BALANCE_METRICS_PERSIST_MS=60000 diff --git a/backend/scripts/test-graceful-shutdown.sh b/backend/scripts/test-graceful-shutdown.sh new file mode 100755 index 000000000..d8526aae1 --- /dev/null +++ b/backend/scripts/test-graceful-shutdown.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# +# Graceful Shutdown Test Script +# +# Starts the application, fires several concurrent requests, sends SIGTERM +# mid-flight, and verifies that in-flight requests complete while new ones +# are rejected with 503. +# +set -euo pipefail + +PORT="${PORT:-3001}" +BASE_URL="http://localhost:${PORT}/api" +APP_PID="" +PASS=0 +FAIL=0 + +cleanup() { + if [ -n "$APP_PID" ] && kill -0 "$APP_PID" 2>/dev/null; then + kill -9 "$APP_PID" 2>/dev/null || true + wait "$APP_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +log() { echo "[TEST] $*"; } +pass() { PASS=$((PASS + 1)); log "PASS: $*"; } +fail() { FAIL=$((FAIL + 1)); log "FAIL: $*"; } + +# ---------- 1. Build & start app ---------- +log "Building application..." +cd "$(dirname "$0")/.." +npm run build 2>&1 | tail -3 + +log "Starting application on port ${PORT}..." +NODE_ENV=development \ + DB_HOST=localhost DB_PORT=5432 DB_NAME=nestera DB_USER=postgres DB_PASS=postgres \ + JWT_SECRET=test-jwt-secret-for-shutdown-test \ + JWT_EXPIRATION=1h \ + SOROBAN_RPC_URL=https://soroban-testnet.stellar.org \ + HORIZON_URL=https://horizon-testnet.stellar.org \ + SOROBAN_RPC_FALLBACK_URLS=https://soroban-testnet.stellar.org \ + HORIZON_FALLBACK_URLS=https://horizon-testnet.stellar.org \ + CONTRACT_ID=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC \ + STELLAR_WEBHOOK_SECRET=test-webhook-secret-16chars \ + PORT="${PORT}" \ + node dist/main.js & +APP_PID=$! + +log "Waiting for app to be ready (PID ${APP_PID})..." +for i in $(seq 1 30); do + if curl -sf "${BASE_URL}/health/live" > /dev/null 2>&1; then + break + fi + if ! kill -0 "$APP_PID" 2>/dev/null; then + log "Application exited before becoming ready" + exit 1 + fi + sleep 1 +done + +if ! curl -sf "${BASE_URL}/health/live" > /dev/null 2>&1; then + fail "Application did not become ready within 30 seconds" + exit 1 +fi +pass "Application is ready" + +# ---------- 2. Verify normal operation ---------- +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "${BASE_URL}/health/live") +if [ "$HTTP_CODE" = "200" ]; then + pass "Health endpoint returns 200" +else + fail "Health endpoint returned ${HTTP_CODE}, expected 200" +fi + +# ---------- 3. Send SIGTERM and verify shutdown behavior ---------- +log "Sending SIGTERM to PID ${APP_PID}..." +kill -TERM "$APP_PID" + +sleep 1 + +# After SIGTERM, new requests should fail (connection refused or 503) +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "${BASE_URL}/health/live" 2>/dev/null || echo "000") +if [ "$HTTP_CODE" = "503" ] || [ "$HTTP_CODE" = "000" ]; then + pass "New requests rejected after SIGTERM (HTTP ${HTTP_CODE})" +else + fail "Expected 503 or connection refused after SIGTERM, got ${HTTP_CODE}" +fi + +# ---------- 4. Wait for process to exit ---------- +log "Waiting for process to exit..." +WAIT_COUNT=0 +while kill -0 "$APP_PID" 2>/dev/null; do + WAIT_COUNT=$((WAIT_COUNT + 1)) + if [ "$WAIT_COUNT" -gt 35 ]; then + fail "Process did not exit within 35 seconds" + break + fi + sleep 1 +done + +if ! kill -0 "$APP_PID" 2>/dev/null; then + wait "$APP_PID" 2>/dev/null + EXIT_CODE=$? + if [ "$EXIT_CODE" -eq 0 ]; then + pass "Process exited cleanly with code 0" + else + fail "Process exited with code ${EXIT_CODE}" + fi + APP_PID="" +fi + +# ---------- Summary ---------- +echo "" +log "=============================" +log "Results: ${PASS} passed, ${FAIL} failed" +log "=============================" +[ "$FAIL" -eq 0 ] diff --git a/backend/src/common/common.module.ts b/backend/src/common/common.module.ts index 1f59877a4..3259c1f87 100644 --- a/backend/src/common/common.module.ts +++ b/backend/src/common/common.module.ts @@ -1,10 +1,19 @@ import { Global, Module } from '@nestjs/common'; import { PiiEncryptionService } from './services/pii-encryption.service'; import { RateLimitMonitorService } from './services/rate-limit-monitor.service'; +import { SecretsConfigService } from './services/secrets-config.service'; @Global() @Module({ - providers: [RateLimitMonitorService, PiiEncryptionService], - exports: [RateLimitMonitorService, PiiEncryptionService], + providers: [ + RateLimitMonitorService, + PiiEncryptionService, + SecretsConfigService, + ], + exports: [ + RateLimitMonitorService, + PiiEncryptionService, + SecretsConfigService, + ], }) export class CommonModule {} diff --git a/backend/src/common/interceptors/graceful-shutdown.interceptor.ts b/backend/src/common/interceptors/graceful-shutdown.interceptor.ts index 911b10b71..55e42cec6 100644 --- a/backend/src/common/interceptors/graceful-shutdown.interceptor.ts +++ b/backend/src/common/interceptors/graceful-shutdown.interceptor.ts @@ -3,8 +3,10 @@ import { NestInterceptor, ExecutionContext, CallHandler, + HttpException, + HttpStatus, } from '@nestjs/common'; -import { Observable } from 'rxjs'; +import { Observable, throwError } from 'rxjs'; import { finalize } from 'rxjs/operators'; import { GracefulShutdownService } from '../services/graceful-shutdown.service'; @@ -13,14 +15,17 @@ export class GracefulShutdownInterceptor implements NestInterceptor { constructor(private gracefulShutdown: GracefulShutdownService) {} intercept(context: ExecutionContext, next: CallHandler): Observable { - // Reject new requests during shutdown if (this.gracefulShutdown.isShutdown()) { - const response = context.switchToHttp().getResponse(); - response.status(503).json({ - statusCode: 503, - message: 'Service is shutting down', - }); - return; + return throwError( + () => + new HttpException( + { + statusCode: HttpStatus.SERVICE_UNAVAILABLE, + message: 'Service is shutting down', + }, + HttpStatus.SERVICE_UNAVAILABLE, + ), + ); } this.gracefulShutdown.incrementActiveRequests(); diff --git a/backend/src/common/interceptors/request-logging.interceptor.ts b/backend/src/common/interceptors/request-logging.interceptor.ts index 95e79d237..9b8ffa0a1 100644 --- a/backend/src/common/interceptors/request-logging.interceptor.ts +++ b/backend/src/common/interceptors/request-logging.interceptor.ts @@ -8,6 +8,31 @@ import { Observable } from 'rxjs'; import { tap, catchError } from 'rxjs/operators'; import { v4 as uuidv4 } from 'uuid'; import { Request, Response } from 'express'; +import { SecretsConfigService } from '../services/secrets-config.service'; + +const REDACTED_HEADERS = new Set([ + 'authorization', + 'cookie', + 'x-api-key', + 'x-auth-token', +]); + +function sanitizeHeaders(headers: Record): Record { + const safe: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (REDACTED_HEADERS.has(key.toLowerCase())) { + safe[key] = '[REDACTED]'; + } else if ( + typeof value === 'string' && + SecretsConfigService.isSensitiveKey(key) + ) { + safe[key] = '[REDACTED]'; + } else { + safe[key] = String(value); + } + } + return safe; +} @Injectable() export class RequestLoggingInterceptor implements NestInterceptor { @@ -17,10 +42,10 @@ export class RequestLoggingInterceptor implements NestInterceptor { const request = context.switchToHttp().getRequest(); const response = context.switchToHttp().getResponse(); - const correlationId = request.headers['x-correlation-id'] as string || uuidv4(); + const correlationId = + (request.headers['x-correlation-id'] as string) || uuidv4(); const startTime = Date.now(); - // Attach correlation ID to request and response (request as any).correlationId = correlationId; response.setHeader('x-correlation-id', correlationId); diff --git a/backend/src/common/services/graceful-shutdown.service.spec.ts b/backend/src/common/services/graceful-shutdown.service.spec.ts new file mode 100644 index 000000000..3decbabfb --- /dev/null +++ b/backend/src/common/services/graceful-shutdown.service.spec.ts @@ -0,0 +1,114 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { GracefulShutdownService } from './graceful-shutdown.service'; + +describe('GracefulShutdownService', () => { + let service: GracefulShutdownService; + let mockDataSource: any; + let mockCacheManager: any; + let mockSchedulerRegistry: any; + + beforeEach(async () => { + mockDataSource = { + isInitialized: true, + destroy: jest.fn().mockResolvedValue(undefined), + }; + + mockCacheManager = { + reset: jest.fn().mockResolvedValue(undefined), + }; + + mockSchedulerRegistry = { + getCronJobs: jest.fn().mockReturnValue(new Map()), + getIntervals: jest.fn().mockReturnValue([]), + getTimeouts: jest.fn().mockReturnValue([]), + deleteInterval: jest.fn(), + deleteTimeout: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GracefulShutdownService, + { provide: 'DataSource', useValue: mockDataSource }, + { provide: CACHE_MANAGER, useValue: mockCacheManager }, + { provide: SchedulerRegistry, useValue: mockSchedulerRegistry }, + ], + }).compile(); + + service = new GracefulShutdownService( + mockDataSource, + mockCacheManager, + mockSchedulerRegistry, + ); + }); + + it('should track active requests', () => { + expect(service.getActiveRequestCount()).toBe(0); + service.incrementActiveRequests(); + service.incrementActiveRequests(); + expect(service.getActiveRequestCount()).toBe(2); + service.decrementActiveRequests(); + expect(service.getActiveRequestCount()).toBe(1); + }); + + it('should not go below zero active requests', () => { + service.decrementActiveRequests(); + expect(service.getActiveRequestCount()).toBe(0); + }); + + it('should not accept new requests during shutdown', async () => { + expect(service.isShutdown()).toBe(false); + await service.onApplicationShutdown('SIGTERM'); + expect(service.isShutdown()).toBe(true); + }); + + it('should close database on shutdown', async () => { + await service.onApplicationShutdown('SIGTERM'); + expect(mockDataSource.destroy).toHaveBeenCalled(); + }); + + it('should close Redis on shutdown', async () => { + await service.onApplicationShutdown('SIGTERM'); + expect(mockCacheManager.reset).toHaveBeenCalled(); + }); + + it('should stop scheduled jobs on shutdown', async () => { + const mockCronJob = { stop: jest.fn() }; + mockSchedulerRegistry.getCronJobs.mockReturnValue( + new Map([['test-cron', mockCronJob]]), + ); + + await service.onApplicationShutdown('SIGTERM'); + expect(mockCronJob.stop).toHaveBeenCalled(); + }); + + it('should stop registered background workers on shutdown', async () => { + const worker = { + name: 'test-worker', + shutdown: jest.fn().mockResolvedValue(undefined), + }; + service.registerWorker(worker); + + await service.onApplicationShutdown('SIGTERM'); + expect(worker.shutdown).toHaveBeenCalled(); + }); + + it('should handle worker shutdown failures gracefully', async () => { + const worker = { + name: 'failing-worker', + shutdown: jest.fn().mockRejectedValue(new Error('shutdown failed')), + }; + service.registerWorker(worker); + + await expect( + service.onApplicationShutdown('SIGTERM'), + ).resolves.not.toThrow(); + }); + + it('should not increment requests when shutting down', async () => { + await service.onApplicationShutdown('SIGTERM'); + service.incrementActiveRequests(); + expect(service.getActiveRequestCount()).toBe(0); + }); +}); diff --git a/backend/src/common/services/graceful-shutdown.service.ts b/backend/src/common/services/graceful-shutdown.service.ts index 540478b6e..98e631064 100644 --- a/backend/src/common/services/graceful-shutdown.service.ts +++ b/backend/src/common/services/graceful-shutdown.service.ts @@ -1,21 +1,34 @@ import { Injectable, Logger, OnApplicationShutdown } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; -import { Inject } from '@nestjs/common'; +import { Inject, Optional } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; import { Cache } from 'cache-manager'; +export type BackgroundWorker = { + name: string; + shutdown: () => Promise; +}; + @Injectable() export class GracefulShutdownService implements OnApplicationShutdown { private readonly logger = new Logger(GracefulShutdownService.name); private isShuttingDown = false; private activeRequests = 0; - private readonly maxShutdownTimeout = 30000; // 30 seconds + private readonly maxDrainTimeout = 25000; + private readonly backgroundWorkers: BackgroundWorker[] = []; constructor( private dataSource: DataSource, @Inject(CACHE_MANAGER) private cacheManager: Cache, + @Optional() private schedulerRegistry?: SchedulerRegistry, ) {} + registerWorker(worker: BackgroundWorker): void { + this.backgroundWorkers.push(worker); + this.logger.log(`Registered background worker: ${worker.name}`); + } + incrementActiveRequests(): void { if (!this.isShuttingDown) { this.activeRequests++; @@ -23,7 +36,11 @@ export class GracefulShutdownService implements OnApplicationShutdown { } decrementActiveRequests(): void { - this.activeRequests--; + this.activeRequests = Math.max(0, this.activeRequests - 1); + } + + getActiveRequestCount(): number { + return this.activeRequests; } isShutdown(): boolean { @@ -36,32 +53,55 @@ export class GracefulShutdownService implements OnApplicationShutdown { const shutdownStartTime = Date.now(); - // Stop accepting new requests this.logger.log('Stopping acceptance of new requests'); - // Wait for in-flight requests to complete + await this.stopScheduledJobs(); + await this.waitForInFlightRequests(); - // Close database connections + await this.stopBackgroundWorkers(); + await this.closeDatabase(); - // Close Redis connections await this.closeRedis(); const shutdownDuration = Date.now() - shutdownStartTime; - this.logger.log( - `Graceful shutdown completed in ${shutdownDuration}ms`, - ); + this.logger.log(`Graceful shutdown completed in ${shutdownDuration}ms`); + } + + private async stopScheduledJobs(): Promise { + if (!this.schedulerRegistry) return; + + try { + const cronJobs = this.schedulerRegistry.getCronJobs(); + cronJobs.forEach((job, name) => { + job.stop(); + this.logger.log(`Stopped cron job: ${name}`); + }); + + const intervals = this.schedulerRegistry.getIntervals(); + intervals.forEach((name) => { + this.schedulerRegistry!.deleteInterval(name); + this.logger.log(`Cleared interval: ${name}`); + }); + + const timeouts = this.schedulerRegistry.getTimeouts(); + timeouts.forEach((name) => { + this.schedulerRegistry!.deleteTimeout(name); + this.logger.log(`Cleared timeout: ${name}`); + }); + } catch (error) { + this.logger.error('Error stopping scheduled jobs:', error); + } } private async waitForInFlightRequests(): Promise { const startTime = Date.now(); - const timeout = 25000; // Leave 5 seconds for other cleanup while (this.activeRequests > 0) { const elapsed = Date.now() - startTime; - if (elapsed > timeout) { + if (elapsed > this.maxDrainTimeout) { this.logger.warn( `Timeout waiting for ${this.activeRequests} in-flight requests. Forcing shutdown.`, ); @@ -71,10 +111,42 @@ export class GracefulShutdownService implements OnApplicationShutdown { this.logger.log( `Waiting for ${this.activeRequests} in-flight requests to complete...`, ); - await new Promise((resolve) => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 500)); } - this.logger.log('All in-flight requests completed'); + if (this.activeRequests === 0) { + this.logger.log('All in-flight requests completed'); + } + } + + private async stopBackgroundWorkers(): Promise { + if (this.backgroundWorkers.length === 0) return; + + this.logger.log( + `Stopping ${this.backgroundWorkers.length} background worker(s)...`, + ); + + const results = await Promise.allSettled( + this.backgroundWorkers.map(async (worker) => { + try { + await worker.shutdown(); + this.logger.log(`Background worker stopped: ${worker.name}`); + } catch (error) { + this.logger.error( + `Error stopping background worker ${worker.name}:`, + error, + ); + throw error; + } + }), + ); + + const failed = results.filter((r) => r.status === 'rejected'); + if (failed.length > 0) { + this.logger.warn( + `${failed.length} background worker(s) failed to stop cleanly`, + ); + } } private async closeDatabase(): Promise { diff --git a/backend/src/common/services/secrets-config.service.spec.ts b/backend/src/common/services/secrets-config.service.spec.ts new file mode 100644 index 000000000..b18b9a4ad --- /dev/null +++ b/backend/src/common/services/secrets-config.service.spec.ts @@ -0,0 +1,64 @@ +import { SecretsConfigService } from './secrets-config.service'; + +describe('SecretsConfigService', () => { + describe('redactValue', () => { + it('should fully redact short values', () => { + expect(SecretsConfigService.redactValue('abc')).toBe('****'); + expect(SecretsConfigService.redactValue('12345678')).toBe('****'); + }); + + it('should partially redact longer values', () => { + const result = SecretsConfigService.redactValue('my-super-secret-key'); + expect(result).toBe('my-s****-key'); + expect(result).not.toContain('super-secret'); + }); + }); + + describe('isSensitiveKey', () => { + it('should detect sensitive keys', () => { + expect(SecretsConfigService.isSensitiveKey('password')).toBe(true); + expect(SecretsConfigService.isSensitiveKey('JWT_SECRET')).toBe(true); + expect(SecretsConfigService.isSensitiveKey('api_key')).toBe(true); + expect(SecretsConfigService.isSensitiveKey('apiKey')).toBe(true); + expect(SecretsConfigService.isSensitiveKey('Authorization')).toBe(true); + expect(SecretsConfigService.isSensitiveKey('access_key')).toBe(true); + expect(SecretsConfigService.isSensitiveKey('encryption_key')).toBe(true); + expect(SecretsConfigService.isSensitiveKey('private_key')).toBe(true); + }); + + it('should not flag non-sensitive keys', () => { + expect(SecretsConfigService.isSensitiveKey('username')).toBe(false); + expect(SecretsConfigService.isSensitiveKey('email')).toBe(false); + expect(SecretsConfigService.isSensitiveKey('port')).toBe(false); + expect(SecretsConfigService.isSensitiveKey('host')).toBe(false); + }); + }); + + describe('redactObject', () => { + it('should redact sensitive fields in an object', () => { + const input = { + host: 'localhost', + port: 5432, + password: 'supersecret', + jwt_secret: 'mysecret123', + nested: { + api_key: 'key-value', + name: 'test', + }, + }; + + const result = SecretsConfigService.redactObject(input); + + expect(result.host).toBe('localhost'); + expect(result.port).toBe(5432); + expect(result.password).toBe('[REDACTED]'); + expect(result.jwt_secret).toBe('[REDACTED]'); + expect(result.nested.api_key).toBe('[REDACTED]'); + expect(result.nested.name).toBe('test'); + }); + + it('should handle empty objects', () => { + expect(SecretsConfigService.redactObject({})).toEqual({}); + }); + }); +}); diff --git a/backend/src/common/services/secrets-config.service.ts b/backend/src/common/services/secrets-config.service.ts new file mode 100644 index 000000000..53ea42455 --- /dev/null +++ b/backend/src/common/services/secrets-config.service.ts @@ -0,0 +1,181 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +interface SecretDefinition { + key: string; + envVar: string; + required: boolean; + description: string; +} + +const SECRET_DEFINITIONS: SecretDefinition[] = [ + { + key: 'jwt.secret', + envVar: 'JWT_SECRET', + required: true, + description: 'JWT signing secret (min 10 chars)', + }, + { + key: 'jwt.expiration', + envVar: 'JWT_EXPIRATION', + required: true, + description: 'JWT token expiration (e.g. 1h, 7d)', + }, + { + key: 'stellar.webhookSecret', + envVar: 'STELLAR_WEBHOOK_SECRET', + required: true, + description: 'Stellar webhook HMAC secret (min 16 chars)', + }, + { + key: 'database.pass', + envVar: 'DB_PASS', + required: false, + description: 'Database password', + }, + { + key: 'database.url', + envVar: 'DATABASE_URL', + required: false, + description: 'Full database connection URL', + }, + { + key: 'redis.url', + envVar: 'REDIS_URL', + required: false, + description: 'Redis connection URL', + }, + { + key: 'mail.pass', + envVar: 'MAIL_PASS', + required: false, + description: 'SMTP password', + }, + { + key: 'kyc.providerApiKey', + envVar: 'KYC_PROVIDER_API_KEY', + required: false, + description: 'KYC provider API key', + }, + { + key: 'kyc.piiEncryptionKey', + envVar: 'KYC_PII_ENCRYPTION_KEY', + required: false, + description: 'AES key for PII encryption (min 16 chars)', + }, + { + key: 'backup.awsAccessKeyId', + envVar: 'BACKUP_AWS_ACCESS_KEY_ID', + required: false, + description: 'AWS access key for S3 backups', + }, + { + key: 'backup.awsSecretAccessKey', + envVar: 'BACKUP_AWS_SECRET_ACCESS_KEY', + required: false, + description: 'AWS secret key for S3 backups', + }, + { + key: 'backup.encryptionKey', + envVar: 'BACKUP_ENCRYPTION_KEY', + required: false, + description: 'Backup encryption key (64 hex chars)', + }, +]; + +const SENSITIVE_PATTERNS = [ + /password/i, + /secret/i, + /token/i, + /api[_-]?key/i, + /authorization/i, + /credential/i, + /private[_-]?key/i, + /encryption[_-]?key/i, + /access[_-]?key/i, +]; + +@Injectable() +export class SecretsConfigService implements OnModuleInit { + private readonly logger = new Logger(SecretsConfigService.name); + + constructor(private readonly configService: ConfigService) {} + + onModuleInit(): void { + this.validateRequiredSecrets(); + } + + get(key: string): string | undefined { + return this.configService.get(key); + } + + getOrThrow(key: string): string { + const value = this.configService.get(key); + if (!value) { + throw new Error(`Required secret "${key}" is not configured`); + } + return value; + } + + private validateRequiredSecrets(): void { + const missing: string[] = []; + + for (const def of SECRET_DEFINITIONS) { + if (def.required) { + const value = this.configService.get(def.key); + if (!value) { + missing.push(`${def.envVar} — ${def.description}`); + } + } + } + + if (missing.length > 0) { + this.logger.error( + `Missing required secrets:\n${missing.map((m) => ` - ${m}`).join('\n')}`, + ); + throw new Error( + `Application cannot start: ${missing.length} required secret(s) missing`, + ); + } + + this.logger.log('All required secrets validated'); + } + + static redactValue(value: string): string { + if (value.length <= 8) return '****'; + return value.substring(0, 4) + '****' + value.substring(value.length - 4); + } + + static isSensitiveKey(key: string): boolean { + return SENSITIVE_PATTERNS.some((pattern) => pattern.test(key)); + } + + static redactObject(obj: Record): Record { + const redacted: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + redacted[key] = SecretsConfigService.redactObject(value); + } else if ( + typeof value === 'string' && + SecretsConfigService.isSensitiveKey(key) + ) { + redacted[key] = '[REDACTED]'; + } else { + redacted[key] = value; + } + } + return redacted; + } + + getSecretDefinitions(): Array<{ + envVar: string; + required: boolean; + description: string; + }> { + return SECRET_DEFINITIONS.map(({ envVar, required, description }) => ({ + envVar, + required, + description, + })); + } +} diff --git a/backend/src/common/versioning/versioning.controller.ts b/backend/src/common/versioning/versioning.controller.ts index 1b7c983cb..752740aa0 100644 --- a/backend/src/common/versioning/versioning.controller.ts +++ b/backend/src/common/versioning/versioning.controller.ts @@ -1,5 +1,10 @@ import { Controller, Get, UseGuards, VERSION_NEUTRAL } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { Roles } from '../decorators/roles.decorator'; import { RolesGuard } from '../guards/roles.guard'; @@ -17,7 +22,30 @@ export class VersioningController { constructor(private readonly versionAnalytics: VersionAnalyticsService) {} @Get('info') - @ApiOperation({ summary: 'Get API version information and sunset policy' }) + @ApiOperation({ + summary: 'Get API version information and sunset policy', + description: + 'Returns the current version, all supported versions, deprecated versions with sunset dates, ' + + 'and a link to the migration guide.', + }) + @ApiResponse({ + status: 200, + description: 'Version information', + schema: { + example: { + current: '2', + supported: ['1', '2'], + deprecated: [ + { + version: '1', + sunset: '2026-09-01', + message: 'API v1 is deprecated. Please migrate to v2.', + }, + ], + migrationGuide: '/api/v2/docs', + }, + }, + }) getVersionInfo() { return { current: CURRENT_VERSION, @@ -30,11 +58,56 @@ export class VersioningController { }; } + @Get('deprecation-policy') + @ApiOperation({ + summary: 'Get deprecation policy and migration guidance', + description: + 'Returns the deprecation timeline, sunset policy, and version-specific migration steps.', + }) + @ApiResponse({ status: 200, description: 'Deprecation policy details' }) + getDeprecationPolicy() { + return { + policy: { + minimumSupportWindow: '6 months', + sunsetNotice: '3 months before removal', + headerIndicators: [ + 'Deprecation: true — version is deprecated', + 'Sunset: — date when the version will be removed', + 'X-Deprecation-Notice: — human-readable migration guidance', + 'Link: ; rel="successor-version" — URL of the successor version docs', + ], + headerNegotiation: [ + 'Accept-Version: — request a specific API version via header', + 'X-API-Version: — alternative version header', + ], + }, + versions: Object.fromEntries( + [...SUPPORTED_VERSIONS].map((v) => { + const deprecation = DEPRECATED_VERSIONS[v]; + return [ + `v${v}`, + { + status: deprecation ? 'deprecated' : 'current', + ...(deprecation && { sunset: deprecation.sunset }), + docs: `/api/v${v}/docs`, + }, + ]; + }), + ), + }; + } + @Get('stats') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(Role.ADMIN) @ApiBearerAuth() - @ApiOperation({ summary: 'Get version usage analytics (admin only)' }) + @ApiOperation({ + summary: 'Get version usage analytics (admin only)', + description: 'Returns per-version request counts and last-seen timestamps.', + }) + @ApiResponse({ status: 200, description: 'Version usage statistics' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Admin role required' }) getStats(): Record { return this.versionAnalytics.getStats(); } diff --git a/backend/src/common/versioning/versioning.middleware.spec.ts b/backend/src/common/versioning/versioning.middleware.spec.ts new file mode 100644 index 000000000..ebcec685f --- /dev/null +++ b/backend/src/common/versioning/versioning.middleware.spec.ts @@ -0,0 +1,96 @@ +import { + VersioningMiddleware, + SUPPORTED_VERSIONS, + CURRENT_VERSION, + DEPRECATED_VERSIONS, +} from './versioning.middleware'; + +describe('VersioningMiddleware', () => { + let middleware: VersioningMiddleware; + let mockReq: any; + let mockRes: any; + let mockNext: jest.Mock; + + beforeEach(() => { + middleware = new VersioningMiddleware(); + mockReq = { headers: {}, url: '/api/v2/health' }; + mockRes = { + setHeader: jest.fn(), + }; + mockNext = jest.fn(); + }); + + it('should set X-API-Version header for versioned URLs', () => { + middleware.use(mockReq, mockRes, mockNext); + expect(mockRes.setHeader).toHaveBeenCalledWith('X-API-Version', '2'); + expect(mockNext).toHaveBeenCalled(); + }); + + it('should set deprecation headers for deprecated versions', () => { + mockReq.url = '/api/v1/health'; + middleware.use(mockReq, mockRes, mockNext); + + expect(mockRes.setHeader).toHaveBeenCalledWith('Deprecation', 'true'); + expect(mockRes.setHeader).toHaveBeenCalledWith( + 'Sunset', + DEPRECATED_VERSIONS['1'].sunset, + ); + expect(mockRes.setHeader).toHaveBeenCalledWith( + 'X-Deprecation-Notice', + DEPRECATED_VERSIONS['1'].message, + ); + expect(mockRes.setHeader).toHaveBeenCalledWith( + 'Link', + `; rel="successor-version"`, + ); + }); + + it('should not set deprecation headers for current version', () => { + mockReq.url = '/api/v2/users'; + middleware.use(mockReq, mockRes, mockNext); + + const calls = mockRes.setHeader.mock.calls.map((c: any) => c[0]); + expect(calls).not.toContain('Deprecation'); + expect(calls).not.toContain('Sunset'); + }); + + it('should rewrite URL when Accept-Version header is provided', () => { + mockReq.url = '/api/health'; + mockReq.headers['accept-version'] = '1'; + middleware.use(mockReq, mockRes, mockNext); + + expect(mockReq.url).toBe('/api/v1/health'); + }); + + it('should rewrite URL when X-API-Version header is provided', () => { + mockReq.url = '/api/health'; + mockReq.headers['x-api-version'] = '2'; + middleware.use(mockReq, mockRes, mockNext); + + expect(mockReq.url).toBe('/api/v2/health'); + }); + + it('should default to current version for unsupported header version', () => { + mockReq.url = '/api/health'; + mockReq.headers['accept-version'] = '99'; + middleware.use(mockReq, mockRes, mockNext); + + expect(mockReq.url).toBe(`/api/v${CURRENT_VERSION}/health`); + }); + + it('should not rewrite URL if version is already in the path', () => { + mockReq.url = '/api/v1/health'; + mockReq.headers['accept-version'] = '2'; + middleware.use(mockReq, mockRes, mockNext); + + expect(mockReq.url).toBe('/api/v1/health'); + }); + + it('should export correct constants', () => { + expect(SUPPORTED_VERSIONS).toContain('1'); + expect(SUPPORTED_VERSIONS).toContain('2'); + expect(CURRENT_VERSION).toBe('2'); + expect(DEPRECATED_VERSIONS['1']).toBeDefined(); + expect(DEPRECATED_VERSIONS['1'].sunset).toBeDefined(); + }); +}); diff --git a/backend/src/main.ts b/backend/src/main.ts index e09fa6563..5fbd707c5 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -53,11 +53,30 @@ async function bootstrap() { ) .setVersion(version) .addBearerAuth() + .addApiKey( + { type: 'apiKey', name: 'X-API-Version', in: 'header' }, + 'api-version', + ) .build(); const document = SwaggerModule.createDocument(app, swaggerConfig); SwaggerModule.setup(`api/v${version}/docs`, app, document); } + // Combined Swagger doc at /api/docs + const combinedConfig = new DocumentBuilder() + .setTitle('Nestera API') + .setDescription( + 'Nestera platform API — all versions. ' + + 'Use the versioned docs at /api/v1/docs or /api/v2/docs for version-specific views.', + ) + .setVersion(CURRENT_VERSION) + .addBearerAuth() + .build(); + const combinedDoc = SwaggerModule.createDocument(app, combinedConfig); + SwaggerModule.setup('api/docs', app, combinedDoc); + + app.enableShutdownHooks(); + const server = await app.listen(port || 3001); const logger = app.get(Logger); logger.log(`Application is running on: http://localhost:${port}/api`); @@ -66,21 +85,27 @@ async function bootstrap() { ); logger.log(`Swagger v2 docs: http://localhost:${port}/api/v2/docs`); - // Setup graceful shutdown const gracefulShutdown = app.get(GracefulShutdownService); - const signals = ['SIGTERM', 'SIGINT']; - signals.forEach((signal) => { - process.on(signal, async () => { - logger.log(`Received ${signal}, starting graceful shutdown...`); - server.close(async () => { - await app.close(); - process.exit(0); - }); + const shutdown = async (signal: string) => { + logger.log(`Received ${signal}, starting graceful shutdown...`); + + // Stop accepting new connections + server.close(() => { + logger.log('HTTP server closed — no new connections accepted'); }); - }); - // Handle uncaught exceptions + // NestJS onApplicationShutdown hooks handle the rest: + // drain in-flight requests, stop workers, close DB/Redis + await app.close(); + + logger.log('Application shut down successfully'); + process.exit(0); + }; + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('uncaughtException', (error) => { logger.error('Uncaught Exception:', error); process.exit(1); diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index e40a7d48a..f753456bc 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -34,6 +34,9 @@ export class AdminController { ) {} @Patch('users/:id/kyc/approve') + @ApiOperation({ summary: 'Approve KYC for a user' }) + @ApiResponse({ status: 200, description: 'KYC approved' }) + @ApiResponse({ status: 400, description: 'User ID is required' }) async approveKyc(@Param('id') userId: string) { if (!userId) { throw new BadRequestException('User ID is required'); @@ -42,6 +45,12 @@ export class AdminController { } @Patch('users/:id/kyc/reject') + @ApiOperation({ summary: 'Reject KYC for a user' }) + @ApiResponse({ status: 200, description: 'KYC rejected' }) + @ApiResponse({ + status: 400, + description: 'User ID or rejection reason missing', + }) async rejectKyc(@Param('id') userId: string, @Body() dto: RejectKycDto) { if (!userId) { throw new BadRequestException('User ID is required'); @@ -53,6 +62,12 @@ export class AdminController { } @Patch('users/:id/kyc') + @ApiOperation({ summary: 'Update KYC status (approve or reject)' }) + @ApiResponse({ status: 200, description: 'KYC status updated' }) + @ApiResponse({ + status: 400, + description: 'Invalid action or missing parameters', + }) async updateKycStatus( @Param('id') userId: string, @Body() body: { action: 'approve' | 'reject'; reason?: string }, diff --git a/backend/src/modules/transactions/transactions.controller.ts b/backend/src/modules/transactions/transactions.controller.ts index 7e58e3ce2..be1257c6a 100644 --- a/backend/src/modules/transactions/transactions.controller.ts +++ b/backend/src/modules/transactions/transactions.controller.ts @@ -85,6 +85,9 @@ export class TransactionsController { } @Post(':id/tag') + @ApiOperation({ summary: 'Add or update tags on a transaction' }) + @ApiResponse({ status: 201, description: 'Transaction tagged' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) async tagTransaction( @CurrentUser() user: { id: string }, @Param('id') id: string, @@ -94,11 +97,18 @@ export class TransactionsController { } @Get('categories') + @ApiOperation({ + summary: 'List transaction categories for the authenticated user', + }) + @ApiResponse({ status: 200, description: 'List of categories' }) async getCategories(@CurrentUser() user: { id: string }) { return this.transactionsService.listCategories(user.id); } @Post('tags/bulk') + @ApiOperation({ summary: 'Bulk-tag multiple transactions' }) + @ApiResponse({ status: 201, description: 'Transactions tagged' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) async bulkTag(@CurrentUser() user: { id: string }, @Body() body: BulkTagDto) { return this.transactionsService.bulkTag(user.id, body); } diff --git a/backend/src/modules/user/user.controller.ts b/backend/src/modules/user/user.controller.ts index 659384389..aa2cb6d5b 100644 --- a/backend/src/modules/user/user.controller.ts +++ b/backend/src/modules/user/user.controller.ts @@ -117,6 +117,17 @@ export class UserController { } @Get('me/net-worth') + @ApiOperation({ + summary: 'Get net worth breakdown for the authenticated user', + description: + 'Returns wallet balance, savings breakdown (flexible/locked), and percentage allocations.', + }) + @ApiResponse({ + status: 200, + description: 'Net worth breakdown', + type: NetWorthDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) async getNetWorth(@CurrentUser() user: { id: string }): Promise { const userEntity = await this.userService.findById(user.id); @@ -162,21 +173,33 @@ export class UserController { } @Get(':id') + @ApiOperation({ summary: 'Get a user by ID' }) + @ApiResponse({ status: 200, description: 'User found' }) + @ApiResponse({ status: 404, description: 'User not found' }) findOne(@Param('id') id: string) { return this.userService.findById(id); } @Patch('me') + @ApiOperation({ summary: 'Update the authenticated user profile' }) + @ApiResponse({ status: 200, description: 'Profile updated' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) updateMe(@CurrentUser() user: { id: string }, @Body() dto: UpdateUserDto) { return this.userService.update(user.id, dto); } @Delete('me') + @ApiOperation({ summary: 'Delete the authenticated user account' }) + @ApiResponse({ status: 200, description: 'Account deleted' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) deleteMe(@CurrentUser() user: { id: string }) { return this.userService.remove(user.id); } @Post('avatar') + @ApiOperation({ summary: 'Upload a profile avatar image' }) + @ApiResponse({ status: 201, description: 'Avatar uploaded' }) + @ApiResponse({ status: 400, description: 'Invalid file type or size' }) @UseInterceptors(FileInterceptor('file')) async uploadAvatar( @CurrentUser() user: { id: string }, @@ -195,6 +218,9 @@ export class UserController { } @Post('me/kyc-docs') + @ApiOperation({ summary: 'Upload a KYC verification document' }) + @ApiResponse({ status: 201, description: 'Document uploaded' }) + @ApiResponse({ status: 400, description: 'Invalid file type or size' }) @UseInterceptors(FileInterceptor('document')) async uploadKycDocument( @CurrentUser() user: { id: string },