fase(7): api server refactor with composition root

This commit is contained in:
debian
2026-03-05 09:36:28 -05:00
parent e746dc0497
commit f01acfe985
20 changed files with 861 additions and 2 deletions

View File

@@ -0,0 +1,82 @@
import { Request, Response, NextFunction } from 'express';
export class AppError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly code: string,
public readonly isOperational = true,
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
export class ValidationError extends AppError {
constructor(message: string, public readonly details?: unknown) {
super(message, 400, 'VALIDATION_ERROR');
}
}
export class AuthenticationError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401, 'AUTHENTICATION_ERROR');
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403, 'FORBIDDEN');
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 409, 'CONFLICT');
}
}
export class RateLimitError extends AppError {
constructor() {
super('Too many requests', 429, 'RATE_LIMIT');
}
}
export function globalErrorHandler(
err: Error,
req: Request,
res: Response,
_next: NextFunction,
): void {
const logger = (req as Request & { log?: { warn: Function; error: Function } }).log;
if (err instanceof AppError && err.isOperational) {
if (logger) {
logger.warn({ err, statusCode: err.statusCode }, err.message);
}
const body: Record<string, unknown> = { error: err.message, code: err.code };
if (err instanceof ValidationError && err.details !== undefined) {
body['details'] = err.details;
}
res.status(err.statusCode).json(body);
return;
}
if (logger) {
logger.error({ err }, 'Unhandled error');
} else {
console.error('Unhandled error', err);
}
res.status(500).json({
error: process.env['NODE_ENV'] === 'production' ? 'Internal server error' : err.message,
code: 'INTERNAL_ERROR',
});
}

View File

@@ -0,0 +1,8 @@
import { Request, Response } from 'express';
export function notFoundMiddleware(req: Request, res: Response): void {
res.status(404).json({
error: `Route ${req.method} ${req.path} not found`,
code: 'NOT_FOUND',
});
}

View File

@@ -0,0 +1,21 @@
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { Logger } from '../../shared/infrastructure/Logger';
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
id: string;
log: Logger;
}
}
}
export function createRequestIdMiddleware(logger: Logger) {
return (req: Request, _res: Response, next: NextFunction): void => {
req.id = (req.headers['x-request-id'] as string | undefined) ?? uuidv4();
req.log = logger.child({ requestId: req.id, method: req.method, url: req.url });
next();
};
}

18
src/api/router.ts Normal file
View File

@@ -0,0 +1,18 @@
/**
* ABE API Router — registers all module routes.
*/
import { Router } from 'express';
import { createCrawlingRouter } from '../modules/crawling/infrastructure/http/CrawlingController';
import { createFindingsRouter } from '../modules/findings/infrastructure/http/FindingsController';
import { createFuzzingRouter } from '../modules/fuzzing/infrastructure/http/FuzzingController';
import { ServerDependencies } from './server';
export function createRouter(deps: ServerDependencies): Router {
const router = Router();
router.use('/sessions', createCrawlingRouter(deps.crawlingDeps));
router.use('/findings', createFindingsRouter(deps.findingsDeps));
router.use('/fuzz', createFuzzingRouter(deps.fuzzingDeps));
return router;
}

89
src/api/server.ts Normal file
View File

@@ -0,0 +1,89 @@
/**
* ABE API Server — Express app factory.
* Middleware order matters: requestId → helmet → cors → rateLimit → body → routes → notFound → errorHandler
*/
import express, { Express, Request, Response } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import { Kysely } from 'kysely';
import { AppConfig } from '../shared/infrastructure/Config';
import { Logger } from '../shared/infrastructure/Logger';
import { Database } from '../shared/infrastructure/DatabaseConnection';
import { createRequestIdMiddleware } from './middleware/requestId';
import { notFoundMiddleware } from './middleware/notFound';
import { globalErrorHandler } from './middleware/errorHandler';
import { createRouter } from './router';
import { CrawlingControllerDeps } from '../modules/crawling/infrastructure/http/CrawlingController';
import { FindingsControllerDeps } from '../modules/findings/infrastructure/http/FindingsController';
import { FuzzingControllerDeps } from '../modules/fuzzing/infrastructure/http/FuzzingController';
export interface ServerDependencies {
config: AppConfig;
logger: Logger;
db: Kysely<Database>;
crawlingDeps: CrawlingControllerDeps;
findingsDeps: FindingsControllerDeps;
fuzzingDeps: FuzzingControllerDeps;
}
export function createServer(deps: ServerDependencies): Express {
const app = express();
// 1. Request ID — must be first so all logs have requestId
app.use(createRequestIdMiddleware(deps.logger));
// 2. Security headers
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
connectSrc: ["'self'", 'ws:', 'wss:'],
scriptSrc: ["'self'", "'unsafe-inline'"],
},
},
}),
);
// 3. CORS
app.use(cors({ origin: deps.config.cors.origin, credentials: true }));
// 4. Rate limiting
app.use(
rateLimit({
windowMs: deps.config.api.rateLimitWindowMs,
max: deps.config.api.rateLimitMax,
standardHeaders: true,
legacyHeaders: false,
}),
);
// 5. Body parsing
app.use(express.json({ limit: '10mb' }));
// 6. Health endpoints — no auth required
app.get('/health/live', (_req: Request, res: Response) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
app.get('/health/ready', async (_req: Request, res: Response) => {
try {
await deps.db.selectFrom('sessions').select('id').limit(1).execute();
res.json({ status: 'ready', db: 'connected' });
} catch (err) {
res.status(503).json({ status: 'not_ready', db: 'disconnected', error: String(err) });
}
});
// 7. Module routes
app.use('/api', createRouter(deps));
// 8. 404 handler
app.use(notFoundMiddleware);
// 9. Global error handler — always last
app.use(globalErrorHandler);
return app;
}

164
src/main.ts Normal file
View File

@@ -0,0 +1,164 @@
/**
* ABE — composition root.
* Wires all modules together and starts the HTTP + WebSocket server.
*/
import http from 'http';
import { Server as SocketIOServer } from 'socket.io';
import { loadConfig } from './shared/infrastructure/Config';
import { createLogger } from './shared/infrastructure/Logger';
import { createDatabase } from './shared/infrastructure/DatabaseConnection';
import { InProcessEventBus } from './shared/infrastructure/InProcessEventBus';
import { runMigrations } from './db/migrator';
// Crawling module
import { KyselyCrawlSessionRepository } from './modules/crawling/infrastructure/repositories/KyselyCrawlSessionRepository';
import { KyselyStateRepository } from './modules/crawling/infrastructure/repositories/KyselyStateRepository';
import { StartCrawlCommand } from './modules/crawling/application/commands/StartCrawlCommand';
import { StopCrawlCommand } from './modules/crawling/application/commands/StopCrawlCommand';
import { GetSessionQuery } from './modules/crawling/application/queries/GetSessionQuery';
import { ListSessionsQuery } from './modules/crawling/application/queries/ListSessionsQuery';
// Findings module
import { KyselyFindingRepository } from './modules/findings/infrastructure/repositories/KyselyFindingRepository';
import { CreateFindingCommand } from './modules/findings/application/commands/CreateFindingCommand';
import { EnrichFindingCommand } from './modules/findings/application/commands/EnrichFindingCommand';
import { ResolveFindingCommand } from './modules/findings/application/commands/ResolveFindingCommand';
import { GetFindingQuery } from './modules/findings/application/queries/GetFindingQuery';
import { ListFindingsQuery } from './modules/findings/application/queries/ListFindingsQuery';
import { FindingStatsQuery } from './modules/findings/application/queries/FindingStatsQuery';
import { OnAnomalyDetected } from './modules/findings/application/event-handlers/OnAnomalyDetected';
import { NullAIEnricher } from './modules/findings/infrastructure/NullAIEnricher';
// Fuzzing module
import { FuzzingEngineAdapter } from './modules/fuzzing/infrastructure/adapters/FuzzingEngineAdapter';
import { RunFuzzCommand } from './modules/fuzzing/application/commands/RunFuzzCommand';
import { OnActionExecuted } from './modules/fuzzing/application/event-handlers/OnActionExecuted';
import { InMemoryFuzzSessionRepository } from './modules/fuzzing/infrastructure/repositories/InMemoryFuzzSessionRepository';
// API + Realtime
import { createServer } from './api/server';
import { SocketGateway } from './realtime/SocketGateway';
async function bootstrap(): Promise<void> {
// 1. Config
const config = loadConfig();
// 2. Logger
const logger = createLogger({ level: config.log.level, nodeEnv: config.nodeEnv });
logger.info({ port: config.port, env: config.nodeEnv }, 'Starting ABE...');
// 3. Database + migrations
const db = createDatabase(config.db);
await runMigrations(db);
logger.info('Database migrations applied');
// 4. Event bus
const eventBus = new InProcessEventBus(logger);
// 5. Repositories
const sessionRepo = new KyselyCrawlSessionRepository(db);
const stateRepo = new KyselyStateRepository(db);
const findingRepo = new KyselyFindingRepository(db);
const fuzzRepo = new InMemoryFuzzSessionRepository();
// Suppress unused warning for stateRepo — used by crawling infrastructure
void stateRepo;
// 6. Crawling use cases
const startCrawl = new StartCrawlCommand(sessionRepo, eventBus);
const stopCrawl = new StopCrawlCommand(sessionRepo, eventBus);
const getSession = new GetSessionQuery(sessionRepo);
const listSessions = new ListSessionsQuery(sessionRepo);
// 7. Findings use cases
const createFinding = new CreateFindingCommand(findingRepo, eventBus);
const enricher = new NullAIEnricher();
const enrichFinding = new EnrichFindingCommand(findingRepo, enricher, eventBus);
const resolveFinding = new ResolveFindingCommand(findingRepo, eventBus);
const getFinding = new GetFindingQuery(findingRepo);
const listFindings = new ListFindingsQuery(findingRepo);
const findingStats = new FindingStatsQuery(findingRepo);
// 8. Fuzzing use cases
const fuzzerEngine = new FuzzingEngineAdapter({ intensity: 'low', seed: 42 });
const runFuzz = new RunFuzzCommand(fuzzerEngine, fuzzRepo, eventBus);
// 9. Event handlers — subscribe to EventBus
const onAnomalyDetected = new OnAnomalyDetected(createFinding);
eventBus.subscribe('crawling.anomaly_detected', onAnomalyDetected);
const onActionExecuted = new OnActionExecuted(runFuzz);
eventBus.subscribe('crawling.action_executed', onActionExecuted);
// 10. HTTP server
const app = createServer({
config,
logger,
db,
crawlingDeps: { startCrawl, stopCrawl, getSession, listSessions },
findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding },
fuzzingDeps: { runFuzz, repository: fuzzRepo },
});
const httpServer = http.createServer(app);
// 11. Socket.io + gateway
const io = new SocketIOServer(httpServer, {
cors: { origin: config.cors.origin, credentials: true },
});
const gateway = new SocketGateway(io, eventBus, logger);
gateway.start();
// 12. Start listening
await new Promise<void>((resolve) => {
httpServer.listen(config.port, config.host, resolve);
});
logger.info({ port: config.port, host: config.host }, 'ABE server ready');
// 13. Graceful shutdown
let shuttingDown = false;
async function shutdown(signal: string): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
logger.info({ signal }, 'Shutting down...');
// Stop accepting new connections
httpServer.close();
// Close socket.io
io.close();
// Close database
try {
await db.destroy();
} catch (err) {
logger.warn({ err }, 'Error closing database');
}
logger.info('Shutdown complete');
process.exit(0);
}
// Force-exit if graceful shutdown takes too long
function forceExit(signal: string): void {
void shutdown(signal).catch(() => {
process.exit(1);
});
setTimeout(() => {
logger.error('Forced shutdown after 30s');
process.exit(1);
}, 30_000).unref();
}
process.on('SIGTERM', () => forceExit('SIGTERM'));
process.on('SIGINT', () => forceExit('SIGINT'));
}
bootstrap().catch((err: unknown) => {
console.error('Fatal: failed to start ABE', err);
process.exit(1);
});

View File

@@ -0,0 +1,12 @@
import { IAIEnricher } from '../domain/ports/IAIEnricher';
import { Finding } from '../domain/entities/Finding';
import { IAIEnrichment } from '../../../core/interfaces';
/**
* NullAIEnricher — no-op enricher used when AI provider is not configured.
*/
export class NullAIEnricher implements IAIEnricher {
async enrich(_finding: Finding): Promise<IAIEnrichment> {
throw new Error('AI enrichment is not configured. Set ABE_AI_PROVIDER to enable it.');
}
}

View File

@@ -0,0 +1,21 @@
import { IFuzzSessionRepository } from '../../application/commands/RunFuzzCommand';
import { FuzzSession } from '../../domain/entities/FuzzSession';
/**
* InMemoryFuzzSessionRepository — temporary in-memory store used until Phase 8 adds SQLite persistence.
*/
export class InMemoryFuzzSessionRepository implements IFuzzSessionRepository {
private readonly store = new Map<string, FuzzSession>();
async save(session: FuzzSession): Promise<void> {
this.store.set(session.id.toString(), session);
}
async findById(id: string): Promise<FuzzSession | null> {
return this.store.get(id) ?? null;
}
async update(session: FuzzSession): Promise<void> {
this.store.set(session.id.toString(), session);
}
}

View File

@@ -0,0 +1,52 @@
/**
* SocketGateway — bridges EventBus domain events to socket.io clients.
* Subscribes to all relevant events on start and broadcasts them.
*/
import { Server as SocketIOServer, Socket } from 'socket.io';
import { EventBus } from '../shared/application/EventBus';
import { DomainEvent } from '../shared/domain/DomainEvent';
import { Logger } from '../shared/infrastructure/Logger';
const BROADCAST_EVENTS = [
'crawling.started',
'crawling.state_discovered',
'crawling.action_executed',
'crawling.completed',
'crawling.failed',
'findings.created',
'findings.resolved',
'findings.enriched',
'fuzzing.started',
'fuzzing.vulnerability_detected',
'fuzzing.completed',
];
export class SocketGateway {
constructor(
private readonly io: SocketIOServer,
private readonly eventBus: EventBus,
private readonly logger: Logger,
) {}
start(): void {
// Subscribe EventBus events → broadcast to all connected clients
for (const eventName of BROADCAST_EVENTS) {
this.eventBus.subscribe(eventName, {
handle: async (event: DomainEvent): Promise<void> => {
this.io.emit(eventName, event);
this.logger.debug({ eventName }, 'Socket event broadcast');
},
});
}
this.io.on('connection', (socket: Socket) => {
this.logger.debug({ socketId: socket.id }, 'Client connected');
socket.on('disconnect', () => {
this.logger.debug({ socketId: socket.id }, 'Client disconnected');
});
});
this.logger.info('SocketGateway started');
}
}