docs: enterprise refactor plan with ralph specs
This commit is contained in:
216
.ralph/specs/phase-07-api-server.md
Normal file
216
.ralph/specs/phase-07-api-server.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# Phase 7: API Server Refactor + Composition Root
|
||||
|
||||
## Middleware stack (ORDEN IMPORTA)
|
||||
```typescript
|
||||
// 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
|
||||
```typescript
|
||||
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
|
||||
```typescript
|
||||
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)
|
||||
```typescript
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user