295 lines
9.1 KiB
TypeScript
295 lines
9.1 KiB
TypeScript
import Fastify from 'fastify';
|
|
import { config } from './lib/config.js';
|
|
import { initializeContainers } from './lib/cosmos-init.js';
|
|
import { extractAuth, AuthError, requireAdmin } from './lib/auth.js';
|
|
import { generateCsrfToken, validateCsrfToken, getSessionId } from './lib/csrf.js';
|
|
import { serviceRoutes } from './modules/services/routes.js';
|
|
import { deploymentRoutes } from './modules/deployments/routes.js';
|
|
import { healthRoutes } from './modules/health/routes.js';
|
|
import { auditRoutes } from './modules/audit/routes.js';
|
|
import { backupRoutes } from './modules/backup/routes.js';
|
|
import { systemRoutes } from './modules/system/routes.js';
|
|
import { envRoutes } from './modules/env/routes.js';
|
|
import { azureConfigRoutes } from './modules/azure-config/routes.js';
|
|
import { codeQualityRoutes } from './modules/code-quality/routes.js';
|
|
import { cosmosConfigRoutes } from './modules/cosmos-config/routes.js';
|
|
import { hermesOpsRoutes } from './modules/hermes-ops/routes.js';
|
|
// import sse from 'fastify-sse-v2';
|
|
import rateLimit from '@fastify/rate-limit';
|
|
import swagger from '@fastify/swagger';
|
|
import swaggerUi from '@fastify/swagger-ui';
|
|
|
|
const fastify = Fastify({
|
|
logger: true,
|
|
});
|
|
|
|
// Register SSE plugin
|
|
// TODO: fastify-sse-v2 has compatibility issues with Fastify 5
|
|
// await fastify.register(sse);
|
|
|
|
// Register rate limiting
|
|
await fastify.register(rateLimit, {
|
|
max: 100, // 100 requests per window
|
|
timeWindow: '1 minute',
|
|
errorResponseBuilder: (request, context) => ({
|
|
code: 429,
|
|
error: 'Too many requests',
|
|
retryAfter: context.ttl,
|
|
}),
|
|
skipOnError: false,
|
|
});
|
|
|
|
// Register Swagger
|
|
await fastify.register(swagger, {
|
|
openapi: {
|
|
openapi: '3.0.0',
|
|
info: {
|
|
title: 'ByteLyst DevOps API',
|
|
description: 'API for deployment orchestration and service monitoring',
|
|
version: '0.1.0',
|
|
},
|
|
servers: [
|
|
{
|
|
url: 'http://localhost:4004',
|
|
description: 'Development server',
|
|
},
|
|
],
|
|
components: {
|
|
securitySchemes: {
|
|
bearerAuth: {
|
|
type: 'http',
|
|
scheme: 'bearer',
|
|
bearerFormat: 'JWT',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await fastify.register(swaggerUi, {
|
|
routePrefix: '/docs',
|
|
uiConfig: {
|
|
docExpansion: 'list',
|
|
deepLinking: false,
|
|
},
|
|
staticCSP: true,
|
|
transformStaticCSP: (header) => header,
|
|
transformSpecification: (swaggerObject, request, reply) => {
|
|
return swaggerObject;
|
|
},
|
|
transformSpecificationClone: true,
|
|
});
|
|
|
|
// Auth hook - extract user info from JWT
|
|
fastify.addHook('onRequest', async (request, reply) => {
|
|
const auth = await extractAuth(request);
|
|
if (auth) {
|
|
(request as any).authUserId = auth.userId;
|
|
(request as any).authRole = auth.role;
|
|
(request as any).authEmail = auth.email;
|
|
(request as any).authProductId = auth.productId;
|
|
}
|
|
});
|
|
|
|
// CSRF protection hook - validate CSRF tokens for state-changing requests
|
|
fastify.addHook('onRequest', async (request, reply) => {
|
|
const method = request.method;
|
|
const stateChangingMethods = ['POST', 'PUT', 'DELETE', 'PATCH'];
|
|
|
|
if (!stateChangingMethods.includes(method)) {
|
|
return;
|
|
}
|
|
|
|
const sessionId = getSessionId(request);
|
|
if (!sessionId) {
|
|
return reply.code(401).send({ error: 'Unauthorized' });
|
|
}
|
|
|
|
const csrfToken = request.headers['x-csrf-token'] as string;
|
|
if (!csrfToken) {
|
|
return reply.code(403).send({ error: 'CSRF token missing' });
|
|
}
|
|
|
|
if (!validateCsrfToken(csrfToken, sessionId)) {
|
|
return reply.code(403).send({ error: 'Invalid CSRF token' });
|
|
}
|
|
});
|
|
|
|
// Performance monitoring hook
|
|
fastify.addHook('onRequest', async (request, reply) => {
|
|
(request as any).startTime = Date.now();
|
|
});
|
|
|
|
fastify.addHook('onResponse', async (request, reply) => {
|
|
const startTime = (request as any).startTime;
|
|
if (startTime) {
|
|
const duration = Date.now() - startTime;
|
|
const route = request.routeOptions.url || request.url;
|
|
const method = request.method;
|
|
|
|
fastify.log.info({
|
|
method,
|
|
route,
|
|
statusCode: reply.statusCode,
|
|
duration,
|
|
performance: 'slow',
|
|
}, `${method} ${route} - ${reply.statusCode} (${duration}ms)`);
|
|
|
|
// Alert on slow responses (> 1 second)
|
|
if (duration > 1000) {
|
|
fastify.log.warn({
|
|
method,
|
|
route,
|
|
statusCode: reply.statusCode,
|
|
duration,
|
|
alert: 'slow-response',
|
|
}, `Slow response detected: ${method} ${route} took ${duration}ms`);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Error handler for AuthError
|
|
fastify.setErrorHandler((error, request, reply) => {
|
|
if (error instanceof AuthError) {
|
|
return reply.code(error.statusCode).send({ error: error.message });
|
|
}
|
|
|
|
// Default error handling
|
|
reply.code(500).send({ error: 'Internal server error' });
|
|
});
|
|
|
|
// CORS - more secure configuration
|
|
fastify.addHook('onSend', async (request, reply) => {
|
|
const allowedOrigins = [
|
|
'http://localhost:3000',
|
|
'http://localhost:3001',
|
|
'https://devops.bytelyst.com',
|
|
];
|
|
|
|
const origin = request.headers.origin;
|
|
if (origin && allowedOrigins.includes(origin)) {
|
|
reply.header('Access-Control-Allow-Origin', origin);
|
|
}
|
|
|
|
reply.header('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
|
|
reply.header('Access-Control-Allow-Headers', 'Content-Type,Authorization');
|
|
reply.header('Access-Control-Allow-Credentials', 'true');
|
|
reply.header('Access-Control-Max-Age', '86400'); // 24 hours
|
|
|
|
// Security headers
|
|
reply.header('X-Content-Type-Options', 'nosniff');
|
|
reply.header('X-Frame-Options', 'DENY');
|
|
reply.header('X-XSS-Protection', '1; mode=block');
|
|
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
});
|
|
|
|
// Handle OPTIONS preflight requests
|
|
fastify.options('*', async (request, reply) => {
|
|
reply.code(204).send();
|
|
});
|
|
|
|
// Health check
|
|
fastify.get('/health', async () => ({ status: 'ok', service: 'devops-backend' }));
|
|
|
|
// Register standalone routes with /api prefix
|
|
await fastify.register(async function (fastify) {
|
|
// Performance metrics endpoint (admin only) - DEPRECATED: Use /api/system/metrics instead
|
|
fastify.get('/metrics', {
|
|
preHandler: async (req) => requireAdmin(req),
|
|
}, async (request, reply) => {
|
|
try {
|
|
const { getSystemMetrics } = await import('./modules/system/repository.js');
|
|
const metrics = await getSystemMetrics();
|
|
return reply.send(metrics);
|
|
} catch (error) {
|
|
fastify.log.error(error, 'Failed to get metrics');
|
|
return reply.code(500).send({ error: 'Failed to get metrics' });
|
|
}
|
|
});
|
|
|
|
// CSRF token endpoint
|
|
fastify.get('/csrf-token', async (request, reply) => {
|
|
const sessionId = getSessionId(request);
|
|
if (!sessionId) {
|
|
return reply.code(401).send({ error: 'Unauthorized' });
|
|
}
|
|
|
|
const token = generateCsrfToken(sessionId);
|
|
return { csrfToken: token };
|
|
});
|
|
|
|
// Seed default services
|
|
fastify.post('/seed', async (request, reply) => {
|
|
const { createService } = await import('./modules/services/repository.js');
|
|
|
|
const defaultServices = [
|
|
{
|
|
id: 'trading',
|
|
name: 'Investment Trading',
|
|
scriptPath: '../deploy-invttrdg.sh',
|
|
healthUrl: 'https://api.bytelyst.com/invttrdg/health',
|
|
repoPath: '../learning_ai_invt_trdg',
|
|
},
|
|
{
|
|
id: 'notes',
|
|
name: 'Agentic Notes',
|
|
scriptPath: '../deploy-notes.sh',
|
|
healthUrl: 'https://api.notelett.app/health',
|
|
repoPath: '../learning_ai_notes',
|
|
},
|
|
{
|
|
id: 'clock',
|
|
name: 'AI Clock',
|
|
scriptPath: '../deploy-clock.sh',
|
|
healthUrl: 'https://api.clock.bytelyst.com/health',
|
|
repoPath: '../learning_ai_clock',
|
|
},
|
|
];
|
|
|
|
for (const serviceData of defaultServices) {
|
|
try {
|
|
await createService(serviceData);
|
|
} catch (error) {
|
|
fastify.log.info({ serviceId: serviceData.id }, 'Service might already exist');
|
|
}
|
|
}
|
|
|
|
return reply.send({ message: 'Seeded default services' });
|
|
});
|
|
}, { prefix: '/api' });
|
|
|
|
// Register modular routes with /api prefix
|
|
await fastify.register(serviceRoutes, { prefix: '/api' });
|
|
await fastify.register(deploymentRoutes, { prefix: '/api' });
|
|
await fastify.register(healthRoutes, { prefix: '/api' });
|
|
await fastify.register(auditRoutes, { prefix: '/api' });
|
|
await fastify.register(backupRoutes, { prefix: '/api' });
|
|
await fastify.register(systemRoutes, { prefix: '/api' });
|
|
await fastify.register(envRoutes, { prefix: '/api' });
|
|
await fastify.register(azureConfigRoutes, { prefix: '/api' });
|
|
await fastify.register(codeQualityRoutes, { prefix: '/api' });
|
|
await fastify.register(cosmosConfigRoutes, { prefix: '/api' });
|
|
await fastify.register(hermesOpsRoutes, { prefix: '/api' });
|
|
|
|
// Start server
|
|
async function start() {
|
|
try {
|
|
// Try to initialize Cosmos containers, but allow server to start even if it fails
|
|
try {
|
|
await initializeContainers();
|
|
fastify.log.info('Cosmos containers initialized successfully');
|
|
} catch (err) {
|
|
fastify.log.warn(err, 'Failed to initialize Cosmos containers (server will start anyway)');
|
|
}
|
|
|
|
await fastify.listen({ port: parseInt(config.PORT), host: '0.0.0.0' });
|
|
fastify.log.info({ port: config.PORT }, 'DevOps backend listening');
|
|
} catch (err) {
|
|
fastify.log.error(err);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
start();
|