🐛 fix(mana-core-auth): use EdDSA for OIDC id_token signing

Set useJWTPlugin: true so id_tokens are signed with EdDSA keys
from JWKS instead of HS256. This fixes Synapse OIDC integration
which verifies tokens via JWKS endpoint.
This commit is contained in:
Till-JS 2026-02-01 13:24:55 +01:00
parent 5c61a4ed0f
commit efb077b9ea
22 changed files with 1605 additions and 142 deletions

View file

@ -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) |

View file

@ -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

View file

@ -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 <noreply@mana.how>
# ============================================================================
# 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=

View file

@ -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"]

View file

@ -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();
```

View file

@ -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 <noreply@yourdomain.com>
# 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.

View file

@ -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,

View file

@ -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);
}

View file

@ -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`);
}
}

View file

@ -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',
},

View file

@ -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',

View file

@ -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<string>('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<string>('jwt.privateKey');
const issuer = this.configService.get<string>('jwt.issuer') || 'manacore';
const audience = this.configService.get<string>('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<ValidateTokenResult> {
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<string>('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<string>('jwt.issuer') || baseUrl;
const audience = this.configService.get<string>('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;
}
}

View file

@ -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({

View file

@ -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<typeof createRemoteJWKSet> | 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<boolean> {
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<string>('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<string>('jwt.issuer') || 'manacore';
const audience = this.configService.get<string>('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');
}
}

View file

@ -0,0 +1,2 @@
export { LoggerService, getLogger } from './logger.service';
export { LoggerModule } from './logger.module';

View file

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { LoggerService } from './logger.service';
@Global()
@Module({
providers: [LoggerService],
exports: [LoggerService],
})
export class LoggerModule {}

View file

@ -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<string, unknown>): 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<string, unknown> {
const meta: Record<string, unknown> = { 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;
}

View file

@ -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' : ''),
});

View file

@ -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<typeof envSchema>;
/**
* 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';
}

View file

@ -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<boolean> {
const { to, subject, html, text } = options;
const from = process.env.SMTP_FROM || 'ManaCore <noreply@mana.how>';
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<boolean> {
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;
}
}

View file

@ -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<HealthStatus> {
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<string>('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<string>('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<number>('redis.port') || 6379;
await new Promise<void>((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',
};
}
}
}

View file

@ -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<string[]>('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<number>('port') || 3001;
await app.listen(port);
console.log(`🚀 Mana Core Auth running on: http://localhost:${port}`);
console.log(`📚 Environment: ${configService.get<string>('nodeEnv')}`);
logger.info(`Mana Core Auth running on http://localhost:${port}`, {
port,
environment: configService.get<string>('nodeEnv'),
});
}
bootstrap();