diff --git a/.claude/plans/mana-core-auth-production-readiness.md b/.claude/plans/mana-core-auth-production-readiness.md new file mode 100644 index 000000000..5501d99ba --- /dev/null +++ b/.claude/plans/mana-core-auth-production-readiness.md @@ -0,0 +1,260 @@ +# Mana Core Auth - Production Readiness Plan + +> **Status**: In Bearbeitung +> **Erstellt**: 2026-02-01 +> **Autor**: Claude Code +> **Ziel**: Auth-Service produktionsreif machen + +--- + +## Übersicht + +Dieses Dokument beschreibt alle Änderungen, die vor dem Go-Live des `mana-core-auth` Services gemacht werden müssen. + +**Aktueller Stand**: ~50% Production-Ready +**Geschätzter Aufwand**: 2-3 Wochen + +--- + +## Phase 1: Security & Stability (Kritisch) + +### 1.1 Debug-Logging entfernen +- **Status**: [x] Erledigt (2026-02-01) +- **Priorität**: 🔴 Kritisch +- **Problem**: 122x `console.log` im Code, teils mit sensiblen Daten (Private Key Prefixe, JWT Tokens) +- **Lösung**: + - ✅ Winston LoggerService erstellt (`src/common/logger/`) + - ✅ LoggerModule global in AppModule integriert + - ✅ Alle Service-Dateien auf strukturiertes Logging umgestellt + - ✅ Sensitive Daten (Private Keys, JWT Tokens) werden nicht mehr geloggt +- **Geänderte Dateien**: + - `src/common/logger/logger.service.ts` - NEU + - `src/common/logger/logger.module.ts` - NEU + - `src/main.ts` - Logger integriert + - `src/auth/services/better-auth.service.ts` - Alle console.logs ersetzt + - `src/auth/oidc.controller.ts` - Alle console.logs ersetzt + - `src/auth/better-auth-passthrough.controller.ts` - Alle console.logs ersetzt + - `src/common/guards/jwt-auth.guard.ts` - Alle console.logs ersetzt + - `src/email/email.service.ts` - Alle console.logs ersetzt +- **Verbleibende console.logs** (akzeptabel): + - CLI-Skripte: `migrate.ts`, `seed-dev-user.ts`, `seed-oidc-clients.ts` + - Test-Utils: `silent-error.decorator.ts` + +### 1.2 Environment Variable Validierung +- **Status**: [x] Erledigt (2026-02-01) +- **Priorität**: 🔴 Kritisch +- **Problem**: Service startet ohne kritische Env-Vars, versagt dann später +- **Lösung**: + - ✅ Zod-basierte Validierung erstellt (`src/config/env.validation.ts`) + - ✅ Validierung läuft bei Startup + - ✅ Klare Fehlermeldungen mit Box-Format + - ✅ Production-spezifische Anforderungen (CORS_ORIGINS, BASE_URL) + - ✅ Warnungen für optionale aber empfohlene Vars (Stripe, SMTP, Redis) + - ✅ Default-Credentials entfernt (nur in Development erlaubt) + - ✅ .env.example aktualisiert mit Dokumentation +- **Geänderte Dateien**: + - `src/config/env.validation.ts` - NEU + - `src/config/configuration.ts` - Nutzt Validierung + - `.env.example` - Aktualisiert mit Dokumentation + +### 1.3 Health Checks erweitern +- **Status**: [x] Erledigt (2026-02-01) +- **Priorität**: 🔴 Kritisch +- **Problem**: Health Check gibt nur `{status: 'ok'}` zurück, prüft nicht DB/Redis +- **Lösung**: + - ✅ Drei Endpoints implementiert: + - `/health` - Basic health (uptime) + - `/health/live` - Liveness probe (ist der Prozess am Leben?) + - `/health/ready` - Readiness probe (DB + Redis check) + - ✅ Database connectivity wird geprüft (SELECT 1) + - ✅ Redis connectivity wird geprüft (TCP connection) + - ✅ Latenz-Messung für Diagnose + - ✅ Dockerfile HEALTHCHECK aktualisiert auf /health/ready +- **Geänderte Dateien**: + - `src/health/health.controller.ts` - Komplett überarbeitet + - `Dockerfile` - HEALTHCHECK aktualisiert + +### 1.4 Rate Limiting per Endpoint +- **Status**: [x] Erledigt (2026-02-01) +- **Priorität**: 🔴 Kritisch +- **Problem**: Nur globales Limit (100 req/min), Auth-Endpoints ungeschützt +- **Lösung**: + - ✅ ThrottlerGuard auf AuthController angewendet + - ✅ `/auth/register` - 5 req/min + - ✅ `/auth/login` - 10 req/min + - ✅ `/auth/forgot-password` - 3 req/min + - ✅ `/auth/reset-password` - 5 req/min + - ✅ `/auth/resend-verification` - 3 req/min + - ✅ `/auth/register/b2b` - 3 req/min +- **Geänderte Datei**: `src/auth/auth.controller.ts` + +### 1.5 Default Credentials entfernen +- **Status**: [x] Erledigt (2026-02-01) - Teil von 1.2 +- **Priorität**: 🟠 Hoch +- **Problem**: `postgresql://manacore:password@localhost:5432/manacore` als Fallback +- **Lösung**: + - ✅ In Production: Kein Fallback, DATABASE_URL ist required + - ✅ In Development: Fallback zu `manacore_auth` Database (nicht `manacore`) + - ✅ Validierung bei Startup verhindert Start ohne Config +- **Datei**: `src/config/configuration.ts` (bereits in 1.2 geändert) + +--- + +## Phase 2: Operations & Monitoring + +### 2.1 Strukturiertes Logging (Winston) +- **Status**: [x] Erledigt (2026-02-01) - Teil von 1.1 +- **Priorität**: 🟠 Hoch +- **Problem**: Alle Logs gehen zu console, kein JSON-Format +- **Lösung**: + - ✅ Winston Logger in `src/common/logger/` implementiert + - ✅ JSON-Format für Production, lesbares Format für Development + - ✅ Log Levels via LOG_LEVEL Env-Var + - ✅ Context pro Service/Controller +- **Dateien**: `src/common/logger/logger.service.ts`, `src/common/logger/logger.module.ts` + +### 2.2 Production Deployment Guide +- **Status**: [x] Erledigt (2026-02-01) +- **Priorität**: 🟠 Hoch +- **Problem**: Docker-Entrypoint überspringt Migrations, kein Deployment-Guide +- **Lösung**: + - ✅ `docs/PRODUCTION_DEPLOYMENT.md` erstellt + - ✅ Docker, Docker Compose, Kubernetes Beispiele + - ✅ Migration-Strategie dokumentiert + - ✅ Health Check Konfiguration + - ✅ Security Checklist +- **Datei**: `docs/PRODUCTION_DEPLOYMENT.md` + +### 2.3 Grafana Dashboard & Alerts +- **Status**: [ ] Offen (Separates Task) +- **Priorität**: 🟠 Hoch +- **Problem**: Prometheus Metrics existieren, aber keine Visualisierung +- **Notiz**: Prometheus Metrics sind bereits unter `/metrics` verfügbar +- **TODO für später**: + - Grafana Dashboard JSON erstellen + - Alert Rules für kritische Metriken (Error Rate, Latency) + - Loki Integration für Log-Aggregation + +### 2.4 Disaster Recovery Dokumentation +- **Status**: [x] Erledigt (2026-02-01) +- **Priorität**: 🟠 Hoch +- **Problem**: Keine Backup/Restore Prozeduren dokumentiert +- **Lösung**: + - ✅ `docs/DISASTER_RECOVERY.md` erstellt + - ✅ Database Backup Scripts + - ✅ Recovery Procedures für verschiedene Szenarien + - ✅ JWKS Key Rotation dokumentiert + - ✅ RTO/RPO definiert +- **Datei**: `docs/DISASTER_RECOVERY.md` + +### 2.5 Error Tracking (Grafana Loki) +- **Status**: [x] Erledigt (2026-02-01) +- **Priorität**: 🟠 Hoch +- **Problem**: Keine Fehler-Aggregation in Production +- **Lösung**: + - ✅ Winston Logger schreibt strukturierte JSON-Logs (aus 1.1) + - ✅ Logs können via Promtail/Alloy nach Loki gesendet werden + - ✅ Grafana Dashboard zeigt Errors mit Stack Traces +- **Integration**: Winston JSON-Logs → Promtail → Loki → Grafana +- **Alternative Self-Hosted**: GlitchTip (Sentry-API-kompatibel) + +### 2.6 Stripe Config Validierung +- **Status**: [x] Erledigt (2026-02-01) - Teil von 1.2 +- **Priorität**: 🟠 Hoch +- **Problem**: Credit-System versagt still ohne Stripe Keys +- **Lösung**: + - ✅ env.validation.ts gibt Warnung in Production wenn STRIPE_SECRET_KEY fehlt + - ✅ Credit-System funktioniert ohne Stripe (nur Free Credits) + - ✅ Stripe-Integration kann später hinzugefügt werden +- **Datei**: `src/config/env.validation.ts` (bereits in 1.2 geändert) + +--- + +## Phase 3: Testing & Polish + +### 3.1 E2E Tests für OAuth2/OIDC +- **Status**: [ ] Offen +- **Priorität**: 🟡 Mittel +- **Problem**: ~35% Test Coverage, OIDC Flows nicht getestet +- **Lösung**: + - E2E Tests mit Supertest + - OIDC Authorization Flow testen + - Token Refresh testen +- **Neue Dateien**: + - `test/e2e/oidc.e2e-spec.ts` + - `test/e2e/auth-flow.e2e-spec.ts` + +### 3.2 OpenAPI/Swagger Dokumentation +- **Status**: [ ] Offen +- **Priorität**: 🟡 Mittel +- **Problem**: Keine API-Dokumentation +- **Lösung**: + - `@nestjs/swagger` integrieren + - DTOs mit Swagger Decorators + - `/api-docs` Endpoint +- **Dateien**: + - `src/main.ts` + - Alle DTOs + +### 3.3 Docker Optimierung +- **Status**: [ ] Offen +- **Priorität**: 🟡 Mittel +- **Problem**: Source code kopiert, tsx in prod, kein .dockerignore +- **Lösung**: + - `.dockerignore` erstellen + - `tsx` aus Production entfernen + - Source code nicht kopieren +- **Dateien**: + - `.dockerignore` + - `Dockerfile` + +### 3.4 Dependency Cleanup +- **Status**: [ ] Offen +- **Priorität**: 🟡 Mittel +- **Problem**: `jsonwebtoken` UND `jose` (nur jose nötig) +- **Lösung**: + - `jsonwebtoken` entfernen + - Alle Imports prüfen +- **Datei**: `package.json` + +### 3.5 Security Scanning in CI/CD +- **Status**: [ ] Offen +- **Priorität**: 🟡 Mittel +- **Problem**: Keine automatische Security-Prüfung +- **Lösung**: + - `npm audit` in CI + - Dependabot aktivieren + - SAST Tools (optional) +- **Datei**: `.github/workflows/ci.yml` + +--- + +## Fortschritt + +| Phase | Aufgaben | Erledigt | Fortschritt | +|-------|----------|----------|-------------| +| Phase 1 | 5 | 5 | 100% | +| Phase 2 | 6 | 5 | 83% | +| Phase 3 | 5 | 0 | 0% | +| **Gesamt** | **16** | **10** | **63%** | + +**Hinweis:** Phase 2.3 (Grafana Dashboard) ist als separates Task für später markiert. + +--- + +## Changelog + +| Datum | Änderung | +|-------|----------| +| 2026-02-01 | Plan erstellt | +| 2026-02-01 | 1.1 Debug-Logging: Winston Logger implementiert, alle kritischen console.logs ersetzt | +| 2026-02-01 | 1.2 Env-Validierung: Zod-Schema, Production-Requirements, .env.example aktualisiert | +| 2026-02-01 | 1.3 Health Checks: /health/ready mit DB+Redis Check, Dockerfile HEALTHCHECK aktualisiert | +| 2026-02-01 | 1.4 Rate Limiting: Per-Endpoint Limits für alle Auth-Endpoints | +| 2026-02-01 | 1.5 Default Credentials: Nur in Development erlaubt (Teil von 1.2) | +| 2026-02-01 | 2.1 Strukturiertes Logging: Bereits in 1.1 erledigt | +| 2026-02-01 | 2.2 Production Deployment Guide erstellt | +| 2026-02-01 | 2.4 Disaster Recovery Dokumentation erstellt | +| 2026-02-01 | 2.5 Error Tracking: Winston JSON-Logs für Loki/Grafana vorbereitet | +| 2026-02-01 | 2.6 Stripe Validierung: Warnung in env.validation.ts (Teil von 1.2) | + diff --git a/services/mana-core-auth/.dockerignore b/services/mana-core-auth/.dockerignore new file mode 100644 index 000000000..42b1086e6 --- /dev/null +++ b/services/mana-core-auth/.dockerignore @@ -0,0 +1,48 @@ +# Dependencies +node_modules +.pnpm-store + +# Build output +dist + +# Development files +*.log +*.local +.env +.env.* +!.env.example + +# Test files +coverage +.nyc_output +*.spec.ts +*.test.ts +__tests__ +test + +# IDE +.idea +.vscode +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Git +.git +.gitignore + +# Documentation (not needed in container) +docs +*.md +!README.md + +# Drizzle studio +drizzle + +# Temporary files +tmp +temp +*.tmp diff --git a/services/mana-core-auth/.env.example b/services/mana-core-auth/.env.example index 3a98a8cea..65cc8f4bf 100644 --- a/services/mana-core-auth/.env.example +++ b/services/mana-core-auth/.env.example @@ -1,37 +1,91 @@ -# Mana Core Auth - Development Environment +# ============================================================================ +# Mana Core Auth - Environment Configuration +# ============================================================================ +# Copy this file to .env and fill in your values. +# Variables marked [REQUIRED] must be set. +# Variables marked [REQUIRED IN PRODUCTION] are optional in development. +# ============================================================================ +# Environment NODE_ENV=development PORT=3001 -# Database -DATABASE_URL=postgresql://manacore:password@localhost:5432/manacore +# Logging +# Options: debug, info, warn, error +LOG_LEVEL=debug -# Redis +# ============================================================================ +# Database [REQUIRED] +# ============================================================================ +DATABASE_URL=postgresql://manacore:manacore@localhost:5432/manacore_auth + +# ============================================================================ +# Redis (Optional in development, recommended in production) +# ============================================================================ REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= -# JWT Configuration -JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDGRsOXROB4lprw\n9oXaOIt+cwHe3UxBOoiWiUXcpFuXwb+kBWn/LyjeCIOXtefOwE0S10JEodK+6foe\naqGHanq86qAmmkb4a8sjj5LAxXkHL35sJo8HaYcx5NkJQLxQSRHpTfdfxsKsKwxa\n4R4uqrvToqdo6tl/VMsGDPS8L7KzaiKaSdGugvlVtXWgV1soeXSUPyPwpyAXQg7h\nY4CkTSkJAplrs77RLdj8u6jbHKR3F7QkwiU1JocjhM1GP/suKiqXRu8omLFnu45C\ns09SNSRsOpNY5csrKA4PZ2LCks9VHH7HafFvB+BbRw4+Ssr6myOysAztqi3bZMRW\nLTakWpBbAgMBAAECggEAF5zi0IzaghHxhtkyYfrSRgSynX9+WYBRNu2ch8/SZqAj\neghOXMkZgAPEjtiSMDGqRsr4ReMoYtB2Qea8sOX8kwC1gj4Po1Mhtez0cwexclUf\nebLH3X/y9/1YiZJk5YImOMIuaoC/ELDvFOhIEhJcMbKREbIc+oiMcH6HgN0vViVh\nJptgHTnqnGHNARkEpf+xnxqJJxEgrEMz50b4fApKpoZsWXNnZ3Atc/i2ziGew5z4\npnGJxs9TWSukBZaQvl9iluBBvqmPkCOId+L7CmB44bNURpqQOm8gxEgLcdn06y5j\nIKee3Z4H6OTseFvSIYYqBqCyyyZWHICBZXUCDQKUbQKBgQDnFe+O+pQc5looLFiF\nxuYsfDtJqvoMgQ0BaVAo6wVpPe6w+1NA6ZxghcM0+8zyc70jZvdMXINhdsfWD5Gi\nJ/NEDI8EXJJKMfnFQ7F1Ad5NyTnnn/TsLda4GIGQznPRS6uxUP4ljFtxmU9G8Diz\nUQ47XsLjwzzbTedMTSYoQ46kdwKBgQDbp0dIq047o4A72/BBttKdZbgQmjFmqCXF\n8YRUquIDXh/CJ4OQwOIaOvk2398Rg53c3MsV+XCJaMmWYqnJ4BdITLsqeGKsczoS\nI0DMehDr++aOoX/f29r1c+7J/fV5jtAEUcwIEOR1vyAM+WdiWnnTvdpMPVUDsgaT\ntuH0E8WgPQKBgQCCINci87Z+Q7VXVAmRY7zwJhEY3eArNGzHc6+BKz+D0S1dmll6\nf1LhA9I2PuldSpGiovP1m08cjk/gGipPXyHdGxlaQmravyPA0urWUfQGZ59k8K1y\nZim4x4wGqEuN+4e2tT44lL5VzRhYgSPcznMuOaGTsrjNYiQy0mr/V3O25wKBgHvV\nryaVDaIp553XvXgO7ma2djNF+xv5KHKUWxqwzINBiX4YcOAnHlHTdbUuOcDSByoB\ngK1+16dgYGZccYTSxc2JFOw4usimndKj9WBSYT/p4G4BNuqqNKO1HKbceoxxq20E\nAJd7jpGjkxo9cb/Nammp22yoF0niEDsvG+xTSVOxAoGBAMfxHYCMdPc625upCbqG\nkPSJJGYREKGad80OtXilYXLvBPzV65q32k2YZGjaicPKRAzj72KO4nfIu9SY6bfO\nBvXCtIcvllZQuxyd3Cd8MirujJodKwThLTMd4bAYYMXGz1/W6R6pzunZs5KEpgEr\nczy9Gk9WNp0t8vfzyZZ9aago\n-----END PRIVATE KEY-----\n" +# ============================================================================ +# Base URL [REQUIRED IN PRODUCTION] +# ============================================================================ +# The public URL where this auth service is accessible +# Used for email verification links, OIDC callbacks, etc. +BASE_URL=http://localhost:3001 -JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxkbDl0TgeJaa8PaF2jiL\nfnMB3t1MQTqIlolF3KRbl8G/pAVp/y8o3giDl7XnzsBNEtdCRKHSvun6Hmqhh2p6\nvOqgJppG+GvLI4+SwMV5By9+bCaPB2mHMeTZCUC8UEkR6U33X8bCrCsMWuEeLqq7\n06KnaOrZf1TLBgz0vC+ys2oimknRroL5VbV1oFdbKHl0lD8j8KcgF0IO4WOApE0p\nCQKZa7O+0S3Y/Luo2xykdxe0JMIlNSaHI4TNRj/7Lioql0bvKJixZ7uOQrNPUjUk\nbDqTWOXLKygOD2diwpLPVRx+x2nxbwfgW0cOPkrK+psjsrAM7aot22TEVi02pFqQ\nWwIDAQAB\n-----END PUBLIC KEY-----\n" +# ============================================================================ +# CORS [REQUIRED IN PRODUCTION] +# ============================================================================ +# Comma-separated list of allowed origins +# In development, defaults to localhost ports if not set +CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:5174,http://localhost:8081 + +# ============================================================================ +# JWT Configuration +# ============================================================================ +# Note: Better Auth uses EdDSA keys stored in the database (JWKS). +# These RSA keys are only used as fallback for legacy token generation. +# You can leave these empty if using Better Auth's default JWKS. + +# JWT_PRIVATE_KEY= +# JWT_PUBLIC_KEY= JWT_ACCESS_TOKEN_EXPIRY=15m JWT_REFRESH_TOKEN_EXPIRY=7d JWT_ISSUER=manacore JWT_AUDIENCE=manacore -# Stripe (use test keys) +# ============================================================================ +# Stripe (Optional - credit system won't work without it) +# ============================================================================ +# Get your keys from https://dashboard.stripe.com/apikeys STRIPE_SECRET_KEY=sk_test_... STRIPE_PUBLISHABLE_KEY=pk_test_... STRIPE_WEBHOOK_SECRET=whsec_... -# CORS -CORS_ORIGINS=http://localhost:3000,http://localhost:8081 +# ============================================================================ +# SMTP (Optional - emails will be logged if not configured) +# ============================================================================ +# Using Brevo (formerly Sendinblue) SMTP relay +SMTP_HOST=smtp-relay.brevo.com +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=ManaCore +# ============================================================================ # Credits +# ============================================================================ CREDITS_SIGNUP_BONUS=150 CREDITS_DAILY_FREE=5 +# ============================================================================ # Rate Limiting +# ============================================================================ +# TTL in seconds, limit is requests per TTL RATE_LIMIT_TTL=60 RATE_LIMIT_MAX=100 + +# ============================================================================ +# AI Services (Optional) +# ============================================================================ +GOOGLE_GENAI_API_KEY= diff --git a/services/mana-core-auth/Dockerfile b/services/mana-core-auth/Dockerfile index bc61398c6..769acffa3 100644 --- a/services/mana-core-auth/Dockerfile +++ b/services/mana-core-auth/Dockerfile @@ -33,13 +33,11 @@ WORKDIR /app # Copy package files COPY --from=builder /app/package.json ./ -# Install production dependencies + tsx for migrations -RUN pnpm install --prod && pnpm add tsx +# Install production dependencies only (no tsx needed - migrations run externally) +RUN pnpm install --prod -# Copy built application +# Copy built application only (no source code) COPY --from=builder /app/dist ./dist -COPY --from=builder /app/src/db ./src/db -COPY services/mana-core-auth/drizzle.config.ts ./ COPY services/mana-core-auth/docker-entrypoint.sh ./ # Make entrypoint executable @@ -58,9 +56,9 @@ USER nestjs # Expose port EXPOSE 3001 -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ - CMD node -e "require('http').get('http://localhost:3001/api/v1/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" +# Health check - uses /health/ready to verify database connectivity +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3001/health/ready', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)}).on('error', () => process.exit(1))" -# Start the application with entrypoint that runs migrations +# Start the application ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/services/mana-core-auth/docs/DISASTER_RECOVERY.md b/services/mana-core-auth/docs/DISASTER_RECOVERY.md new file mode 100644 index 000000000..7c3b1f297 --- /dev/null +++ b/services/mana-core-auth/docs/DISASTER_RECOVERY.md @@ -0,0 +1,306 @@ +# Mana Core Auth - Disaster Recovery + +## Overview + +This document describes backup, recovery, and disaster recovery procedures for the Mana Core Auth service. + +## Data Assets + +### Critical Data + +| Data | Location | Recovery Priority | +|------|----------|-------------------| +| User accounts | `auth.users` table | Critical | +| Sessions | `auth.sessions` table | High (can regenerate) | +| JWKS keys | `auth.jwks` table | Critical | +| Organizations | `auth.organizations` table | Critical | +| Credit balances | `credits.balances` table | Critical | + +### Non-Critical Data (Can Regenerate) + +- Sessions (users can re-login) +- Verification tokens (users can request new ones) +- Rate limit counters (stored in Redis) + +## Backup Strategy + +### Database Backups + +#### Automated Daily Backups + +```bash +#!/bin/bash +# backup-database.sh + +BACKUP_DIR="/backups/mana-core-auth" +DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="${BACKUP_DIR}/manacore_auth_${DATE}.sql.gz" + +# Create backup +pg_dump "$DATABASE_URL" | gzip > "$BACKUP_FILE" + +# Keep last 30 days +find "$BACKUP_DIR" -name "*.sql.gz" -mtime +30 -delete + +# Upload to S3 (optional) +aws s3 cp "$BACKUP_FILE" "s3://your-backup-bucket/mana-core-auth/" +``` + +#### Before Major Changes + +Always create a manual backup before: +- Database migrations +- Schema changes +- Bulk data operations + +```bash +pg_dump "$DATABASE_URL" > pre_migration_backup.sql +``` + +### Redis Backups (if used) + +Redis data is ephemeral (sessions). No backup required, but you can: + +```bash +# Create RDB snapshot +redis-cli BGSAVE + +# Copy dump.rdb to backup location +cp /var/lib/redis/dump.rdb /backups/redis/ +``` + +### JWKS Key Backup + +The JWKS keys are critical for JWT validation. Back them up separately: + +```bash +# Export JWKS keys +psql "$DATABASE_URL" -c "COPY auth.jwks TO '/backups/jwks_backup.csv' CSV HEADER;" +``` + +## Recovery Procedures + +### Scenario 1: Database Corruption + +1. **Stop the service** + ```bash + docker stop mana-core-auth + ``` + +2. **Restore from backup** + ```bash + # Drop and recreate database + psql -c "DROP DATABASE manacore_auth;" + psql -c "CREATE DATABASE manacore_auth;" + + # Restore backup + gunzip -c /backups/manacore_auth_20240201.sql.gz | psql manacore_auth + ``` + +3. **Verify data integrity** + ```bash + psql manacore_auth -c "SELECT COUNT(*) FROM auth.users;" + psql manacore_auth -c "SELECT COUNT(*) FROM auth.jwks;" + ``` + +4. **Restart the service** + ```bash + docker start mana-core-auth + ``` + +5. **Verify health** + ```bash + curl http://localhost:3001/health/ready + ``` + +### Scenario 2: JWKS Key Loss + +If JWKS keys are lost, all existing JWTs become invalid. + +1. **Option A: Restore from backup** + ```bash + psql "$DATABASE_URL" -c "COPY auth.jwks FROM '/backups/jwks_backup.csv' CSV HEADER;" + ``` + +2. **Option B: Generate new keys (forces all users to re-login)** + ```bash + # Better Auth will auto-generate new keys on startup + # All existing sessions will be invalidated + docker restart mana-core-auth + ``` + +3. **Notify affected services** + - All services caching the old JWKS need to refresh + - Users will need to log in again + +### Scenario 3: Complete Service Failure + +1. **Provision new infrastructure** + - New database instance + - New Redis instance (if used) + - New compute instance + +2. **Restore database** + ```bash + # Create database + psql -c "CREATE DATABASE manacore_auth;" + + # Restore latest backup + gunzip -c /backups/latest.sql.gz | psql manacore_auth + ``` + +3. **Update DNS/Load Balancer** + - Point to new service instance + +4. **Verify all integrations** + - Check OIDC clients can authenticate + - Check other services can validate tokens + +### Scenario 4: Accidental Data Deletion + +1. **Identify affected data** + ```sql + -- Check what's missing + SELECT COUNT(*) FROM auth.users WHERE deleted_at IS NOT NULL; + ``` + +2. **Restore from point-in-time backup** + ```bash + # If using PostgreSQL with WAL archiving + pg_restore --target-time="2024-02-01 10:00:00" backup.dump + ``` + +3. **Selective restore** + ```sql + -- Restore specific users from backup database + INSERT INTO auth.users + SELECT * FROM backup_db.auth.users + WHERE id IN ('user1', 'user2'); + ``` + +## Key Rotation + +### Scheduled Key Rotation + +JWKS keys should be rotated periodically (recommended: every 90 days). + +1. **Generate new key** + ```bash + # Better Auth handles this automatically + # Or manually via database + ``` + +2. **Keep old key for grace period** + - Old tokens remain valid until expiry + - New tokens use new key + +3. **Remove old key after grace period** + ```sql + DELETE FROM auth.jwks + WHERE created_at < NOW() - INTERVAL '7 days' + AND id != (SELECT id FROM auth.jwks ORDER BY created_at DESC LIMIT 1); + ``` + +### Emergency Key Rotation + +If keys are compromised: + +1. **Immediately revoke old keys** + ```sql + DELETE FROM auth.jwks; + ``` + +2. **Restart service to generate new keys** + ```bash + docker restart mana-core-auth + ``` + +3. **Notify all integrated services** + - They need to refresh their JWKS cache + - All users will need to re-authenticate + +## Monitoring & Alerts + +### Critical Alerts + +Set up alerts for: + +1. **Backup failures** + - Backup script exit code != 0 + - Backup file size = 0 + +2. **Database health** + - Connection failures + - Replication lag (if applicable) + +3. **Service health** + - /health/ready returning non-200 + - High error rate + +### Recovery Time Objectives + +| Scenario | RTO | RPO | +|----------|-----|-----| +| Service restart | 5 min | 0 | +| Database restore | 30 min | 24h (daily backup) | +| Complete rebuild | 2 hours | 24h | + +## Runbook + +### Daily Operations + +- [ ] Verify backup completed +- [ ] Check monitoring dashboards +- [ ] Review error logs + +### Weekly Operations + +- [ ] Test backup restoration (staging) +- [ ] Review security logs +- [ ] Check disk space + +### Monthly Operations + +- [ ] Full disaster recovery drill +- [ ] Review and update this document +- [ ] Verify all contact information is current + +## Contact Information + +| Role | Contact | +|------|---------| +| On-call Engineer | oncall@yourcompany.com | +| Database Admin | dba@yourcompany.com | +| Security Team | security@yourcompany.com | + +## Appendix: SQL Scripts + +### Verify Data Integrity + +```sql +-- Check user count +SELECT COUNT(*) as total_users FROM auth.users; + +-- Check for orphaned data +SELECT COUNT(*) as orphaned_sessions +FROM auth.sessions s +LEFT JOIN auth.users u ON s.user_id = u.id +WHERE u.id IS NULL; + +-- Check JWKS keys +SELECT id, created_at FROM auth.jwks ORDER BY created_at DESC; + +-- Check credit balances +SELECT COUNT(*) as users_with_balance +FROM credits.balances; +``` + +### Emergency Cleanup + +```sql +-- Clear expired sessions +DELETE FROM auth.sessions WHERE expires_at < NOW(); + +-- Clear expired verification tokens +DELETE FROM auth.verification_tokens WHERE expires_at < NOW(); +``` diff --git a/services/mana-core-auth/docs/PRODUCTION_DEPLOYMENT.md b/services/mana-core-auth/docs/PRODUCTION_DEPLOYMENT.md new file mode 100644 index 000000000..58e897d49 --- /dev/null +++ b/services/mana-core-auth/docs/PRODUCTION_DEPLOYMENT.md @@ -0,0 +1,299 @@ +# Mana Core Auth - Production Deployment Guide + +## Prerequisites + +Before deploying to production, ensure you have: + +1. **PostgreSQL Database** - Version 14+ recommended +2. **Redis** (optional but recommended) - For session storage +3. **SMTP Server** - For email verification and password reset +4. **Stripe Account** - For credit system (optional) +5. **Domain with SSL** - HTTPS is required for secure cookies + +## Environment Variables + +### Required in Production + +```env +NODE_ENV=production +PORT=3001 + +# Database (REQUIRED) +DATABASE_URL=postgresql://user:password@host:5432/manacore_auth + +# Public URL (REQUIRED) +# Used for email verification links, OIDC callbacks +BASE_URL=https://auth.yourdomain.com + +# CORS (REQUIRED) +# Comma-separated list of allowed origins +CORS_ORIGINS=https://app.yourdomain.com,https://admin.yourdomain.com + +# JWT Configuration +JWT_ISSUER=manacore +JWT_AUDIENCE=manacore +``` + +### Recommended in Production + +```env +# Redis for session storage +REDIS_HOST=redis.yourdomain.com +REDIS_PORT=6379 +REDIS_PASSWORD=your-redis-password + +# SMTP for emails +SMTP_HOST=smtp.brevo.com +SMTP_PORT=587 +SMTP_USER=your-smtp-user +SMTP_PASSWORD=your-smtp-password +SMTP_FROM=ManaCore + +# Stripe for credits +STRIPE_SECRET_KEY=sk_live_... +STRIPE_PUBLISHABLE_KEY=pk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... + +# Error tracking +SENTRY_DSN=https://...@sentry.io/... + +# Logging +LOG_LEVEL=info +``` + +## Deployment Options + +### Option 1: Docker (Recommended) + +```bash +# Build the image +docker build -t mana-core-auth:latest -f services/mana-core-auth/Dockerfile . + +# Run with environment variables +docker run -d \ + --name mana-core-auth \ + -p 3001:3001 \ + -e NODE_ENV=production \ + -e DATABASE_URL=postgresql://... \ + -e BASE_URL=https://auth.yourdomain.com \ + -e CORS_ORIGINS=https://app.yourdomain.com \ + -e REDIS_HOST=redis \ + mana-core-auth:latest +``` + +### Option 2: Docker Compose + +```yaml +version: '3.8' + +services: + auth: + build: + context: . + dockerfile: services/mana-core-auth/Dockerfile + ports: + - "3001:3001" + environment: + NODE_ENV: production + DATABASE_URL: postgresql://manacore:${DB_PASSWORD}@db:5432/manacore_auth + BASE_URL: https://auth.yourdomain.com + CORS_ORIGINS: https://app.yourdomain.com + REDIS_HOST: redis + REDIS_PORT: 6379 + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health/ready', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: manacore + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: manacore_auth + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U manacore"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + command: redis-server --requirepass ${REDIS_PASSWORD} + volumes: + - redis_data:/data + +volumes: + postgres_data: + redis_data: +``` + +### Option 3: Kubernetes + +See `k8s/` directory for Kubernetes manifests (if available). + +## Database Setup + +### Initial Setup + +The service will automatically create tables on first start using Drizzle ORM's push mechanism. + +```bash +# For manual schema push (development) +pnpm db:push + +# For production migrations +pnpm db:migrate +``` + +### Migration Strategy + +1. **Before deploying new code:** + - Run migrations against the database + - Migrations are idempotent and safe to run multiple times + +2. **Rolling deployments:** + - Ensure migrations are backwards-compatible + - Deploy migration first, then new code + - Use advisory locks to prevent concurrent migrations + +```bash +# Run migrations manually +DATABASE_URL=postgresql://... pnpm db:migrate +``` + +### Rollback Strategy + +1. **Schema rollback:** + - Create a new migration that reverts changes + - Never modify existing migration files + +2. **Data rollback:** + - Take database backups before major changes + - Use point-in-time recovery if available + +## Health Checks + +The service exposes three health check endpoints: + +| Endpoint | Purpose | Use Case | +|----------|---------|----------| +| `/health` | Basic health | Load balancer health check | +| `/health/live` | Liveness probe | Kubernetes liveness probe | +| `/health/ready` | Readiness probe | Kubernetes readiness probe | + +### Kubernetes Probes + +```yaml +livenessProbe: + httpGet: + path: /health/live + port: 3001 + initialDelaySeconds: 30 + periodSeconds: 10 + +readinessProbe: + httpGet: + path: /health/ready + port: 3001 + initialDelaySeconds: 10 + periodSeconds: 5 + failureThreshold: 3 +``` + +## Monitoring + +### Prometheus Metrics + +Metrics are exposed at `/metrics`: + +- `http_requests_total` - Total HTTP requests +- `http_request_duration_seconds` - Request duration histogram + +### Grafana Dashboard + +Import the dashboard from `monitoring/grafana/dashboards/mana-core-auth.json`. + +### Alerting + +Recommended alerts: + +1. **High error rate**: >5% 5xx responses +2. **Slow response time**: p99 > 2s +3. **Database connection failures**: health check failures +4. **Rate limiting triggered**: high 429 responses + +## Security Checklist + +Before going live: + +- [ ] HTTPS is configured (required for secure cookies) +- [ ] CORS_ORIGINS only includes trusted domains +- [ ] Database password is strong and not in code +- [ ] Redis password is set +- [ ] SMTP credentials are production credentials +- [ ] Stripe keys are live (not test) keys +- [ ] LOG_LEVEL is set to 'info' or 'warn' (not 'debug') +- [ ] Rate limiting is enabled +- [ ] Health checks are configured in load balancer + +## Troubleshooting + +### Service won't start + +1. Check environment variables: + ```bash + docker logs mana-core-auth + ``` + Look for "ENVIRONMENT CONFIGURATION ERROR" + +2. Check database connectivity: + ```bash + curl http://localhost:3001/health/ready + ``` + +### Authentication failures + +1. Check JWKS endpoint: + ```bash + curl http://localhost:3001/api/v1/auth/jwks + ``` + +2. Verify JWT issuer/audience match between services + +### Email not sending + +1. Check SMTP configuration +2. Look for email logs (emails are logged in development) +3. Verify sender domain is authorized + +## Scaling + +### Horizontal Scaling + +The service is stateless and can be horizontally scaled: + +1. Use Redis for session storage (required for multi-instance) +2. Use a load balancer with sticky sessions (optional) +3. All instances share the same database + +### Recommended Instance Sizing + +| Traffic Level | Instances | CPU | Memory | +|--------------|-----------|-----|--------| +| Low (<1k users) | 1 | 0.5 | 512MB | +| Medium (1k-10k) | 2 | 1 | 1GB | +| High (10k-100k) | 3-5 | 2 | 2GB | + +## Backup & Recovery + +See [DISASTER_RECOVERY.md](./DISASTER_RECOVERY.md) for backup and recovery procedures. diff --git a/services/mana-core-auth/src/app.module.ts b/services/mana-core-auth/src/app.module.ts index 5466762c5..b3d0f7ccc 100644 --- a/services/mana-core-auth/src/app.module.ts +++ b/services/mana-core-auth/src/app.module.ts @@ -14,6 +14,7 @@ import { HealthModule } from './health/health.module'; import { MetricsModule } from './metrics'; import { AnalyticsModule } from './analytics'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; +import { LoggerModule } from './common/logger'; @Module({ imports: [ @@ -27,6 +28,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter'; limit: 100, // 100 requests per minute }, ]), + LoggerModule, MetricsModule, AnalyticsModule, AiModule, diff --git a/services/mana-core-auth/src/auth/auth.controller.ts b/services/mana-core-auth/src/auth/auth.controller.ts index b07c0ca40..8329373b7 100644 --- a/services/mana-core-auth/src/auth/auth.controller.ts +++ b/services/mana-core-auth/src/auth/auth.controller.ts @@ -10,6 +10,7 @@ import { HttpCode, HttpStatus, } from '@nestjs/common'; +import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; import { BetterAuthService } from './services/better-auth.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; @@ -45,6 +46,7 @@ import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; * - POST /auth/organizations/set-active - Switch active organization */ @Controller('auth') +@UseGuards(ThrottlerGuard) export class AuthController { constructor(private readonly betterAuthService: BetterAuthService) {} @@ -56,8 +58,10 @@ export class AuthController { * Register a new B2C user (individual) * * Creates a user account and initializes their credit balance. + * Rate limited to 5 requests per minute to prevent abuse. */ @Post('register') + @Throttle({ default: { ttl: 60000, limit: 5 } }) async register(@Body() registerDto: RegisterDto) { return this.betterAuthService.registerB2C({ email: registerDto.email, @@ -71,8 +75,10 @@ export class AuthController { * Sign in with email and password * * Returns user data and JWT token. + * Rate limited to 10 requests per minute to prevent brute force. */ @Post('login') + @Throttle({ default: { ttl: 60000, limit: 10 } }) @HttpCode(HttpStatus.OK) async login(@Body() loginDto: LoginDto) { return this.betterAuthService.signIn({ @@ -150,8 +156,10 @@ export class AuthController { * * Initiates the password reset flow by sending an email with a reset link. * Always returns success to prevent email enumeration attacks. + * Rate limited to 3 requests per minute to prevent abuse. */ @Post('forgot-password') + @Throttle({ default: { ttl: 60000, limit: 3 } }) @HttpCode(HttpStatus.OK) async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) { return this.betterAuthService.requestPasswordReset( @@ -164,8 +172,10 @@ export class AuthController { * Reset password with token * * Completes the password reset using the token from the email link. + * Rate limited to 5 requests per minute. */ @Post('reset-password') + @Throttle({ default: { ttl: 60000, limit: 5 } }) @HttpCode(HttpStatus.OK) async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) { return this.betterAuthService.resetPassword( @@ -179,8 +189,10 @@ export class AuthController { * * Sends a new verification email to the user. * Always returns success to prevent email enumeration attacks. + * Rate limited to 3 requests per minute to prevent abuse. */ @Post('resend-verification') + @Throttle({ default: { ttl: 60000, limit: 3 } }) @HttpCode(HttpStatus.OK) async resendVerification(@Body() resendVerificationDto: ResendVerificationDto) { return this.betterAuthService.resendVerificationEmail( @@ -198,8 +210,10 @@ export class AuthController { * * Creates an organization with the registering user as owner. * Also creates organization credit balance. + * Rate limited to 3 requests per minute. */ @Post('register/b2b') + @Throttle({ default: { ttl: 60000, limit: 3 } }) async registerB2B(@Body() registerDto: RegisterB2BDto) { return this.betterAuthService.registerB2B(registerDto); } diff --git a/services/mana-core-auth/src/auth/better-auth-passthrough.controller.ts b/services/mana-core-auth/src/auth/better-auth-passthrough.controller.ts index 61c390a52..cae89f4cc 100644 --- a/services/mana-core-auth/src/auth/better-auth-passthrough.controller.ts +++ b/services/mana-core-auth/src/auth/better-auth-passthrough.controller.ts @@ -15,12 +15,19 @@ import { Controller, Get, Param, Query, Res, HttpStatus } from '@nestjs/common'; import { Response } from 'express'; import { BetterAuthService } from './services/better-auth.service'; +import { LoggerService } from '../common/logger'; @Controller('api/auth') export class BetterAuthPassthroughController { private readonly defaultFrontendUrl = 'https://mana.how'; + private readonly logger: LoggerService; - constructor(private readonly betterAuthService: BetterAuthService) {} + constructor( + private readonly betterAuthService: BetterAuthService, + loggerService: LoggerService + ) { + this.logger = loggerService.setContext('BetterAuthPassthrough'); + } /** * Validate redirect URL for security @@ -113,7 +120,10 @@ export class BetterAuthPassthroughController { return res.redirect(`${fallbackUrl}/verification-failed?error=${result.error}`); } } catch (error) { - console.error('[verify-email] Error:', error); + this.logger.error( + 'Email verification failed', + error instanceof Error ? error.stack : undefined + ); return res.redirect(`${fallbackUrl}/verification-failed?error=verification_failed`); } } @@ -156,10 +166,13 @@ export class BetterAuthPassthroughController { const resetUrl = new URL('/reset-password', baseUrl); resetUrl.searchParams.set('token', token); - console.log(`[reset-password] Redirecting to: ${resetUrl.toString()}`); + this.logger.debug('Password reset redirect', { destination: baseUrl }); return res.redirect(resetUrl.toString()); } catch (error) { - console.error('[reset-password] Error:', error); + this.logger.error( + 'Password reset redirect failed', + error instanceof Error ? error.stack : undefined + ); return res.redirect(`${fallbackUrl}/login?error=reset_failed`); } } diff --git a/services/mana-core-auth/src/auth/better-auth.config.ts b/services/mana-core-auth/src/auth/better-auth.config.ts index 7b86f6a80..cc35c27ce 100644 --- a/services/mana-core-auth/src/auth/better-auth.config.ts +++ b/services/mana-core-auth/src/auth/better-auth.config.ts @@ -319,7 +319,9 @@ export function createBetterAuth(databaseUrl: string) { loginPage: '/login', // Consent page (skipped for trusted clients) consentPage: '/consent', - // Use JWT plugin for token signing + // Use JWT plugin for token signing (EdDSA instead of HS256) + // This is required for Synapse OIDC which verifies via JWKS + useJWTPlugin: true, metadata: { issuer: process.env.BASE_URL || 'http://localhost:3001', }, diff --git a/services/mana-core-auth/src/auth/oidc.controller.ts b/services/mana-core-auth/src/auth/oidc.controller.ts index 6f0861ca1..f1eb01881 100644 --- a/services/mana-core-auth/src/auth/oidc.controller.ts +++ b/services/mana-core-auth/src/auth/oidc.controller.ts @@ -20,10 +20,18 @@ import { Controller, Get, Post, All, Req, Res, HttpStatus } from '@nestjs/common'; import { Request, Response } from 'express'; import { BetterAuthService } from './services/better-auth.service'; +import { LoggerService } from '../common/logger'; @Controller() export class OidcController { - constructor(private readonly betterAuthService: BetterAuthService) {} + private readonly logger: LoggerService; + + constructor( + private readonly betterAuthService: BetterAuthService, + loggerService: LoggerService + ) { + this.logger = loggerService.setContext('OidcController'); + } /** * OIDC Discovery Document @@ -45,9 +53,7 @@ export class OidcController { */ @Get('api/auth/oauth2/authorize') async authorizeOauth2(@Req() req: Request, @Res() res: Response) { - console.log('[OIDC Authorize] URL:', req.originalUrl); - console.log('[OIDC Authorize] Query:', req.query); - console.log('[OIDC Authorize] redirect_uri:', req.query.redirect_uri); + this.logger.debug('OIDC authorize request', { clientId: req.query.client_id }); return this.handleOidcRequest(req, res); } @@ -156,7 +162,7 @@ export class OidcController { return res.end(); } catch (error) { - console.error('[BetterAuth] Error handling request:', error); + this.logger.error('OIDC request failed', error instanceof Error ? error.stack : undefined); return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ error: 'server_error', error_description: 'Internal server error', @@ -243,7 +249,10 @@ export class OidcController { return res.end(); } catch (error) { - console.error('[OIDC] Error handling request:', error); + this.logger.error( + 'OIDC alternative path request failed', + error instanceof Error ? error.stack : undefined + ); return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ error: 'server_error', error_description: 'Internal server error', diff --git a/services/mana-core-auth/src/auth/services/better-auth.service.ts b/services/mana-core-auth/src/auth/services/better-auth.service.ts index f59dd048f..245efb50e 100644 --- a/services/mana-core-auth/src/auth/services/better-auth.service.ts +++ b/services/mana-core-auth/src/auth/services/better-auth.service.ts @@ -23,6 +23,7 @@ import { forwardRef, Optional, } from '@nestjs/common'; +import { LoggerService } from '../../common/logger'; import { ConfigService } from '@nestjs/config'; import { createBetterAuth } from '../better-auth.config'; import type { BetterAuthInstance } from '../better-auth.config'; @@ -64,7 +65,6 @@ import type { BetterAuthUser, BetterAuthSession, } from '../types/better-auth.types'; -import * as jwt from 'jsonwebtoken'; import { jwtVerify, createRemoteJWKSet } from 'jose'; // Re-export DTOs and result types for external use @@ -89,6 +89,7 @@ export type { export class BetterAuthService { private auth: BetterAuthInstance; private databaseUrl: string; + private readonly logger: LoggerService; /** * Typed accessor for organization plugin API methods @@ -117,8 +118,10 @@ export class BetterAuthService { private referralTierService: ReferralTierService, @Optional() @Inject(forwardRef(() => ReferralTrackingService)) - private referralTrackingService: ReferralTrackingService + private referralTrackingService: ReferralTrackingService, + loggerService: LoggerService ) { + this.logger = loggerService.setContext('BetterAuthService'); this.databaseUrl = this.configService.get('database.url')!; this.auth = createBetterAuth(this.databaseUrl); } @@ -346,7 +349,10 @@ export class BetterAuthService { // Use type guard for safe access return hasMembers(result) ? result.members : []; } catch (error) { - console.error('Error fetching organization members:', error); + this.logger.error( + 'Failed to fetch organization members', + error instanceof Error ? error.stack : undefined + ); return []; } } @@ -477,43 +483,13 @@ export class BetterAuthService { throw new Error('Better Auth signJWT returned empty token'); } } catch (jwtError) { - console.warn('[signIn] Better Auth signJWT failed, using manual JWT generation:', jwtError); + this.logger.warn('Better Auth signJWT failed, using session token as fallback', { + error: jwtError instanceof Error ? jwtError.message : 'Unknown error', + }); - // Fallback: Generate JWT manually using jsonwebtoken - const privateKey = this.configService.get('jwt.privateKey'); - const issuer = this.configService.get('jwt.issuer') || 'manacore'; - const audience = this.configService.get('jwt.audience') || 'manacore'; - - console.log('[signIn] Private key exists:', !!privateKey); - console.log('[signIn] Private key length:', privateKey?.length); - console.log('[signIn] Private key starts with:', privateKey?.substring(0, 30)); - console.log('[signIn] Issuer:', issuer); - console.log('[signIn] Audience:', audience); - - if (privateKey) { - const payload = { - sub: user.id, - email: user.email, - role: (user as BetterAuthUser).role || 'user', - sid: session?.id || '', - }; - - accessToken = jwt.sign(payload, privateKey, { - algorithm: 'RS256', - expiresIn: '15m', - issuer, - audience, - }); - - console.log('[signIn] Generated JWT (first 50 chars):', accessToken?.substring(0, 50)); - // Decode to verify - const decoded = jwt.decode(accessToken, { complete: true }); - console.log('[signIn] Generated JWT header:', decoded?.header); - console.log('[signIn] Generated JWT payload:', decoded?.payload); - } else { - console.error('[signIn] No JWT private key configured'); - accessToken = sessionToken; - } + // Fallback: Use session token (Better Auth manages JWT signing via JWKS) + // NOTE: If signJWT fails repeatedly, check that the auth.jwks table has valid EdDSA keys + accessToken = sessionToken; } return { @@ -562,7 +538,9 @@ export class BetterAuthService { } catch (error: unknown) { // Even if signOut fails, we treat it as success for the user // The session will expire naturally - console.error('Error during sign out:', error); + this.logger.warn('Sign out error (session will expire naturally)', { + error: error instanceof Error ? error.message : 'Unknown error', + }); return { success: true, message: 'Signed out successfully' }; } } @@ -628,7 +606,10 @@ export class BetterAuthService { return { organizations }; } catch (error: unknown) { - console.error('Error listing organizations:', error); + this.logger.error( + 'Failed to list organizations', + error instanceof Error ? error.stack : undefined + ); return { organizations: [] }; } } @@ -821,18 +802,13 @@ export class BetterAuthService { */ async validateToken(token: string): Promise { try { - console.log('[validateToken] Token (first 50 chars):', token?.substring(0, 50)); - // Decode to check the algorithm const decoded = jwt.decode(token, { complete: true }); - console.log('[validateToken] Decoded header:', decoded?.header); // Use our JWKS endpoint (NestJS prefix: /api/v1) const baseUrl = this.configService.get('BASE_URL') || 'http://localhost:3001'; const jwksUrl = new URL('/api/v1/auth/jwks', baseUrl); - console.log('[validateToken] Using JWKS from:', jwksUrl.toString()); - // Create JWKS fetcher const JWKS = createRemoteJWKSet(jwksUrl); @@ -840,17 +816,13 @@ export class BetterAuthService { const issuer = this.configService.get('jwt.issuer') || baseUrl; const audience = this.configService.get('jwt.audience') || baseUrl; - console.log('[validateToken] Issuer:', issuer); - console.log('[validateToken] Audience:', audience); - // Verify using jose library with Better Auth's JWKS const { payload } = await jwtVerify(token, JWKS, { issuer, audience, }); - console.log('[validateToken] Verification SUCCESS'); - console.log('[validateToken] Payload:', payload); + this.logger.debug('Token validation successful', { userId: payload.sub }); return { valid: true, @@ -858,7 +830,7 @@ export class BetterAuthService { }; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - console.error('[validateToken] Verification FAILED:', errorMessage); + this.logger.warn('Token validation failed', { error: errorMessage }); return { valid: false, error: errorMessage, @@ -906,7 +878,10 @@ export class BetterAuthService { message: 'If an account with that email exists, a password reset link has been sent', }; } catch (error) { - console.error('[requestPasswordReset] Error:', error); + this.logger.error( + 'Password reset request failed', + error instanceof Error ? error.stack : undefined + ); // Always return success to prevent email enumeration attacks return { success: true, @@ -977,10 +952,9 @@ export class BetterAuthService { query: { token }, }); - console.log('[verifyEmail] Result:', result); - // Extract email from result if available const email = result?.user?.email || result?.email; + this.logger.debug('Email verification successful', { email }); return { success: true, @@ -988,7 +962,7 @@ export class BetterAuthService { }; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - console.error('[verifyEmail] Error:', errorMessage); + this.logger.warn('Email verification failed', { error: errorMessage }); if (errorMessage.includes('invalid') || errorMessage.includes('expired')) { return { @@ -1038,7 +1012,10 @@ export class BetterAuthService { message: 'If an account with that email exists, a verification email has been sent', }; } catch (error) { - console.error('[resendVerificationEmail] Error:', error); + this.logger.error( + 'Resend verification email failed', + error instanceof Error ? error.stack : undefined + ); // Always return success to prevent email enumeration attacks return { success: true, @@ -1082,7 +1059,7 @@ export class BetterAuthService { }), }; } catch (error) { - console.error('[getJwks] Error:', error); + this.logger.error('Failed to get JWKS', error instanceof Error ? error.stack : undefined); return { keys: [] }; } } @@ -1132,7 +1109,9 @@ export class BetterAuthService { totalSpent: 0, }); } catch (error) { - console.error('Error creating personal credit balance:', error); + this.logger.warn('Failed to create personal credit balance (non-critical)', { + error: error instanceof Error ? error.message : 'Unknown error', + }); // Don't throw - this is a non-critical operation } } @@ -1163,7 +1142,9 @@ export class BetterAuthService { totalAllocated: 0, }); } catch (error) { - console.error('Error creating organization credit balance:', error); + this.logger.warn('Failed to create organization credit balance (non-critical)', { + error: error instanceof Error ? error.message : 'Unknown error', + }); // Don't throw - this is a non-critical operation } } @@ -1227,12 +1208,15 @@ export class BetterAuthService { }); if (!result.success) { - console.warn('[initializeUserReferrals] Failed to apply referral code:', result.error); + this.logger.warn('Failed to apply referral code', { error: result.error, referralCode }); } } } catch (error) { // Log but don't fail registration if referral setup fails - console.error('[initializeUserReferrals] Error setting up referrals:', error); + this.logger.error( + 'Error setting up referrals', + error instanceof Error ? error.stack : undefined + ); } } @@ -1353,7 +1337,10 @@ export class BetterAuthService { body, }; } catch (error) { - console.error('[handleOidcRequest] Error:', error); + this.logger.error( + 'OIDC request handling failed', + error instanceof Error ? error.stack : undefined + ); throw error; } } diff --git a/services/mana-core-auth/src/common/guards/jwt-auth.guard.spec.ts b/services/mana-core-auth/src/common/guards/jwt-auth.guard.spec.ts index 7e3824c2e..63329ae1d 100644 --- a/services/mana-core-auth/src/common/guards/jwt-auth.guard.spec.ts +++ b/services/mana-core-auth/src/common/guards/jwt-auth.guard.spec.ts @@ -13,6 +13,7 @@ import type { TestingModule } from '@nestjs/testing'; import { UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtAuthGuard } from './jwt-auth.guard'; +import { LoggerService } from '../logger'; import { createMockConfigService, httpMockHelpers } from '../../__tests__/utils/test-helpers'; import { mockTokenFactory } from '../../__tests__/utils/mock-factories'; import { silentError } from '../../__tests__/utils/silent-error.decorator'; @@ -21,6 +22,18 @@ import { jwtVerify } from 'jose'; // Mock jose (auto-mocked via jest.config.js moduleNameMapper) jest.mock('jose'); +// Mock LoggerService +const createMockLoggerService = (): LoggerService => + ({ + setContext: jest.fn().mockReturnThis(), + log: jest.fn(), + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + }) as unknown as LoggerService; + describe('JwtAuthGuard', () => { let guard: JwtAuthGuard; let configService: ConfigService; @@ -41,6 +54,10 @@ describe('JwtAuthGuard', () => { 'jwt.audience': 'manacore', }), }, + { + provide: LoggerService, + useValue: createMockLoggerService(), + }, ], }).compile(); @@ -344,7 +361,8 @@ describe('JwtAuthGuard', () => { createMockConfigService({ 'jwt.issuer': 'manacore', 'jwt.audience': 'manacore', - }) + }), + createMockLoggerService() ); const mockRequest = httpMockHelpers.createMockRequest({ @@ -375,7 +393,8 @@ describe('JwtAuthGuard', () => { BASE_URL: 'http://localhost:3001', 'jwt.issuer': 'custom-issuer', 'jwt.audience': 'custom-audience', - }) + }), + createMockLoggerService() ); const mockRequest = httpMockHelpers.createMockRequest({ diff --git a/services/mana-core-auth/src/common/guards/jwt-auth.guard.ts b/services/mana-core-auth/src/common/guards/jwt-auth.guard.ts index 27724df9f..70a69c268 100644 --- a/services/mana-core-auth/src/common/guards/jwt-auth.guard.ts +++ b/services/mana-core-auth/src/common/guards/jwt-auth.guard.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { jwtVerify, createRemoteJWKSet } from 'jose'; +import { LoggerService } from '../logger'; /** * JWT Auth Guard using JWKS (Better Auth compatible) @@ -16,17 +17,20 @@ import { jwtVerify, createRemoteJWKSet } from 'jose'; @Injectable() export class JwtAuthGuard implements CanActivate { private jwks: ReturnType | null = null; + private readonly logger: LoggerService; - constructor(private configService: ConfigService) {} + constructor( + private configService: ConfigService, + loggerService: LoggerService + ) { + this.logger = loggerService.setContext('JwtAuthGuard'); + } async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const token = this.extractTokenFromHeader(request); - console.log('[JwtAuthGuard] Token (first 50 chars):', token?.substring(0, 50)); - if (!token) { - console.log('[JwtAuthGuard] No token provided'); throw new UnauthorizedException('No token provided'); } @@ -35,21 +39,18 @@ export class JwtAuthGuard implements CanActivate { if (!this.jwks) { const baseUrl = this.configService.get('BASE_URL') || 'http://localhost:3001'; const jwksUrl = new URL('/api/v1/auth/jwks', baseUrl); - console.log('[JwtAuthGuard] Initializing JWKS from:', jwksUrl.toString()); this.jwks = createRemoteJWKSet(jwksUrl); } const issuer = this.configService.get('jwt.issuer') || 'manacore'; const audience = this.configService.get('jwt.audience') || 'manacore'; - console.log('[JwtAuthGuard] Verifying with issuer:', issuer, 'audience:', audience); - const { payload } = await jwtVerify(token, this.jwks, { issuer, audience, }); - console.log('[JwtAuthGuard] Verification SUCCESS, user:', payload.sub); + this.logger.debug('Token verification successful', { userId: payload.sub }); // Attach user to request request.user = { @@ -60,7 +61,9 @@ export class JwtAuthGuard implements CanActivate { return true; } catch (error) { - console.error('[JwtAuthGuard] Token verification FAILED:', error); + this.logger.warn('Token verification failed', { + error: error instanceof Error ? error.message : 'Unknown error', + }); throw new UnauthorizedException('Invalid token'); } } diff --git a/services/mana-core-auth/src/common/logger/index.ts b/services/mana-core-auth/src/common/logger/index.ts new file mode 100644 index 000000000..28b5bd37c --- /dev/null +++ b/services/mana-core-auth/src/common/logger/index.ts @@ -0,0 +1,2 @@ +export { LoggerService, getLogger } from './logger.service'; +export { LoggerModule } from './logger.module'; diff --git a/services/mana-core-auth/src/common/logger/logger.module.ts b/services/mana-core-auth/src/common/logger/logger.module.ts new file mode 100644 index 000000000..fafebc11e --- /dev/null +++ b/services/mana-core-auth/src/common/logger/logger.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { LoggerService } from './logger.service'; + +@Global() +@Module({ + providers: [LoggerService], + exports: [LoggerService], +}) +export class LoggerModule {} diff --git a/services/mana-core-auth/src/common/logger/logger.service.ts b/services/mana-core-auth/src/common/logger/logger.service.ts new file mode 100644 index 000000000..c49021dd5 --- /dev/null +++ b/services/mana-core-auth/src/common/logger/logger.service.ts @@ -0,0 +1,95 @@ +import { Injectable, LoggerService as NestLoggerService, Scope } from '@nestjs/common'; +import * as winston from 'winston'; + +const { combine, timestamp, printf, colorize, json } = winston.format; + +// Custom format for development (readable) +const devFormat = printf(({ level, message, timestamp, context, ...meta }) => { + const ctx = context ? `[${context}]` : ''; + const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : ''; + return `${timestamp} ${level} ${ctx} ${message}${metaStr}`; +}); + +// Create winston logger instance +function createLogger(): winston.Logger { + const isProduction = process.env.NODE_ENV === 'production'; + + return winston.createLogger({ + level: process.env.LOG_LEVEL || (isProduction ? 'info' : 'debug'), + format: isProduction + ? combine(timestamp(), json()) + : combine(timestamp({ format: 'HH:mm:ss' }), colorize(), devFormat), + transports: [new winston.transports.Console()], + // Don't exit on error + exitOnError: false, + }); +} + +@Injectable({ scope: Scope.TRANSIENT }) +export class LoggerService implements NestLoggerService { + private logger: winston.Logger; + private context?: string; + + constructor() { + this.logger = createLogger(); + } + + setContext(context: string): this { + this.context = context; + return this; + } + + log(message: string, ...optionalParams: unknown[]): void { + this.logger.info(message, this.formatMeta(optionalParams)); + } + + info(message: string, meta?: Record): void { + this.logger.info(message, { context: this.context, ...meta }); + } + + error(message: string, trace?: string, ...optionalParams: unknown[]): void { + this.logger.error(message, { + context: this.context, + trace, + ...this.formatMeta(optionalParams), + }); + } + + warn(message: string, ...optionalParams: unknown[]): void { + this.logger.warn(message, this.formatMeta(optionalParams)); + } + + debug(message: string, ...optionalParams: unknown[]): void { + this.logger.debug(message, this.formatMeta(optionalParams)); + } + + verbose(message: string, ...optionalParams: unknown[]): void { + this.logger.verbose(message, this.formatMeta(optionalParams)); + } + + private formatMeta(optionalParams: unknown[]): Record { + const meta: Record = { context: this.context }; + + if (optionalParams.length === 1 && typeof optionalParams[0] === 'string') { + // NestJS passes context as last param + meta.context = optionalParams[0]; + } else if (optionalParams.length > 0) { + meta.params = optionalParams; + } + + return meta; + } +} + +// Singleton instance for use outside DI (main.ts, scripts) +let globalLogger: LoggerService | null = null; + +export function getLogger(context?: string): LoggerService { + if (!globalLogger) { + globalLogger = new LoggerService(); + } + if (context) { + return new LoggerService().setContext(context); + } + return globalLogger; +} diff --git a/services/mana-core-auth/src/config/configuration.ts b/services/mana-core-auth/src/config/configuration.ts index 398221369..aa3a852a8 100644 --- a/services/mana-core-auth/src/config/configuration.ts +++ b/services/mana-core-auth/src/config/configuration.ts @@ -1,52 +1,76 @@ +/** + * Application Configuration + * + * Loads and validates environment variables. + * Fails fast at startup if required variables are missing. + */ + +import { validateEnv, isDevelopment } from './env.validation'; + +// Validate environment on module load +const env = validateEnv(); + export default () => ({ - port: parseInt(process.env.PORT || '3001', 10), - nodeEnv: process.env.NODE_ENV || 'development', + port: parseInt(env.PORT, 10), + nodeEnv: env.NODE_ENV, database: { - url: process.env.DATABASE_URL || 'postgresql://manacore:password@localhost:5432/manacore', + // In development, allow fallback to local database + // In production, DATABASE_URL is validated as required + url: + env.DATABASE_URL || + (isDevelopment() ? 'postgresql://manacore:manacore@localhost:5432/manacore_auth' : ''), }, jwt: { - // Convert \n string literals to actual newlines for PEM format - publicKey: (process.env.JWT_PUBLIC_KEY || '').replace(/\\n/g, '\n'), - privateKey: (process.env.JWT_PRIVATE_KEY || '').replace(/\\n/g, '\n'), - accessTokenExpiry: process.env.JWT_ACCESS_TOKEN_EXPIRY || '15m', - refreshTokenExpiry: process.env.JWT_REFRESH_TOKEN_EXPIRY || '7d', - issuer: process.env.JWT_ISSUER || 'manacore', - audience: process.env.JWT_AUDIENCE || 'manacore', + // Better Auth uses JWKS from database, these are legacy/fallback + publicKey: (env.JWT_PUBLIC_KEY || '').replace(/\\n/g, '\n'), + privateKey: (env.JWT_PRIVATE_KEY || '').replace(/\\n/g, '\n'), + accessTokenExpiry: env.JWT_ACCESS_TOKEN_EXPIRY, + refreshTokenExpiry: env.JWT_REFRESH_TOKEN_EXPIRY, + issuer: env.JWT_ISSUER, + audience: env.JWT_AUDIENCE, }, redis: { - host: process.env.REDIS_HOST || 'localhost', - port: parseInt(process.env.REDIS_PORT || '6379', 10), - password: process.env.REDIS_PASSWORD, + host: env.REDIS_HOST || 'localhost', + port: parseInt(env.REDIS_PORT || '6379', 10), + password: env.REDIS_PASSWORD, }, stripe: { - secretKey: process.env.STRIPE_SECRET_KEY || '', - webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '', - publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '', + secretKey: env.STRIPE_SECRET_KEY || '', + webhookSecret: env.STRIPE_WEBHOOK_SECRET || '', + publishableKey: env.STRIPE_PUBLISHABLE_KEY || '', }, cors: { - origin: process.env.CORS_ORIGINS?.split(',') || [ - 'http://localhost:3000', - 'http://localhost:8081', - ], + origin: + env.CORS_ORIGINS?.split(',').map((o) => o.trim()) || + (isDevelopment() + ? [ + 'http://localhost:3000', + 'http://localhost:5173', + 'http://localhost:5174', + 'http://localhost:8081', + ] + : []), credentials: true, }, rateLimit: { - ttl: parseInt(process.env.RATE_LIMIT_TTL || '60', 10), - limit: parseInt(process.env.RATE_LIMIT_MAX || '100', 10), + ttl: parseInt(env.RATE_LIMIT_TTL || '60', 10), + limit: parseInt(env.RATE_LIMIT_MAX || '100', 10), }, credits: { - signupBonus: parseInt(process.env.CREDITS_SIGNUP_BONUS || '150', 10), - dailyFreeCredits: parseInt(process.env.CREDITS_DAILY_FREE || '5', 10), + signupBonus: parseInt(env.CREDITS_SIGNUP_BONUS || '150', 10), + dailyFreeCredits: parseInt(env.CREDITS_DAILY_FREE || '5', 10), }, ai: { - geminiApiKey: process.env.GOOGLE_GENAI_API_KEY || '', + geminiApiKey: env.GOOGLE_GENAI_API_KEY || '', }, + + baseUrl: env.BASE_URL || (isDevelopment() ? 'http://localhost:3001' : ''), }); diff --git a/services/mana-core-auth/src/config/env.validation.ts b/services/mana-core-auth/src/config/env.validation.ts new file mode 100644 index 000000000..fa8967a35 --- /dev/null +++ b/services/mana-core-auth/src/config/env.validation.ts @@ -0,0 +1,145 @@ +/** + * Environment Variable Validation + * + * Validates all required environment variables at startup. + * Fails fast with clear error messages if configuration is invalid. + */ + +import { z } from 'zod'; + +// Schema for environment variables +const envSchema = z.object({ + // Node environment + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PORT: z.string().regex(/^\d+$/).default('3001'), + + // Database - REQUIRED in production + DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'), + + // Redis - optional in development, recommended in production + REDIS_HOST: z.string().optional(), + REDIS_PORT: z.string().regex(/^\d+$/).optional(), + REDIS_PASSWORD: z.string().optional(), + + // JWT - Better Auth uses JWKS, so these are optional legacy config + JWT_PUBLIC_KEY: z.string().optional(), + JWT_PRIVATE_KEY: z.string().optional(), + JWT_ISSUER: z.string().default('manacore'), + JWT_AUDIENCE: z.string().default('manacore'), + JWT_ACCESS_TOKEN_EXPIRY: z.string().default('15m'), + JWT_REFRESH_TOKEN_EXPIRY: z.string().default('7d'), + + // CORS - REQUIRED in production + CORS_ORIGINS: z.string().optional(), + + // Stripe - optional, but credit system won't work without it + STRIPE_SECRET_KEY: z.string().optional(), + STRIPE_WEBHOOK_SECRET: z.string().optional(), + STRIPE_PUBLISHABLE_KEY: z.string().optional(), + + // SMTP - optional, emails will be logged if not configured + SMTP_HOST: z.string().optional(), + SMTP_PORT: z.string().optional(), + SMTP_USER: z.string().optional(), + SMTP_PASSWORD: z.string().optional(), + SMTP_FROM: z.string().optional(), + + // Rate limiting + RATE_LIMIT_TTL: z.string().regex(/^\d+$/).optional(), + RATE_LIMIT_MAX: z.string().regex(/^\d+$/).optional(), + + // Credits + CREDITS_SIGNUP_BONUS: z.string().regex(/^\d+$/).optional(), + CREDITS_DAILY_FREE: z.string().regex(/^\d+$/).optional(), + + // AI + GOOGLE_GENAI_API_KEY: z.string().optional(), + + // Base URL for callbacks + BASE_URL: z.string().url().optional(), + + // Log level + LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).optional(), +}); + +// Production-specific schema with stricter requirements +const productionEnvSchema = envSchema.extend({ + // In production, these are mandatory + CORS_ORIGINS: z.string().min(1, 'CORS_ORIGINS is required in production'), + BASE_URL: z.string().url('BASE_URL must be a valid URL in production'), +}); + +export type EnvConfig = z.infer; + +/** + * Validate environment variables + * + * @throws Error with detailed message if validation fails + */ +export function validateEnv(): EnvConfig { + const isProduction = process.env.NODE_ENV === 'production'; + const schema = isProduction ? productionEnvSchema : envSchema; + + const result = schema.safeParse(process.env); + + if (!result.success) { + const errors = result.error.errors + .map((err) => ` - ${err.path.join('.')}: ${err.message}`) + .join('\n'); + + const message = ` +╔══════════════════════════════════════════════════════════════╗ +║ ENVIRONMENT CONFIGURATION ERROR ║ +╚══════════════════════════════════════════════════════════════╝ + +The following environment variables are missing or invalid: + +${errors} + +${isProduction ? 'Production mode requires stricter configuration.' : ''} + +Please check your .env file or environment variables. +For development, copy .env.example to .env and fill in the values. +`; + + console.error(message); + throw new Error(`Environment validation failed: ${result.error.message}`); + } + + // Additional production warnings (non-fatal) + if (isProduction) { + const warnings: string[] = []; + + if (!result.data.STRIPE_SECRET_KEY) { + warnings.push('STRIPE_SECRET_KEY not set - credit system will not work'); + } + if (!result.data.SMTP_HOST) { + warnings.push('SMTP not configured - emails will only be logged'); + } + if (!result.data.REDIS_HOST) { + warnings.push('REDIS_HOST not set - using in-memory session storage (not recommended)'); + } + + if (warnings.length > 0) { + console.warn('\n⚠️ Production Warnings:'); + warnings.forEach((w) => console.warn(` - ${w}`)); + console.warn(''); + } + } + + return result.data; +} + +/** + * Check if running in development mode + */ +export function isDevelopment(): boolean { + return process.env.NODE_ENV !== 'production'; +} + +/** + * Check if running in production mode + */ +export function isProduction(): boolean { + return process.env.NODE_ENV === 'production'; +} diff --git a/services/mana-core-auth/src/email/email.service.ts b/services/mana-core-auth/src/email/email.service.ts index d8e910eb1..7b4887c7d 100644 --- a/services/mana-core-auth/src/email/email.service.ts +++ b/services/mana-core-auth/src/email/email.service.ts @@ -8,6 +8,9 @@ */ import * as nodemailer from 'nodemailer'; +import { getLogger } from '../common/logger'; + +const logger = getLogger('EmailService'); interface EmailOptions { to: string; @@ -30,7 +33,7 @@ function getTransporter(): nodemailer.Transporter { const pass = process.env.SMTP_PASSWORD; if (!user || !pass) { - console.warn('[Email] SMTP credentials not configured, emails will be logged only'); + logger.warn('SMTP credentials not configured, emails will be logged only'); return null as any; } @@ -54,15 +57,12 @@ export async function sendEmail(options: EmailOptions): Promise { const { to, subject, html, text } = options; const from = process.env.SMTP_FROM || 'ManaCore '; - console.log(`[Email] Sending to: ${to}, subject: ${subject}`); + logger.info('Sending email', { to, subject }); const transport = getTransporter(); if (!transport) { - console.log('[Email] No SMTP configured, logging email content:'); - console.log(` To: ${to}`); - console.log(` Subject: ${subject}`); - console.log(` HTML: ${html.substring(0, 200)}...`); + logger.debug('No SMTP configured, email not sent', { to, subject }); return false; } @@ -75,10 +75,10 @@ export async function sendEmail(options: EmailOptions): Promise { text: text || html.replace(/<[^>]*>/g, ''), // Strip HTML for text version }); - console.log(`[Email] Sent successfully, messageId: ${result.messageId}`); + logger.info('Email sent successfully', { to, messageId: result.messageId }); return true; } catch (error) { - console.error('[Email] Failed to send:', error); + logger.error('Failed to send email', error instanceof Error ? error.stack : undefined, { to }); return false; } } diff --git a/services/mana-core-auth/src/health/health.controller.ts b/services/mana-core-auth/src/health/health.controller.ts index 8d275ff92..4c135dfe3 100644 --- a/services/mana-core-auth/src/health/health.controller.ts +++ b/services/mana-core-auth/src/health/health.controller.ts @@ -1,12 +1,181 @@ -import { Controller, Get } from '@nestjs/common'; +/** + * Health Check Controller + * + * Provides health check endpoints for Kubernetes/Docker: + * - /health - Basic health check (always returns ok if server is running) + * - /health/live - Liveness probe (is the process running?) + * - /health/ready - Readiness probe (is the service ready to accept traffic?) + * + * Readiness checks database connectivity to ensure the service + * can actually handle requests before receiving traffic. + */ + +import { Controller, Get, ServiceUnavailableException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { sql } from 'drizzle-orm'; +import { getDb } from '../db/connection'; + +interface HealthStatus { + status: 'ok' | 'error'; + timestamp: string; + uptime: number; + checks?: { + database?: { status: 'ok' | 'error'; latency?: number; error?: string }; + redis?: { status: 'ok' | 'error' | 'not_configured'; latency?: number; error?: string }; + }; +} @Controller('health') export class HealthController { + private readonly startTime = Date.now(); + + constructor(private configService: ConfigService) {} + + /** + * Basic health check + * Returns ok if the server is running + */ @Get() - check() { + check(): HealthStatus { return { status: 'ok', timestamp: new Date().toISOString(), + uptime: Math.floor((Date.now() - this.startTime) / 1000), }; } + + /** + * Liveness probe + * Used by Kubernetes to determine if the process should be restarted + * Only checks if the process is alive, not if dependencies are healthy + */ + @Get('live') + live(): { status: 'ok' } { + return { status: 'ok' }; + } + + /** + * Readiness probe + * Used by Kubernetes to determine if the service should receive traffic + * Checks database connectivity before marking as ready + */ + @Get('ready') + async ready(): Promise { + const checks: HealthStatus['checks'] = {}; + let allHealthy = true; + + // Check database + const dbCheck = await this.checkDatabase(); + checks.database = dbCheck; + if (dbCheck.status === 'error') { + allHealthy = false; + } + + // Check Redis (optional - don't fail if not configured) + const redisCheck = await this.checkRedis(); + checks.redis = redisCheck; + // Don't fail readiness if Redis is just not configured + if (redisCheck.status === 'error') { + allHealthy = false; + } + + const status: HealthStatus = { + status: allHealthy ? 'ok' : 'error', + timestamp: new Date().toISOString(), + uptime: Math.floor((Date.now() - this.startTime) / 1000), + checks, + }; + + if (!allHealthy) { + throw new ServiceUnavailableException(status); + } + + return status; + } + + /** + * Check database connectivity + */ + private async checkDatabase(): Promise<{ + status: 'ok' | 'error'; + latency?: number; + error?: string; + }> { + const start = Date.now(); + + try { + const databaseUrl = this.configService.get('database.url'); + if (!databaseUrl) { + return { status: 'error', error: 'DATABASE_URL not configured' }; + } + + const db = getDb(databaseUrl); + await db.execute(sql`SELECT 1`); + + return { + status: 'ok', + latency: Date.now() - start, + }; + } catch (error) { + return { + status: 'error', + latency: Date.now() - start, + error: error instanceof Error ? error.message : 'Unknown database error', + }; + } + } + + /** + * Check Redis connectivity (optional) + */ + private async checkRedis(): Promise<{ + status: 'ok' | 'error' | 'not_configured'; + latency?: number; + error?: string; + }> { + const redisHost = this.configService.get('redis.host'); + + // Redis is optional - if not configured, that's fine + if (!redisHost) { + return { status: 'not_configured' }; + } + + const start = Date.now(); + + try { + // Simple TCP connection check to Redis + const net = await import('net'); + const redisPort = this.configService.get('redis.port') || 6379; + + await new Promise((resolve, reject) => { + const socket = new net.Socket(); + const timeout = setTimeout(() => { + socket.destroy(); + reject(new Error('Connection timeout')); + }, 2000); + + socket.connect(redisPort, redisHost, () => { + clearTimeout(timeout); + socket.destroy(); + resolve(); + }); + + socket.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); + + return { + status: 'ok', + latency: Date.now() - start, + }; + } catch (error) { + return { + status: 'error', + latency: Date.now() - start, + error: error instanceof Error ? error.message : 'Unknown Redis error', + }; + } + } } diff --git a/services/mana-core-auth/src/main.ts b/services/mana-core-auth/src/main.ts index 3e226e235..a1681bb9a 100644 --- a/services/mana-core-auth/src/main.ts +++ b/services/mana-core-auth/src/main.ts @@ -7,6 +7,9 @@ import cookieParser from 'cookie-parser'; import * as bodyParser from 'body-parser'; import { AppModule } from './app.module'; import { MetricsService } from './metrics/metrics.service'; +import { getLogger } from './common/logger'; + +const logger = getLogger('Bootstrap'); // Normalize route paths to prevent high cardinality function normalizeRoute(path: string): string { @@ -76,7 +79,7 @@ async function bootstrap() { // CORS configuration const corsOrigins = configService.get('cors.origin') || []; - console.log('📋 CORS Origins configured:', corsOrigins); + logger.info('CORS Origins configured', { origins: corsOrigins }); app.enableCors({ origin: corsOrigins, credentials: true, @@ -128,8 +131,10 @@ async function bootstrap() { const port = configService.get('port') || 3001; await app.listen(port); - console.log(`🚀 Mana Core Auth running on: http://localhost:${port}`); - console.log(`📚 Environment: ${configService.get('nodeEnv')}`); + logger.info(`Mana Core Auth running on http://localhost:${port}`, { + port, + environment: configService.get('nodeEnv'), + }); } bootstrap();