- Add prom-client 15; shared registry in src/metrics/registry.ts (7 metrics) - HTTP request counter + duration histogram via metricsMiddleware - DB query duration histogram wrapping pg Pool.query - Redis command duration histogram via typed instrumentRedisMethod wrapper - agentidp_tokens_issued_total in OAuth2Service - agentidp_agents_registered_total in AgentService - GET /metrics unauthenticated endpoint (Prometheus text format) - docker-compose.monitoring.yml overlay (Prometheus + Grafana) - Grafana auto-provisioned datasource + pre-built AgentIdP dashboard - docs/devops/operations.md monitoring section added - 36/36 unit tests passing, 100% coverage on new metrics code - Fix pre-existing unused import in tests/integration/agents.test.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
186 lines
11 KiB
TypeScript
186 lines
11 KiB
TypeScript
/**
|
|
* 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<Application> {
|
|
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;
|
|
}
|