200 lines
8.5 KiB
JavaScript
200 lines
8.5 KiB
JavaScript
"use strict";
|
|
/**
|
|
* ABE API Server
|
|
* Express + socket.io on port 3001.
|
|
* Manages exploration sessions and serves REST + WebSocket API.
|
|
*/
|
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
};
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.createApp = createApp;
|
|
exports.createServer = createServer;
|
|
const express_1 = __importDefault(require("express"));
|
|
const cors_1 = __importDefault(require("cors"));
|
|
const http_1 = __importDefault(require("http"));
|
|
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
|
|
const socket_io_1 = require("socket.io");
|
|
const sessions_1 = require("./routes/sessions");
|
|
const anomalies_1 = require("./routes/anomalies");
|
|
const config_1 = require("./routes/config");
|
|
const schedules_1 = require("./routes/schedules");
|
|
const visual_1 = require("./routes/visual");
|
|
const SessionStore_1 = require("./SessionStore");
|
|
const auth_1 = require("./middleware/auth");
|
|
const logger_1 = require("./logger");
|
|
const AIEnrichmentService_1 = require("./enrichment/AIEnrichmentService");
|
|
const PORT = process.env['ABE_PORT']
|
|
? parseInt(process.env['ABE_PORT'], 10)
|
|
: process.env['PORT']
|
|
? parseInt(process.env['PORT'], 10)
|
|
: 3001;
|
|
function createApp(store, dbCheck, scheduleRepo, scheduler, visualRepo, enrichmentService) {
|
|
const corsOrigin = process.env['ABE_CORS_ORIGIN'] ?? 'http://localhost:5173';
|
|
const app = (0, express_1.default)();
|
|
app.use((0, cors_1.default)({ origin: corsOrigin }));
|
|
app.use(express_1.default.json());
|
|
// Health endpoints — no auth required
|
|
app.get('/health', (_req, res) => {
|
|
const uptime = Math.floor(process.uptime());
|
|
res.json({ status: 'ok', version: '0.1.0', uptime_seconds: uptime });
|
|
});
|
|
app.get('/ready', (_req, res) => {
|
|
const stats = store.getStats();
|
|
if (dbCheck && !dbCheck()) {
|
|
res.status(503).json({ status: 'not_ready', db: 'disconnected', active_sessions: stats.runningSessions });
|
|
return;
|
|
}
|
|
res.json({ status: 'ready', db: 'connected', active_sessions: stats.runningSessions });
|
|
});
|
|
// Apply API key auth to all /api/* routes
|
|
app.use('/api', auth_1.apiKeyAuth);
|
|
// Global rate limit: 200 req/min
|
|
const globalLimiter = (0, express_rate_limit_1.default)({
|
|
windowMs: 60 * 1000,
|
|
max: 200,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
});
|
|
app.use('/api', globalLimiter);
|
|
// POST /api/sessions rate limit: 20/hour
|
|
const sessionCreateLimiter = (0, express_rate_limit_1.default)({
|
|
windowMs: 60 * 60 * 1000,
|
|
max: 20,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
});
|
|
app.post('/api/sessions', sessionCreateLimiter);
|
|
app.get('/api/stats', (_req, res) => {
|
|
res.json(store.getStats());
|
|
});
|
|
app.use('/api/sessions', (0, sessions_1.createSessionRouter)(store));
|
|
app.use('/api/anomalies', (0, anomalies_1.createAnomalyRouter)(store, enrichmentService));
|
|
app.use('/api/config', (0, config_1.createConfigRouter)());
|
|
if (scheduleRepo && scheduler) {
|
|
app.use('/api/schedules', (0, schedules_1.createScheduleRouter)(scheduleRepo, scheduler));
|
|
}
|
|
if (visualRepo) {
|
|
app.use('/api/visual', (0, visual_1.createVisualRouter)(visualRepo));
|
|
}
|
|
// Global error handler
|
|
app.use((err, _req, res, _next) => {
|
|
const isDev = process.env['NODE_ENV'] !== 'production';
|
|
const message = isDev && err instanceof Error ? err.message : 'Internal server error';
|
|
res.status(500).json({
|
|
error: message,
|
|
code: 'INTERNAL_ERROR',
|
|
timestamp: Date.now(),
|
|
});
|
|
});
|
|
return app;
|
|
}
|
|
function createServer(store, dbCheck, scheduleRepo, scheduler, visualRepo) {
|
|
const corsOrigin = process.env['ABE_CORS_ORIGIN'] ?? 'http://localhost:5173';
|
|
// Deferred emitter: AIEnrichmentService is created before io, using a closure
|
|
let ioEmit = () => undefined;
|
|
const enrichmentService = new AIEnrichmentService_1.AIEnrichmentService((event, payload) => ioEmit(event, payload));
|
|
const app = createApp(store, dbCheck, scheduleRepo, scheduler, visualRepo, enrichmentService);
|
|
const httpServer = http_1.default.createServer(app);
|
|
const io = new socket_io_1.Server(httpServer, {
|
|
cors: { origin: corsOrigin },
|
|
});
|
|
// Now wire the real io emitter
|
|
ioEmit = (event, payload) => io.emit(event, payload);
|
|
io.on('connection', (socket) => {
|
|
socket.on('session:stop', (data) => {
|
|
store.stopSession(data.sessionId);
|
|
});
|
|
});
|
|
store.setEmitter((event, payload) => {
|
|
io.emit(event, payload);
|
|
// Auto-enrich high/critical anomalies
|
|
if (event === 'anomaly:detected') {
|
|
const p = payload;
|
|
if (p?.anomalyId) {
|
|
const anomaly = store.getAnomaly(p.anomalyId);
|
|
if (anomaly && enrichmentService.shouldAutoEnrich(anomaly)) {
|
|
const context = {
|
|
domSnapshot: '',
|
|
httpLog: anomaly.evidence.httpLog ?? [],
|
|
consoleErrors: anomaly.evidence.rawErrors ?? [],
|
|
actionTrace: anomaly.actionTrace,
|
|
pageTitle: '',
|
|
url: anomaly.actionTrace[anomaly.actionTrace.length - 1]?.url ?? '',
|
|
};
|
|
void enrichmentService.enrich(anomaly, context);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
return httpServer;
|
|
}
|
|
if (require.main === module) {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const { getDb } = require('../db/connection');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const { SessionRepository } = require('../db/SessionRepository');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const { AnomalyRepository } = require('../db/AnomalyRepository');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const { NotificationService } = require('./notifications/NotificationService');
|
|
const db = getDb();
|
|
const sessionRepo = new SessionRepository(db);
|
|
const anomalyRepo = new AnomalyRepository(db);
|
|
const notificationService = new NotificationService({
|
|
persister: (record) => {
|
|
db.prepare(`INSERT OR REPLACE INTO notifications (id, anomaly_id, channel, status, sent_at, error)
|
|
VALUES (?, ?, ?, ?, ?, ?)`).run(record.id, record.anomalyId, record.channel, record.status, record.sentAt ?? null, record.error ?? null);
|
|
},
|
|
});
|
|
const outputDir = process.env['ABE_REPORTS_DIR'] ?? './reports';
|
|
const maxConcurrent = process.env['ABE_MAX_CONCURRENT_SESSIONS']
|
|
? parseInt(process.env['ABE_MAX_CONCURRENT_SESSIONS'], 10)
|
|
: 3;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const { VisualBaselineRepository: VisualRepo } = require('../db/VisualBaselineRepository');
|
|
const visualRepo = new VisualRepo(db);
|
|
const store = new SessionStore_1.SessionStore(outputDir, sessionRepo, anomalyRepo, maxConcurrent, notificationService, visualRepo);
|
|
const dbCheck = () => { try {
|
|
db.prepare('SELECT 1').run();
|
|
return true;
|
|
}
|
|
catch {
|
|
return false;
|
|
} };
|
|
// Scheduler
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const { ScheduleRepository: SchedRepo } = require('../db/ScheduleRepository');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const { SchedulerService: SchedSvc } = require('./scheduler/SchedulerService');
|
|
const scheduleRepo = new SchedRepo(db);
|
|
const scheduler = new SchedSvc(scheduleRepo, store);
|
|
scheduler.start();
|
|
const server = createServer(store, dbCheck, scheduleRepo, scheduler, visualRepo);
|
|
// Graceful shutdown
|
|
let shuttingDown = false;
|
|
function shutdown(signal) {
|
|
if (shuttingDown)
|
|
return;
|
|
shuttingDown = true;
|
|
logger_1.log.info({ signal }, 'Graceful shutdown initiated');
|
|
scheduler.stop();
|
|
server.close(() => {
|
|
try {
|
|
db.close();
|
|
}
|
|
catch { /* ignore */ }
|
|
process.exit(0);
|
|
});
|
|
setTimeout(() => {
|
|
logger_1.log.error('Forced shutdown after 30s');
|
|
process.exit(1);
|
|
}, 30000);
|
|
}
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
server.listen(PORT, () => {
|
|
logger_1.log.info({ port: PORT }, 'ABE API server listening');
|
|
});
|
|
}
|