/** * Express application factory for SentryAgent.ai AgentIdP. * Creates and configures the Express app with all middleware and routes. * Exported as a factory function — does NOT call listen (testable). */ import express, { Application } from 'express'; import helmet from 'helmet'; import cors from 'cors'; import morgan from 'morgan'; import { getPool } from './db/pool.js'; import { getRedisClient } from './cache/redis.js'; import { AgentRepository } from './repositories/AgentRepository.js'; import { CredentialRepository } from './repositories/CredentialRepository.js'; import { TokenRepository } from './repositories/TokenRepository.js'; import { AuditRepository } from './repositories/AuditRepository.js'; import { AuditService } from './services/AuditService.js'; import { AgentService } from './services/AgentService.js'; import { CredentialService } from './services/CredentialService.js'; import { OAuth2Service } from './services/OAuth2Service.js'; import { AgentController } from './controllers/AgentController.js'; import { TokenController } from './controllers/TokenController.js'; import { CredentialController } from './controllers/CredentialController.js'; import { AuditController } from './controllers/AuditController.js'; import { createAgentsRouter } from './routes/agents.js'; import { createTokenRouter } from './routes/token.js'; import { createCredentialsRouter } from './routes/credentials.js'; import { createAuditRouter } from './routes/audit.js'; import { createHealthRouter } from './routes/health.js'; import { createMetricsRouter } from './routes/metrics.js'; import { errorHandler } from './middleware/errorHandler.js'; import { createOpaMiddleware } from './middleware/opa.js'; import { metricsMiddleware } from './middleware/metrics.js'; import { createVaultClientFromEnv } from './vault/VaultClient.js'; import { RedisClientType } from 'redis'; import path from 'path'; /** * Creates and returns a configured Express application. * All infrastructure dependencies (DB pool, Redis) are initialised here. * * @returns Promise resolving to the configured Express Application. * @throws Error if required environment variables are missing. */ export async function createApp(): Promise { const app = express(); // ──────────────────────────────────────────────────────────────── // Security headers // ──────────────────────────────────────────────────────────────── app.use(helmet()); // ──────────────────────────────────────────────────────────────── // CORS // ──────────────────────────────────────────────────────────────── const corsOrigin = process.env['CORS_ORIGIN'] ?? '*'; app.use(cors({ origin: corsOrigin })); // ──────────────────────────────────────────────────────────────── // Request logging // ──────────────────────────────────────────────────────────────── if (process.env['NODE_ENV'] !== 'test') { app.use(morgan('combined')); } // ──────────────────────────────────────────────────────────────── // Body parsers // JSON body parser for most routes // urlencoded parser for token endpoint (application/x-www-form-urlencoded) // ──────────────────────────────────────────────────────────────── app.use(express.json()); app.use(express.urlencoded({ extended: false })); // ──────────────────────────────────────────────────────────────── // Prometheus HTTP metrics middleware — must be before all routes // ──────────────────────────────────────────────────────────────── app.use(metricsMiddleware); // ──────────────────────────────────────────────────────────────── // Infrastructure singletons // ──────────────────────────────────────────────────────────────── const pool = getPool(); const redis = await getRedisClient(); // ──────────────────────────────────────────────────────────────── // Repository layer // ──────────────────────────────────────────────────────────────── const agentRepo = new AgentRepository(pool); const credentialRepo = new CredentialRepository(pool); // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion const tokenRepo = new TokenRepository(pool, redis as RedisClientType); const auditRepo = new AuditRepository(pool); // ──────────────────────────────────────────────────────────────── // Optional integrations // ──────────────────────────────────────────────────────────────── // Vault is optional. When VAULT_ADDR + VAULT_TOKEN are set, new credentials // are stored in Vault KV v2. When not set, bcrypt is used (Phase 1 behaviour). const vaultClient = createVaultClientFromEnv(); if (vaultClient !== null) { console.log('[AgentIdP] Vault integration enabled — new credentials will use Vault KV v2'); } // ──────────────────────────────────────────────────────────────── // Service layer // ──────────────────────────────────────────────────────────────── const auditService = new AuditService(auditRepo); const agentService = new AgentService(agentRepo, credentialRepo, auditService); const credentialService = new CredentialService(credentialRepo, agentRepo, auditService, vaultClient); const privateKey = process.env['JWT_PRIVATE_KEY']; const publicKey = process.env['JWT_PUBLIC_KEY']; if (!privateKey || !publicKey) { throw new Error('JWT_PRIVATE_KEY and JWT_PUBLIC_KEY environment variables are required'); } const oauth2Service = new OAuth2Service( tokenRepo, credentialRepo, agentRepo, auditService, privateKey, publicKey, vaultClient, ); // ──────────────────────────────────────────────────────────────── // OPA authorization middleware (created once — shared across all routers) // ──────────────────────────────────────────────────────────────── const opaMiddleware = await createOpaMiddleware(); // ──────────────────────────────────────────────────────────────── // Controller layer // ──────────────────────────────────────────────────────────────── const agentController = new AgentController(agentService); const tokenController = new TokenController(oauth2Service); const credentialController = new CredentialController(credentialService); const auditController = new AuditController(auditService); // ──────────────────────────────────────────────────────────────── // Routes // ──────────────────────────────────────────────────────────────── const API_BASE = '/api/v1'; // Health check — unauthenticated, no OPA app.use('/health', createHealthRouter(pool, redis as RedisClientType)); // Prometheus metrics — unauthenticated, internal scraping only app.use('/metrics', createMetricsRouter()); app.use(`${API_BASE}/agents`, createAgentsRouter(agentController, opaMiddleware)); app.use( `${API_BASE}/agents/:agentId/credentials`, createCredentialsRouter(credentialController, opaMiddleware), ); app.use(`${API_BASE}/token`, createTokenRouter(tokenController, opaMiddleware)); app.use(`${API_BASE}/audit`, createAuditRouter(auditController, opaMiddleware)); // ──────────────────────────────────────────────────────────────── // Dashboard static assets (served from dashboard/dist/) // Placed after API routes so API routes take precedence. // __dirname is available because the project compiles to CommonJS. // ──────────────────────────────────────────────────────────────── const dashboardDist = path.resolve(__dirname, '../../dashboard/dist'); app.use('/dashboard', express.static(dashboardDist)); // SPA fallback — serve index.html for all /dashboard/* routes not matching a static file app.get('/dashboard/*', (_req, res) => { res.sendFile(path.join(dashboardDist, 'index.html')); }); // ──────────────────────────────────────────────────────────────── // Global error handler (must be last) // ──────────────────────────────────────────────────────────────── app.use(errorHandler); return app; }