Files
Autonomous-Bug-Explorer/.ralph/specs/phase-07-api-server.md

6.0 KiB

Phase 7: API Server Refactor + Composition Root

Middleware stack (ORDEN IMPORTA)

// server.ts
export function createServer(deps: ServerDependencies): Express {
  const app = express();

  // 1. Request ID (PRIMERO — todo log necesita esto)
  app.use(requestIdMiddleware);

  // 2. Security headers
  app.use(helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        connectSrc: ["'self'", "ws:", "wss:"],
        scriptSrc: ["'self'", "'unsafe-inline'"],  // para Scalar docs
      },
    },
  }));

  // 3. CORS
  app.use(cors({
    origin: deps.config.cors.origin,
    credentials: true,
  }));

  // 4. Rate limiting global
  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 (SIN auth)
  app.get('/health/live', (_, res) => res.json({ status: 'ok' }));
  app.get('/health/ready', async (_, res) => { /* check DB */ });

  // 7. Auth routes (SIN auth middleware general)
  app.use('/api/auth', deps.authController.router);

  // 8. Auth middleware (TODOS los /api/ a partir de aquí)
  app.use('/api', deps.authMiddleware);

  // 9. Module routes
  app.use('/api', deps.crawlingController.router);
  app.use('/api', deps.findingsController.router);
  app.use('/api', deps.fuzzingController.router);
  // ... más módulos

  // 10. 404 handler
  app.use(notFoundMiddleware);

  // 11. Error handler (SIEMPRE último)
  app.use(globalErrorHandler);

  return app;
}

Error hierarchy

export class AppError extends Error {
  constructor(
    message: string,
    public readonly statusCode: number,
    public readonly code: string,
    public readonly isOperational = true,
  ) { super(message); }
}

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');
  }
}

Global error handler

export function globalErrorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
  const logger = req.log || console; // pino child logger

  if (err instanceof AppError && err.isOperational) {
    logger.warn({ err, statusCode: err.statusCode }, err.message);
    return res.status(err.statusCode).json({
      error: err.message,
      code: err.code,
      ...(err instanceof ValidationError && err.details ? { details: err.details } : {}),
    });
  }

  // Programmer error — log full stack, return generic message
  logger.error({ err }, 'Unhandled error');
  return res.status(500).json({
    error: process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message,
    code: 'INTERNAL_ERROR',
  });
}

Composition root (main.ts)

async function bootstrap() {
  // 1. Config
  const config = loadConfig();

  // 2. Logger
  const logger = createLogger(config);
  logger.info({ port: config.port }, 'Starting ABE...');

  // 3. Database + migrations
  const db = createDatabase(config.db);
  await runMigrations(db, logger);

  // 4. Event bus
  const eventBus = new InProcessEventBus(logger);

  // 5. Storage
  const storage = new LocalStorageProvider(config.storage.path);

  // 6. Repositories
  const sessionRepo = new KyselyCrawlSessionRepository(db);
  const findingRepo = new KyselyFindingRepository(db);
  // ... etc

  // 7. Use cases
  const startCrawl = new StartCrawlCommand(sessionRepo, eventBus);
  const listFindings = new ListFindingsQuery(findingRepo);
  // ... etc

  // 8. Event handlers — subscribe to event bus
  const onAnomalyDetected = new OnAnomalyDetected(findingRepo, eventBus);
  eventBus.subscribe('crawling.anomaly_detected', onAnomalyDetected);
  // ... etc

  // 9. Controllers
  const crawlingController = new CrawlingController(startCrawl, ...);
  const findingsController = new FindingsController(listFindings, ...);
  // ... etc

  // 10. HTTP server
  const app = createServer({ config, authMiddleware, crawlingController, findingsController, ... });
  const httpServer = createServer(app);

  // 11. Socket.io
  const io = new Server(httpServer, { cors: { origin: config.cors.origin } });
  const gateway = new SocketGateway(io, eventBus);

  // 12. Job queue
  const jobQueue = new SQLiteJobQueue(db, logger);
  jobQueue.start();

  // 13. Listen
  httpServer.listen(config.port, config.host, () => {
    logger.info({ port: config.port }, 'ABE server ready');
  });

  // 14. Graceful shutdown
  async function shutdown(signal: string) {
    logger.info({ signal }, 'Shutting down...');
    httpServer.close();
    io.close();
    jobQueue.pause();
    await jobQueue.waitForActive(30000);
    await db.destroy();
    logger.info('Shutdown complete');
    process.exit(0);
  }

  process.on('SIGTERM', () => shutdown('SIGTERM'));
  process.on('SIGINT', () => shutdown('SIGINT'));
}

bootstrap().catch((err) => {
  console.error('Fatal: failed to start ABE', err);
  process.exit(1);
});

IMPORTANTE

  • El código existente en src/server/ debe DEJAR DE USARSE gradualmente
  • Mantener los endpoints viejos funcionando durante la migración
  • Cada controller es una clase con un .router getter que retorna Express.Router
  • NUNCA meter lógica de negocio en controllers — solo parse request → call use case → format response