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

76
dist/api/middleware/errorHandler.js vendored Normal file
View File

@@ -0,0 +1,76 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.RateLimitError = exports.ConflictError = exports.NotFoundError = exports.ForbiddenError = exports.AuthenticationError = exports.ValidationError = exports.AppError = void 0;
exports.globalErrorHandler = globalErrorHandler;
class AppError extends Error {
constructor(message, statusCode, code, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = isOperational;
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
exports.AppError = AppError;
class ValidationError extends AppError {
constructor(message, details) {
super(message, 400, 'VALIDATION_ERROR');
this.details = details;
}
}
exports.ValidationError = ValidationError;
class AuthenticationError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401, 'AUTHENTICATION_ERROR');
}
}
exports.AuthenticationError = AuthenticationError;
class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403, 'FORBIDDEN');
}
}
exports.ForbiddenError = ForbiddenError;
class NotFoundError extends AppError {
constructor(resource) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
exports.NotFoundError = NotFoundError;
class ConflictError extends AppError {
constructor(message) {
super(message, 409, 'CONFLICT');
}
}
exports.ConflictError = ConflictError;
class RateLimitError extends AppError {
constructor() {
super('Too many requests', 429, 'RATE_LIMIT');
}
}
exports.RateLimitError = RateLimitError;
function globalErrorHandler(err, req, res, _next) {
const logger = req.log;
if (err instanceof AppError && err.isOperational) {
if (logger) {
logger.warn({ err, statusCode: err.statusCode }, err.message);
}
const body = { 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',
});
}

9
dist/api/middleware/notFound.js vendored Normal file
View File

@@ -0,0 +1,9 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.notFoundMiddleware = notFoundMiddleware;
function notFoundMiddleware(req, res) {
res.status(404).json({
error: `Route ${req.method} ${req.path} not found`,
code: 'NOT_FOUND',
});
}

11
dist/api/middleware/requestId.js vendored Normal file
View File

@@ -0,0 +1,11 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createRequestIdMiddleware = createRequestIdMiddleware;
const uuid_1 = require("uuid");
function createRequestIdMiddleware(logger) {
return (req, _res, next) => {
req.id = req.headers['x-request-id'] ?? (0, uuid_1.v4)();
req.log = logger.child({ requestId: req.id, method: req.method, url: req.url });
next();
};
}

17
dist/api/router.js vendored Normal file
View File

@@ -0,0 +1,17 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createRouter = createRouter;
/**
* ABE API Router — registers all module routes.
*/
const express_1 = require("express");
const CrawlingController_1 = require("../modules/crawling/infrastructure/http/CrawlingController");
const FindingsController_1 = require("../modules/findings/infrastructure/http/FindingsController");
const FuzzingController_1 = require("../modules/fuzzing/infrastructure/http/FuzzingController");
function createRouter(deps) {
const router = (0, express_1.Router)();
router.use('/sessions', (0, CrawlingController_1.createCrawlingRouter)(deps.crawlingDeps));
router.use('/findings', (0, FindingsController_1.createFindingsRouter)(deps.findingsDeps));
router.use('/fuzz', (0, FuzzingController_1.createFuzzingRouter)(deps.fuzzingDeps));
return router;
}

64
dist/api/server.js vendored Normal file
View File

@@ -0,0 +1,64 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createServer = createServer;
/**
* ABE API Server — Express app factory.
* Middleware order matters: requestId → helmet → cors → rateLimit → body → routes → notFound → errorHandler
*/
const express_1 = __importDefault(require("express"));
const cors_1 = __importDefault(require("cors"));
const helmet_1 = __importDefault(require("helmet"));
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
const requestId_1 = require("./middleware/requestId");
const notFound_1 = require("./middleware/notFound");
const errorHandler_1 = require("./middleware/errorHandler");
const router_1 = require("./router");
function createServer(deps) {
const app = (0, express_1.default)();
// 1. Request ID — must be first so all logs have requestId
app.use((0, requestId_1.createRequestIdMiddleware)(deps.logger));
// 2. Security headers
app.use((0, helmet_1.default)({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
connectSrc: ["'self'", 'ws:', 'wss:'],
scriptSrc: ["'self'", "'unsafe-inline'"],
},
},
}));
// 3. CORS
app.use((0, cors_1.default)({ origin: deps.config.cors.origin, credentials: true }));
// 4. Rate limiting
app.use((0, express_rate_limit_1.default)({
windowMs: deps.config.api.rateLimitWindowMs,
max: deps.config.api.rateLimitMax,
standardHeaders: true,
legacyHeaders: false,
}));
// 5. Body parsing
app.use(express_1.default.json({ limit: '10mb' }));
// 6. Health endpoints — no auth required
app.get('/health/live', (_req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
app.get('/health/ready', async (_req, res) => {
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', (0, router_1.createRouter)(deps));
// 8. 404 handler
app.use(notFound_1.notFoundMiddleware);
// 9. Global error handler — always last
app.use(errorHandler_1.globalErrorHandler);
return app;
}

140
dist/main.js vendored Normal file
View File

@@ -0,0 +1,140 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
/**
* ABE — composition root.
* Wires all modules together and starts the HTTP + WebSocket server.
*/
const http_1 = __importDefault(require("http"));
const socket_io_1 = require("socket.io");
const Config_1 = require("./shared/infrastructure/Config");
const Logger_1 = require("./shared/infrastructure/Logger");
const DatabaseConnection_1 = require("./shared/infrastructure/DatabaseConnection");
const InProcessEventBus_1 = require("./shared/infrastructure/InProcessEventBus");
const migrator_1 = require("./db/migrator");
// Crawling module
const KyselyCrawlSessionRepository_1 = require("./modules/crawling/infrastructure/repositories/KyselyCrawlSessionRepository");
const KyselyStateRepository_1 = require("./modules/crawling/infrastructure/repositories/KyselyStateRepository");
const StartCrawlCommand_1 = require("./modules/crawling/application/commands/StartCrawlCommand");
const StopCrawlCommand_1 = require("./modules/crawling/application/commands/StopCrawlCommand");
const GetSessionQuery_1 = require("./modules/crawling/application/queries/GetSessionQuery");
const ListSessionsQuery_1 = require("./modules/crawling/application/queries/ListSessionsQuery");
// Findings module
const KyselyFindingRepository_1 = require("./modules/findings/infrastructure/repositories/KyselyFindingRepository");
const CreateFindingCommand_1 = require("./modules/findings/application/commands/CreateFindingCommand");
const EnrichFindingCommand_1 = require("./modules/findings/application/commands/EnrichFindingCommand");
const ResolveFindingCommand_1 = require("./modules/findings/application/commands/ResolveFindingCommand");
const GetFindingQuery_1 = require("./modules/findings/application/queries/GetFindingQuery");
const ListFindingsQuery_1 = require("./modules/findings/application/queries/ListFindingsQuery");
const FindingStatsQuery_1 = require("./modules/findings/application/queries/FindingStatsQuery");
const OnAnomalyDetected_1 = require("./modules/findings/application/event-handlers/OnAnomalyDetected");
const NullAIEnricher_1 = require("./modules/findings/infrastructure/NullAIEnricher");
// Fuzzing module
const FuzzingEngineAdapter_1 = require("./modules/fuzzing/infrastructure/adapters/FuzzingEngineAdapter");
const RunFuzzCommand_1 = require("./modules/fuzzing/application/commands/RunFuzzCommand");
const OnActionExecuted_1 = require("./modules/fuzzing/application/event-handlers/OnActionExecuted");
const InMemoryFuzzSessionRepository_1 = require("./modules/fuzzing/infrastructure/repositories/InMemoryFuzzSessionRepository");
// API + Realtime
const server_1 = require("./api/server");
const SocketGateway_1 = require("./realtime/SocketGateway");
async function bootstrap() {
// 1. Config
const config = (0, Config_1.loadConfig)();
// 2. Logger
const logger = (0, Logger_1.createLogger)({ level: config.log.level, nodeEnv: config.nodeEnv });
logger.info({ port: config.port, env: config.nodeEnv }, 'Starting ABE...');
// 3. Database + migrations
const db = (0, DatabaseConnection_1.createDatabase)(config.db);
await (0, migrator_1.runMigrations)(db);
logger.info('Database migrations applied');
// 4. Event bus
const eventBus = new InProcessEventBus_1.InProcessEventBus(logger);
// 5. Repositories
const sessionRepo = new KyselyCrawlSessionRepository_1.KyselyCrawlSessionRepository(db);
const stateRepo = new KyselyStateRepository_1.KyselyStateRepository(db);
const findingRepo = new KyselyFindingRepository_1.KyselyFindingRepository(db);
const fuzzRepo = new InMemoryFuzzSessionRepository_1.InMemoryFuzzSessionRepository();
// Suppress unused warning for stateRepo — used by crawling infrastructure
void stateRepo;
// 6. Crawling use cases
const startCrawl = new StartCrawlCommand_1.StartCrawlCommand(sessionRepo, eventBus);
const stopCrawl = new StopCrawlCommand_1.StopCrawlCommand(sessionRepo, eventBus);
const getSession = new GetSessionQuery_1.GetSessionQuery(sessionRepo);
const listSessions = new ListSessionsQuery_1.ListSessionsQuery(sessionRepo);
// 7. Findings use cases
const createFinding = new CreateFindingCommand_1.CreateFindingCommand(findingRepo, eventBus);
const enricher = new NullAIEnricher_1.NullAIEnricher();
const enrichFinding = new EnrichFindingCommand_1.EnrichFindingCommand(findingRepo, enricher, eventBus);
const resolveFinding = new ResolveFindingCommand_1.ResolveFindingCommand(findingRepo, eventBus);
const getFinding = new GetFindingQuery_1.GetFindingQuery(findingRepo);
const listFindings = new ListFindingsQuery_1.ListFindingsQuery(findingRepo);
const findingStats = new FindingStatsQuery_1.FindingStatsQuery(findingRepo);
// 8. Fuzzing use cases
const fuzzerEngine = new FuzzingEngineAdapter_1.FuzzingEngineAdapter({ intensity: 'low', seed: 42 });
const runFuzz = new RunFuzzCommand_1.RunFuzzCommand(fuzzerEngine, fuzzRepo, eventBus);
// 9. Event handlers — subscribe to EventBus
const onAnomalyDetected = new OnAnomalyDetected_1.OnAnomalyDetected(createFinding);
eventBus.subscribe('crawling.anomaly_detected', onAnomalyDetected);
const onActionExecuted = new OnActionExecuted_1.OnActionExecuted(runFuzz);
eventBus.subscribe('crawling.action_executed', onActionExecuted);
// 10. HTTP server
const app = (0, server_1.createServer)({
config,
logger,
db,
crawlingDeps: { startCrawl, stopCrawl, getSession, listSessions },
findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding },
fuzzingDeps: { runFuzz, repository: fuzzRepo },
});
const httpServer = http_1.default.createServer(app);
// 11. Socket.io + gateway
const io = new socket_io_1.Server(httpServer, {
cors: { origin: config.cors.origin, credentials: true },
});
const gateway = new SocketGateway_1.SocketGateway(io, eventBus, logger);
gateway.start();
// 12. Start listening
await new Promise((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) {
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) {
void shutdown(signal).catch(() => {
process.exit(1);
});
setTimeout(() => {
logger.error('Forced shutdown after 30s');
process.exit(1);
}, 30000).unref();
}
process.on('SIGTERM', () => forceExit('SIGTERM'));
process.on('SIGINT', () => forceExit('SIGINT'));
}
bootstrap().catch((err) => {
console.error('Fatal: failed to start ABE', err);
process.exit(1);
});

View File

@@ -0,0 +1,12 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.NullAIEnricher = void 0;
/**
* NullAIEnricher — no-op enricher used when AI provider is not configured.
*/
class NullAIEnricher {
async enrich(_finding) {
throw new Error('AI enrichment is not configured. Set ABE_AI_PROVIDER to enable it.');
}
}
exports.NullAIEnricher = NullAIEnricher;

View File

@@ -0,0 +1,21 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.InMemoryFuzzSessionRepository = void 0;
/**
* InMemoryFuzzSessionRepository — temporary in-memory store used until Phase 8 adds SQLite persistence.
*/
class InMemoryFuzzSessionRepository {
constructor() {
this.store = new Map();
}
async save(session) {
this.store.set(session.id.toString(), session);
}
async findById(id) {
return this.store.get(id) ?? null;
}
async update(session) {
this.store.set(session.id.toString(), session);
}
}
exports.InMemoryFuzzSessionRepository = InMemoryFuzzSessionRepository;

42
dist/realtime/SocketGateway.js vendored Normal file
View File

@@ -0,0 +1,42 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SocketGateway = void 0;
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',
];
class SocketGateway {
constructor(io, eventBus, logger) {
this.io = io;
this.eventBus = eventBus;
this.logger = logger;
}
start() {
// Subscribe EventBus events → broadcast to all connected clients
for (const eventName of BROADCAST_EVENTS) {
this.eventBus.subscribe(eventName, {
handle: async (event) => {
this.io.emit(eventName, event);
this.logger.debug({ eventName }, 'Socket event broadcast');
},
});
}
this.io.on('connection', (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');
}
}
exports.SocketGateway = SocketGateway;