diff --git a/backend/.env.example b/backend/.env.example index 9cf47a9c0..0052ac700 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,26 +1,53 @@ -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. +# ============================================================================== + +# ── Core ────────────────────────────────────────────────────────────────────── +NODE_ENV=development # development | production | test +PORT=3001 # HTTP listen port -# Stellar Network -STELLAR_NETWORK=testnet +# ── 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 +# ── Mail / SMTP (optional) ─────────────────────────────────────────────────── # ── Database ────────────────────────────────────────────────────────────────── # Option A – URL-based (takes precedence; typical for cloud/container deployments) DATABASE_URL=postgresql://user:password@localhost:5432/nestera @@ -70,19 +97,41 @@ REDIS_URL=redis://localhost:6379 # Mail (SMTP) 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 +# ── 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 # ── Database Backup ─────────────────────────────────────────────────────────── BACKUP_S3_BUCKET=nestera-db-backups BACKUP_S3_REGION=us-east-1 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 aa86727e2..9b096f646 100644 --- a/backend/src/common/common.module.ts +++ b/backend/src/common/common.module.ts @@ -1,6 +1,14 @@ 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, + SecretsConfigService, import { IdempotencyService } from './services/idempotency.service'; import { IdempotencyCleanupService } from './services/idempotency-cleanup.service'; import { LogSanitizerService } from './services/log-sanitizer.service'; @@ -23,6 +31,7 @@ import { CacheModule } from '../modules/cache/cache.module'; exports: [ RateLimitMonitorService, PiiEncryptionService, + SecretsConfigService, IdempotencyService, LogSanitizerService, CompressionMetricsService, diff --git a/backend/src/common/interceptors/graceful-shutdown.interceptor.ts b/backend/src/common/interceptors/graceful-shutdown.interceptor.ts index cbaa30a94..588114c10 100644 --- a/backend/src/common/interceptors/graceful-shutdown.interceptor.ts +++ b/backend/src/common/interceptors/graceful-shutdown.interceptor.ts @@ -3,7 +3,10 @@ import { NestInterceptor, ExecutionContext, CallHandler, + HttpException, + HttpStatus, } from '@nestjs/common'; +import { Observable, throwError } from 'rxjs'; import { Observable, EMPTY } from 'rxjs'; import { finalize } from 'rxjs/operators'; import { GracefulShutdownService } from '../services/graceful-shutdown.service'; @@ -13,8 +16,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()) { + return throwError( + () => + new HttpException( + { + statusCode: HttpStatus.SERVICE_UNAVAILABLE, + message: 'Service is shutting down', + }, + HttpStatus.SERVICE_UNAVAILABLE, + ), + ); const response = context.switchToHttp().getResponse(); response.setHeader?.('Connection', 'close'); response.status(503).json({ diff --git a/backend/src/common/interceptors/request-logging.interceptor.ts b/backend/src/common/interceptors/request-logging.interceptor.ts index bc6ab7f5a..9df8d317e 100644 --- a/backend/src/common/interceptors/request-logging.interceptor.ts +++ b/backend/src/common/interceptors/request-logging.interceptor.ts @@ -9,6 +9,31 @@ import { import { Observable, throwError } from 'rxjs'; import { tap, catchError } from 'rxjs/operators'; 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; +} import { Logger } from 'nestjs-pino'; import { LogSanitizerService } from '../services/log-sanitizer.service'; import { ApmService } from '../../modules/apm/apm.service'; @@ -42,6 +67,7 @@ export class RequestLoggingInterceptor implements NestInterceptor { const response = context.switchToHttp().getResponse(); const correlationId = + (request.headers['x-correlation-id'] as string) || uuidv4(); (request as Request & { correlationId?: string }).correlationId || (request.headers['x-correlation-id'] as string) || 'unknown'; @@ -50,6 +76,22 @@ export class RequestLoggingInterceptor implements NestInterceptor { const { method, ip } = request; const url = this.sanitizer?.sanitizeUrl(request.url) ?? request.url; + (request as any).correlationId = correlationId; + response.setHeader('x-correlation-id', correlationId); + + const { method, url, ip } = request; + const userAgent = request.headers['user-agent']; + + this.logger.log( + JSON.stringify({ + type: 'REQUEST', + correlationId, + method, + url, + ip, + userAgent, + timestamp: new Date().toISOString(), + }), // Skip noisy paths const rawPath = request.path ?? request.url; if (SKIP_LOG_PATHS.has(rawPath)) { diff --git a/backend/src/common/services/graceful-shutdown.service.spec.ts b/backend/src/common/services/graceful-shutdown.service.spec.ts index 4c504e015..45ea46210 100644 --- a/backend/src/common/services/graceful-shutdown.service.spec.ts +++ b/backend/src/common/services/graceful-shutdown.service.spec.ts @@ -1,7 +1,115 @@ +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); const createService = () => { jest.useFakeTimers(); diff --git a/backend/src/common/services/graceful-shutdown.service.ts b/backend/src/common/services/graceful-shutdown.service.ts index 255f2ea7b..809b263df 100644 --- a/backend/src/common/services/graceful-shutdown.service.ts +++ b/backend/src/common/services/graceful-shutdown.service.ts @@ -6,6 +6,7 @@ import { Optional, } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject, Optional } from '@nestjs/common'; import { SchedulerRegistry } from '@nestjs/schedule'; import { Cache } from 'cache-manager'; import type { Server } from 'node:http'; @@ -17,6 +18,11 @@ type ShutdownManagedServer = Server & { closeAllConnections?: () => void; }; +export type BackgroundWorker = { + name: string; + shutdown: () => Promise; +}; + @Injectable() export class GracefulShutdownService implements BeforeApplicationShutdown { private static instance: GracefulShutdownService | null = null; @@ -29,6 +35,23 @@ export class GracefulShutdownService implements BeforeApplicationShutdown { private isShuttingDown = false; private schedulersStopped = false; private activeRequests = 0; + 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++; private activeBackgroundTasks = 0; private registeredHttpServer?: ShutdownManagedServer; private closeServerPromise: Promise | null = null; @@ -56,6 +79,12 @@ export class GracefulShutdownService implements BeforeApplicationShutdown { return GracefulShutdownService.instance.runBackgroundTask(taskName, task); } + decrementActiveRequests(): void { + this.activeRequests = Math.max(0, this.activeRequests - 1); + } + + getActiveRequestCount(): number { + return this.activeRequests; registerHttpServer(server: Server): void { if (this.registeredHttpServer === server) { return; @@ -82,6 +111,50 @@ export class GracefulShutdownService implements BeforeApplicationShutdown { return this.activeBackgroundTasks; } + this.logger.log('Stopping acceptance of new requests'); + + await this.stopScheduledJobs(); + + await this.waitForInFlightRequests(); + + await this.stopBackgroundWorkers(); + + await this.closeDatabase(); + + await this.closeRedis(); + + const shutdownDuration = Date.now() - shutdownStartTime; + 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(); incrementActiveRequests(): void { if (this.isShuttingDown) { return; @@ -169,6 +242,7 @@ export class GracefulShutdownService implements BeforeApplicationShutdown { return; } + if (elapsed > this.maxDrainTimeout) { if (this.closeServerPromise) { return this.closeServerPromise; } @@ -262,6 +336,42 @@ export class GracefulShutdownService implements BeforeApplicationShutdown { this.logger.log( `Waiting for shutdown drain: ${this.activeRequests} active request(s), ${this.activeBackgroundTasks} active background task(s)`, ); + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + 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`, + ); + } await this.delay(this.drainPollIntervalMs); } } 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 33c12100a..9a612e8bf 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -187,6 +187,11 @@ The API supports URI-based versioning (\`/api/v1/...\` and \`/api/v2/...\`). : `Nestera decentralized savings & investment platform API.\n\n${rateLimitDescription}`, ) .setVersion(version) + .addBearerAuth() + .addApiKey( + { type: 'apiKey', name: 'X-API-Version', in: 'header' }, + 'api-version', + ) .setContact('Nestera', 'https://nestera.io', 'support@nestera.io') .setLicense('MIT', 'https://opensource.org/licenses/MIT') .addBearerAuth( @@ -214,6 +219,22 @@ The API supports URI-based versioning (\`/api/v1/...\` and \`/api/v2/...\`). }); } + // 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); // Redirect /api/docs → /api/v2/docs for convenience const expressApp = app.getHttpAdapter().getInstance(); expressApp.get( @@ -233,6 +254,17 @@ The API supports URI-based versioning (\`/api/v1/...\` and \`/api/v2/...\`). logger.log( `Swagger v1 docs (deprecated): http://localhost:${port}/api/v1/docs`, ); + logger.log(`Swagger v2 docs: http://localhost:${port}/api/v2/docs`); + + const gracefulShutdown = app.get(GracefulShutdownService); + + 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'); + }); const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT']; for (const signal of signals) { @@ -249,7 +281,17 @@ The API supports URI-based versioning (\`/api/v1/...\` and \`/api/v2/...\`). }); } - // 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 ba2dc4237..8d05ba879 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -37,6 +37,8 @@ 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' }) @ApiParam({ name: 'id', description: 'User UUID' }) @ApiResponse({ status: 200, description: 'KYC approved' }) @ApiResponse({ status: 400, description: 'Missing user ID' }) @@ -52,6 +54,11 @@ 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', + }) @ApiParam({ name: 'id', description: 'User UUID' }) @ApiResponse({ status: 200, description: 'KYC rejected' }) @ApiResponse({ status: 400, description: 'Missing user ID or rejection reason' }) @@ -69,6 +76,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', + }) @ApiOperation({ summary: 'Approve or reject KYC for a user (single endpoint)', description: 'Set `action` to `"approve"` or `"reject"`. Reason is required for rejection.', diff --git a/backend/src/modules/transactions/transactions.controller.ts b/backend/src/modules/transactions/transactions.controller.ts index a11367ff3..2b707541e 100644 --- a/backend/src/modules/transactions/transactions.controller.ts +++ b/backend/src/modules/transactions/transactions.controller.ts @@ -97,6 +97,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' }) @ApiOperation({ summary: 'Tag a transaction with a label or category' }) @ApiParam({ name: 'id', description: 'Transaction UUID' }) @ApiBody({ type: TagTransactionDto }) @@ -112,6 +115,10 @@ export class TransactionsController { } @Get('categories') + @ApiOperation({ + summary: 'List transaction categories for the authenticated user', + }) + @ApiResponse({ status: 200, description: 'List of categories' }) @ApiOperation({ summary: 'List all transaction categories used by the authenticated user' }) @ApiResponse({ status: 200, description: 'List of category strings' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @@ -120,6 +127,7 @@ export class TransactionsController { } @Post('tags/bulk') + @ApiOperation({ summary: 'Bulk-tag multiple transactions' }) @ApiOperation({ summary: 'Bulk tag multiple transactions at once', description: 'Apply tags/categories to a list of transaction IDs in a single request.', diff --git a/backend/src/modules/user/user.controller.ts b/backend/src/modules/user/user.controller.ts index 880589b99..7f042296c 100644 --- a/backend/src/modules/user/user.controller.ts +++ b/backend/src/modules/user/user.controller.ts @@ -125,6 +125,13 @@ export class UserController { @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, + }) 'Returns wallet balance, savings (flexible + locked), total, and percentage breakdown. ' + 'Requires a linked Stellar wallet; returns zero balances if no wallet is linked.', }) @@ -175,6 +182,8 @@ export class UserController { } @Get(':id') + @ApiOperation({ summary: 'Get a user by ID' }) + @ApiResponse({ status: 200, description: 'User found' }) @ApiOperation({ summary: 'Get a user by ID (admin / internal use)' }) @ApiParam({ name: 'id', description: 'User UUID' }) @ApiResponse({ status: 200, description: 'User record' }) @@ -186,6 +195,7 @@ export class UserController { @Patch('me') @ApiOperation({ summary: 'Update the authenticated user profile' }) + @ApiResponse({ status: 200, description: 'Profile updated' }) @ApiBody({ type: UpdateUserDto }) @ApiResponse({ status: 200, description: 'Profile updated' }) @ApiResponse({ status: 400, description: 'Validation error' }) @@ -203,6 +213,9 @@ export class UserController { } @Post('avatar') + @ApiOperation({ summary: 'Upload a profile avatar image' }) + @ApiResponse({ status: 201, description: 'Avatar uploaded' }) + @ApiResponse({ status: 400, description: 'Invalid file type or size' }) @ApiOperation({ summary: 'Upload a profile avatar image', description: 'Accepts JPEG, PNG, or WebP up to 5 MB.', @@ -235,6 +248,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' }) @ApiOperation({ summary: 'Upload a KYC document', description: 'Accepts PDF or JPEG up to 10 MB. Triggers KYC review process.',