diff --git a/.env.example b/.env.example index c861a1d..4e9676f 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,8 @@ PUSHER_APP_ID=your_app_id PUSHER_KEY=your_key PUSHER_SECRET=your_secret PUSHER_CLUSTER=your_cluster + +# Health Check Configuration +HEALTH_HEAP_USED_WARN_MB=300 +HEALTH_DISK_USED_WARN_PERCENT=85 +HEALTH_ALLOWLIST=127.0.0.1,10.0.0.0/24 diff --git a/README.md b/README.md index fd56818..5b1b983 100644 --- a/README.md +++ b/README.md @@ -318,6 +318,24 @@ All endpoints follow RESTful patterns and are versioned: } ``` +## 🏥 Health Checks + +### Endpoints +- `GET /api/v1/health/live` → Always returns `{ status: "up" }` if the app is alive +- `GET /api/v1/health/ready` → Runs DB, Redis, memory, and disk checks + +### Kubernetes Example +```yaml +livenessProbe: + httpGet: + path: /api/v1/health/live + port: 3000 +readinessProbe: + httpGet: + path: /api/v1/health/ready + port: 3000 +``` + ## 🔐 Authentication ### Authentication Flow diff --git a/env.example b/env.example new file mode 100644 index 0000000..05e9937 --- /dev/null +++ b/env.example @@ -0,0 +1,48 @@ +# Database Configuration +DATABASE_URL=postgresql://user:password@localhost:5432/starshop +DB_HOST=localhost +DB_PORT=5432 +DB_USERNAME=user +DB_PASSWORD=password +DB_DATABASE=starshop +DB_SSL=false + +# JWT Configuration +JWT_SECRET=your-super-secret-jwt-key-change-this-in-production +JWT_EXPIRATION_TIME=1h + +# AWS S3 Configuration (Optional - for file uploads) +AWS_ACCESS_KEY_ID=your_access_key +AWS_SECRET_ACCESS_KEY=your_secret_key +AWS_REGION=us-east-1 +AWS_S3_BUCKET=your_bucket_name + +# Cloudinary Configuration (Optional - for image uploads) +CLOUDINARY_CLOUD_NAME=your_cloud_name +CLOUDINARY_API_KEY=your_api_key +CLOUDINARY_API_SECRET=your_api_secret + +# Pusher Configuration (Optional - for real-time notifications) +PUSHER_APP_ID=your_app_id +PUSHER_KEY=your_key +PUSHER_SECRET=your_secret +PUSHER_CLUSTER=your_cluster + +# Supabase Configuration (Optional) +SUPABASE_URL=your_supabase_url +SUPABASE_SERVICE_ROLE_KEY=your_service_role_key + +# Redis Configuration (Optional) +REDIS_URL=redis://localhost:6379 + +# File Upload Configuration +FILE_UPLOAD_LIMIT=10 + +# Application Configuration +NODE_ENV=development +PORT=3000 + +# Health Check Configuration +HEALTH_HEAP_USED_WARN_MB=300 +HEALTH_DISK_USED_WARN_PERCENT=85 +HEALTH_ALLOWLIST=127.0.0.1,10.0.0.0/24 diff --git a/package.json b/package.json index 465760e..6326d7f 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@nestjs/platform-express": "^11.1.0", "@nestjs/schedule": "^6.0.0", "@nestjs/swagger": "^11.2.0", + "@nestjs/terminus": "^11.0.0", "@nestjs/typeorm": "^11.0.0", "@supabase/supabase-js": "^2.46.1", "@types/bcryptjs": "^2.4.6", @@ -58,6 +59,7 @@ "express-async-handler": "^1.2.0", "express-rate-limit": "^7.5.0", "express-validator": "^7.2.0", + "ip-range-check": "^0.3.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.2", "multer-s3": "^3.0.1", diff --git a/src/app.module.ts b/src/app.module.ts index c000a41..e1e6a11 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -18,6 +18,7 @@ import { BuyerRequestsModule } from './modules/buyer-requests/buyer-requests.mod import { OffersModule } from './modules/offers/offers.module'; import { SupabaseModule } from './modules/supabase/supabase.module'; import { StoresModule } from './modules/stores/stores.module'; +import { HealthModule } from './health/health.module'; // Entities import { User } from './modules/users/entities/user.entity'; @@ -85,6 +86,7 @@ import { Store } from './modules/stores/entities/store.entity'; OffersModule, SupabaseModule, StoresModule, + HealthModule, ], }) export class AppModule {} diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts new file mode 100644 index 0000000..36de7cf --- /dev/null +++ b/src/health/health.controller.ts @@ -0,0 +1,65 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator, MemoryHealthIndicator, DiskHealthIndicator } from '@nestjs/terminus'; +import { PrismaClient } from '@prisma/client'; +import { createClient } from 'redis'; +import { HealthAllowlistGuard } from '../middleware/healthAllowlist.guard'; + +@Controller('health') +@UseGuards(HealthAllowlistGuard) +export class HealthController { + private prisma = new PrismaClient(); + private redis = process.env.REDIS_URL ? createClient({ url: process.env.REDIS_URL }) : null; + + constructor( + private health: HealthCheckService, + private db: TypeOrmHealthIndicator, + private memory: MemoryHealthIndicator, + private disk: DiskHealthIndicator, + ) {} + + @Get('live') + @HealthCheck() + checkLive() { + return { status: 'up' }; + } + + @Get('ready') + @HealthCheck() + async checkReady() { + const checks = []; + + // Database check + checks.push(async () => this.db.pingCheck('database', { timeout: 300 })); + + // Redis check (if enabled) + if (this.redis) { + checks.push(async () => { + try { + await this.redis.connect(); + await this.redis.ping(); + await this.redis.disconnect(); + return { redis: { status: 'up' } }; + } catch (e) { + throw new Error('Redis unavailable'); + } + }); + } + + // Optional memory/disk checks + if (process.env.HEALTH_HEAP_USED_WARN_MB) { + checks.push(async () => this.memory.checkHeap( + 'memory_heap', + parseInt(process.env.HEALTH_HEAP_USED_WARN_MB, 10) * 1024 * 1024, + )); + } + + if (process.env.HEALTH_DISK_USED_WARN_PERCENT) { + checks.push(async () => this.disk.checkStorage('disk', { + path: '/', + thresholdPercent: parseInt(process.env.HEALTH_DISK_USED_WARN_PERCENT, 10) / 100, + })); + } + + return this.health.check(checks); + } +} diff --git a/src/health/health.module.ts b/src/health/health.module.ts new file mode 100644 index 0000000..0208ef7 --- /dev/null +++ b/src/health/health.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TerminusModule } from '@nestjs/terminus'; +import { HealthController } from './health.controller'; + +@Module({ + imports: [TerminusModule], + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/src/middleware/healthAllowlist.guard.ts b/src/middleware/healthAllowlist.guard.ts new file mode 100644 index 0000000..0b2da31 --- /dev/null +++ b/src/middleware/healthAllowlist.guard.ts @@ -0,0 +1,18 @@ +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; +import ipRangeCheck from 'ip-range-check'; + +@Injectable() +export class HealthAllowlistGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + + if (process.env.NODE_ENV === 'production' && process.env.HEALTH_ALLOWLIST) { + const allowlist = process.env.HEALTH_ALLOWLIST.split(','); + if (!ipRangeCheck(request.ip, allowlist)) { + throw new ForbiddenException('Forbidden'); + } + } + + return true; + } +}