fase(7): api server refactor with composition root
This commit is contained in:
@@ -1 +1 @@
|
||||
d62bd615bf6b93b982e19e35b0b49591c648e5d2
|
||||
e746dc049766347ca4d135ec35e86cbef2f90261
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"status": "failed", "timestamp": "2026-03-05 04:07:02"}
|
||||
{"status": "completed", "timestamp": "2026-03-05 09:23:22"}
|
||||
|
||||
76
dist/api/middleware/errorHandler.js
vendored
Normal file
76
dist/api/middleware/errorHandler.js
vendored
Normal 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
9
dist/api/middleware/notFound.js
vendored
Normal 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
11
dist/api/middleware/requestId.js
vendored
Normal 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
17
dist/api/router.js
vendored
Normal 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
64
dist/api/server.js
vendored
Normal 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
140
dist/main.js
vendored
Normal 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);
|
||||
});
|
||||
12
dist/modules/findings/infrastructure/NullAIEnricher.js
vendored
Normal file
12
dist/modules/findings/infrastructure/NullAIEnricher.js
vendored
Normal 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;
|
||||
21
dist/modules/fuzzing/infrastructure/repositories/InMemoryFuzzSessionRepository.js
vendored
Normal file
21
dist/modules/fuzzing/infrastructure/repositories/InMemoryFuzzSessionRepository.js
vendored
Normal 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
42
dist/realtime/SocketGateway.js
vendored
Normal 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;
|
||||
82
src/api/middleware/errorHandler.ts
Normal file
82
src/api/middleware/errorHandler.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
8
src/api/middleware/notFound.ts
Normal file
8
src/api/middleware/notFound.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
21
src/api/middleware/requestId.ts
Normal file
21
src/api/middleware/requestId.ts
Normal 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
18
src/api/router.ts
Normal 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
89
src/api/server.ts
Normal 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
164
src/main.ts
Normal 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);
|
||||
});
|
||||
12
src/modules/findings/infrastructure/NullAIEnricher.ts
Normal file
12
src/modules/findings/infrastructure/NullAIEnricher.ts
Normal 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.');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
52
src/realtime/SocketGateway.ts
Normal file
52
src/realtime/SocketGateway.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user