Files
sentryagent-idp/src/app.ts
SentryAgent.ai Developer 7441c9f298 fix(vv): resolve all 6 V&V issues — field trial unblocked
All findings from the inaugural LeadValidator audit resolved and
confirmed. Release gate: PASS.

VV_ISSUE_002 (BLOCKER): 15 OpenAPI specs verified present covering
all 20 route groups (46 endpoints documented in docs/openapi/)

VV_ISSUE_003 (MAJOR): Remove any types from src/db/pool.ts —
replaced pool.query shim with unknown[] + Object.defineProperty,
zero any types, eslint-disable suppressions removed

VV_ISSUE_004 (MAJOR): Remove raw Pool from ScaffoldController and
HealthDetailedController — injected AgentRepository/CredentialRepository
and DbProbe interface respectively; added CredentialRepository.findActiveClientId()

VV_ISSUE_005 (MAJOR): Add unit tests for 5 untested services —
ComplianceStatusStore, EventPublisher, MarketplaceService,
OIDCTrustPolicyService, UsageService

VV_ISSUE_006 (MAJOR): Add integration tests for 7 missing route
groups — analytics, billing, tiers, webhooks, marketplace,
oidc-trust-policies, oidc-token-exchange

VV_ISSUE_001 (MINOR): Create missing design.md and tasks.md in 4
OpenSpec archives — all archives now complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 04:52:47 +00:00

410 lines
28 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 Stripe from 'stripe';
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 { OrgRepository } from './repositories/OrgRepository.js';
import { AuditService } from './services/AuditService.js';
import { AgentService } from './services/AgentService.js';
import { AnalyticsService } from './services/AnalyticsService.js';
import { MarketplaceService } from './services/MarketplaceService.js';
import { BillingService } from './services/BillingService.js';
import { UsageService } from './services/UsageService.js';
import { CredentialService } from './services/CredentialService.js';
import { OAuth2Service } from './services/OAuth2Service.js';
import { OrgService } from './services/OrgService.js';
import { DIDService } from './services/DIDService.js';
import { OIDCKeyService } from './services/OIDCKeyService.js';
import { IDTokenService } from './services/IDTokenService.js';
import { FederationService } from './services/FederationService.js';
import { WebhookService } from './services/WebhookService.js';
import { EventPublisher } from './services/EventPublisher.js';
import { WebhookDeliveryWorker } from './workers/WebhookDeliveryWorker.js';
import { createKafkaProducer } from './adapters/KafkaAdapter.js';
import { AnalyticsController } from './controllers/AnalyticsController.js';
import { AgentController } from './controllers/AgentController.js';
import { MarketplaceController } from './controllers/MarketplaceController.js';
import { BillingController } from './controllers/BillingController.js';
import { OIDCTrustPolicyController } from './controllers/OIDCTrustPolicyController.js';
import { OIDCTokenExchangeController } from './controllers/OIDCTokenExchangeController.js';
import { TokenController } from './controllers/TokenController.js';
import { CredentialController } from './controllers/CredentialController.js';
import { AuditController } from './controllers/AuditController.js';
import { OrgController } from './controllers/OrgController.js';
import { DIDController } from './controllers/DIDController.js';
import { OIDCController } from './controllers/OIDCController.js';
import { FederationController } from './controllers/FederationController.js';
import { WebhookController } from './controllers/WebhookController.js';
import { ComplianceController } from './controllers/ComplianceController.js';
import { ComplianceService } from './services/ComplianceService.js';
import { createAnalyticsRouter } from './routes/analytics.js';
import { createAgentsRouter } from './routes/agents.js';
import { createMarketplaceRouter } from './routes/marketplace.js';
import { createBillingRouter } from './routes/billing.js';
import { createOIDCTrustPoliciesRouter } from './routes/oidcTrustPolicies.js';
import { createOIDCTokenExchangeRouter } from './routes/oidcTokenExchange.js';
import { OIDCTrustPolicyService } from './services/OIDCTrustPolicyService.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 { createOrgsRouter } from './routes/organizations.js';
import { createDIDRouter } from './routes/did.js';
import { createOIDCRouter } from './routes/oidc.js';
import { createFederationRouter } from './routes/federation.js';
import { createWebhooksRouter } from './routes/webhooks.js';
import { createComplianceRouter } from './routes/compliance.js';
import { createDelegationRouter } from './routes/delegation.js';
import { DelegationService } from './services/DelegationService.js';
import { DelegationController } from './controllers/DelegationController.js';
import { createScaffoldRouter } from './routes/scaffold.js';
import { ScaffoldService } from './services/ScaffoldService.js';
import { ScaffoldController } from './controllers/ScaffoldController.js';
import { TierService } from './services/TierService.js';
import { TierController } from './controllers/TierController.js';
import { createTiersRouter } from './routes/tiers.js';
import { errorHandler } from './middleware/errorHandler.js';
import { createOpaMiddleware } from './middleware/opa.js';
import { metricsMiddleware } from './middleware/metrics.js';
import { createOrgContextMiddleware } from './middleware/orgContext.js';
import { authMiddleware } from './middleware/auth.js';
import { createUsageMeteringMiddleware, startUsageMeteringFlush } from './middleware/usageMeteringMiddleware.js';
import { createTierEnforcementMiddleware } from './middleware/tierEnforcement.js';
import { tlsEnforcementMiddleware } from './middleware/TLSEnforcementMiddleware.js';
import { createVaultClientFromEnv } from './vault/VaultClient.js';
import { getEncryptionService } from './services/EncryptionService.js';
import { getAuditVerificationService } from './services/AuditVerificationService.js';
import { startSecretsRotationJob } from './jobs/SecretsRotationJob.js';
import { startAuditChainVerificationJob } from './jobs/AuditChainVerificationJob.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();
// ────────────────────────────────────────────────────────────────
// TLS enforcement — MUST be first middleware (SOC 2 CC6.7)
// In production, redirects all non-HTTPS requests to HTTPS.
// ────────────────────────────────────────────────────────────────
app.use(tlsEnforcementMiddleware);
// ────────────────────────────────────────────────────────────────
// 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);
const orgRepo = new OrgRepository(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');
}
// ────────────────────────────────────────────────────────────────
// Kafka (optional) — activated when KAFKA_BROKERS env var is set
// ────────────────────────────────────────────────────────────────
const kafkaProducer = await createKafkaProducer();
if (kafkaProducer !== null) {
console.log('[AgentIdP] Kafka integration enabled — events will be produced to agentidp-events');
}
// ────────────────────────────────────────────────────────────────
// Encryption service — column-level AES-256-CBC (SOC 2 CC6.1)
// Only initialised when Vault is configured (key stored in Vault).
// ────────────────────────────────────────────────────────────────
const encryptionService =
vaultClient !== null ? getEncryptionService(vaultClient) : null;
if (encryptionService !== null) {
console.log('[AgentIdP] EncryptionService enabled — sensitive columns encrypted at rest (SOC 2 CC6.1)');
}
// ────────────────────────────────────────────────────────────────
// Webhook infrastructure
// ────────────────────────────────────────────────────────────────
const redisUrl = process.env['REDIS_URL'] ?? 'redis://localhost:6379';
const webhookService = new WebhookService(pool, vaultClient, redis as RedisClientType, encryptionService);
const webhookWorker = new WebhookDeliveryWorker(pool, vaultClient, redis as RedisClientType, redisUrl);
webhookWorker.start();
const eventPublisher = new EventPublisher(webhookWorker, pool, kafkaProducer);
// ────────────────────────────────────────────────────────────────
// Stripe client + TierService — created early so both BillingService
// and AgentService can receive TierService via constructor injection.
// ────────────────────────────────────────────────────────────────
const stripe = new Stripe(process.env['STRIPE_SECRET_KEY'] ?? '', { apiVersion: '2026-03-25.dahlia' });
const tierService = new TierService(pool, redis as RedisClientType, stripe);
// ────────────────────────────────────────────────────────────────
// Service layer
// ────────────────────────────────────────────────────────────────
const auditService = new AuditService(auditRepo);
const didService = new DIDService(pool, vaultClient, redis as RedisClientType, encryptionService);
// ────────────────────────────────────────────────────────────────
// Phase 6 WS3: Analytics Service
// ────────────────────────────────────────────────────────────────
const analyticsService = new AnalyticsService(pool);
const agentService = new AgentService(agentRepo, credentialRepo, auditService, didService, eventPublisher, analyticsService, tierService);
const marketplaceService = new MarketplaceService(agentRepo);
const credentialService = new CredentialService(credentialRepo, agentRepo, auditService, vaultClient, eventPublisher, encryptionService);
const orgService = new OrgService(orgRepo, agentRepo);
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');
}
// OIDC services — initialised after DB pool is ready
const oidcKeyService = new OIDCKeyService(pool, redis as RedisClientType);
await oidcKeyService.ensureCurrentKey();
const idTokenService = new IDTokenService(oidcKeyService);
const oauth2Service = new OAuth2Service(
tokenRepo,
credentialRepo,
agentRepo,
auditService,
privateKey,
publicKey,
vaultClient,
idTokenService,
eventPublisher,
encryptionService,
analyticsService,
);
// ────────────────────────────────────────────────────────────────
// OPA authorization middleware (created once — shared across all routers)
// ────────────────────────────────────────────────────────────────
const opaMiddleware = await createOpaMiddleware();
// ────────────────────────────────────────────────────────────────
// Controller layer
// ────────────────────────────────────────────────────────────────
const agentController = new AgentController(agentService);
const analyticsController = new AnalyticsController(analyticsService);
const tokenController = new TokenController(oauth2Service);
const credentialController = new CredentialController(credentialService);
const auditController = new AuditController(auditService);
const orgController = new OrgController(orgService);
const didController = new DIDController(didService, agentRepo);
const oidcController = new OIDCController(oidcKeyService, agentRepo);
const federationService = new FederationService(pool, redis as RedisClientType);
const federationController = new FederationController(federationService);
const webhookController = new WebhookController(webhookService);
const marketplaceController = new MarketplaceController(marketplaceService);
// ────────────────────────────────────────────────────────────────
// Billing & Usage Metering (WS6)
// ────────────────────────────────────────────────────────────────
const billingService = new BillingService(pool, stripe, tierService);
const usageService = new UsageService(pool);
const billingController = new BillingController(billingService, usageService);
// Start periodic flush of in-memory usage counters to DB (every 60s)
startUsageMeteringFlush(pool);
// OIDC trust policy management + GitHub Actions token exchange
const oidcTrustPolicyService = new OIDCTrustPolicyService(pool);
const oidcTrustPolicyController = new OIDCTrustPolicyController(oidcTrustPolicyService);
const oidcTokenExchangeController = new OIDCTokenExchangeController(oidcTrustPolicyService, privateKey);
// ────────────────────────────────────────────────────────────────
// Compliance services and background jobs (SOC 2 Type II)
// ────────────────────────────────────────────────────────────────
const auditVerificationService = getAuditVerificationService(pool);
const complianceService = new ComplianceService(pool, redis as RedisClientType);
const complianceController = new ComplianceController(auditVerificationService, complianceService);
// Start background compliance monitoring jobs (non-blocking)
startSecretsRotationJob(pool);
startAuditChainVerificationJob(auditVerificationService);
// ────────────────────────────────────────────────────────────────
// Org context middleware — sets PostgreSQL session variable app.organization_id
// Must run after auth (so req.user is populated) and before route handlers.
// Applied globally here; routes that don't require auth skip it gracefully.
// ────────────────────────────────────────────────────────────────
app.use(createOrgContextMiddleware(pool));
// ────────────────────────────────────────────────────────────────
// Usage metering — records per-tenant API call counts in-memory
// Applied after auth middleware so req.user is populated.
// ────────────────────────────────────────────────────────────────
app.use(createUsageMeteringMiddleware(pool));
// ────────────────────────────────────────────────────────────────
// Tier enforcement — Redis-backed daily API call rate limits per
// tenant tier (free/pro/enterprise). Runs after auth; skipped when
// TIER_ENFORCEMENT=false or for enterprise tenants. Supersedes
// the legacy freeTierEnforcementMiddleware (removed Phase 6 WS4).
// ────────────────────────────────────────────────────────────────
app.use(createTierEnforcementMiddleware(pool, redis as RedisClientType));
// ────────────────────────────────────────────────────────────────
// 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());
// Well-known DID Document for the AgentIdP instance — unauthenticated
app.get('/.well-known/did.json', (req, res, next) => {
void didController.getInstanceDIDDocument(req, res, next);
});
// OIDC well-known endpoints and agent-info — mounted at root so /.well-known/* paths resolve
app.use('/', createOIDCRouter(oidcController, authMiddleware));
app.use(`${API_BASE}/agents`, createAgentsRouter(agentController, opaMiddleware));
app.use(`${API_BASE}`, createDIDRouter(didController, authMiddleware, 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));
app.use(`${API_BASE}/organizations`, createOrgsRouter(orgController, opaMiddleware));
app.use(`${API_BASE}`, createFederationRouter(federationController, authMiddleware, opaMiddleware));
app.use(`${API_BASE}/webhooks`, createWebhooksRouter(webhookController, authMiddleware, opaMiddleware));
app.use(`${API_BASE}`, createComplianceRouter(complianceController));
app.use(`${API_BASE}/marketplace`, createMarketplaceRouter(marketplaceController));
// Billing & Usage Metering — checkout, webhook, usage summary
app.use(`${API_BASE}/billing`, createBillingRouter(billingController, authMiddleware));
// ────────────────────────────────────────────────────────────────
// Phase 6 WS4: Tier management — status and upgrade endpoints
// ────────────────────────────────────────────────────────────────
const tierController = new TierController(tierService);
app.use(`${API_BASE}/tiers`, createTiersRouter(tierController, authMiddleware));
// OIDC trust-policy management (authenticated) and token exchange (unauthenticated)
// Both routers mount under ${API_BASE}/oidc — trust-policy routes use /trust-policies prefix,
// token exchange uses /token, so there are no path conflicts.
app.use(`${API_BASE}/oidc`, createOIDCTrustPoliciesRouter(oidcTrustPolicyController, authMiddleware));
app.use(`${API_BASE}/oidc`, createOIDCTokenExchangeRouter(oidcTokenExchangeController));
// ────────────────────────────────────────────────────────────────
// Phase 5 WS2: A2A Delegation (guarded by A2A_ENABLED flag)
// ────────────────────────────────────────────────────────────────
if (process.env['A2A_ENABLED'] !== 'false') {
const delegationService = new DelegationService(pool, auditService);
const delegationController = new DelegationController(delegationService);
app.use(`${API_BASE}`, createDelegationRouter(delegationController, authMiddleware));
}
// ────────────────────────────────────────────────────────────────
// Phase 6 WS3: Analytics (guarded by ANALYTICS_ENABLED flag)
// When disabled, all /api/v1/analytics/* routes return 404.
// ────────────────────────────────────────────────────────────────
if (process.env['ANALYTICS_ENABLED'] !== 'false') {
app.use(`${API_BASE}/analytics`, createAnalyticsRouter(analyticsController, authMiddleware));
}
// ────────────────────────────────────────────────────────────────
// Phase 5 WS5: Scaffold Generator
// ────────────────────────────────────────────────────────────────
const scaffoldService = new ScaffoldService();
const scaffoldController = new ScaffoldController(scaffoldService, agentRepo, credentialRepo, auditService);
app.use(`${API_BASE}`, createScaffoldRouter(scaffoldController, authMiddleware));
// ────────────────────────────────────────────────────────────────
// 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;
}