First half of Phase 5 P2 (the "structured backend logging" piece;
E2E-in-CI lands separately so the diff stays reviewable).
Adds `lib/logger.ts` exporting a singleton pino instance shared between
Fastify (via `loggerInstance`) and any non-request code path. One
configured logger across the backend means uniform formatting,
redaction, and log-level control:
- LOG_LEVEL env knob (defaults: debug in non-prod, info in prod when
NODE_ENV=production). Documented in `.env.example`.
- Built-in redaction for Authorization / Cookie headers and the
common secret-shaped field names (password, token, refreshToken,
accessToken, csrfToken, JWT_SECRET, CSRF_SECRET, ENCRYPTION_KEY,
COSMOS_KEY, AZURE_CLIENT_SECRET) so an accidental
`req.log.info(req.body)` or `logger.error({ err, config }, …)`
won't dump credentials. This is a backstop, not the primary
defense — call sites should still avoid logging raw config/req.
- JSON to stdout in every environment. Pipe through `pino-pretty`
locally if you want pretty output; we deliberately don't bundle
pino-pretty as a runtime dep.
- `childLogger(module)` helper tags log lines with their origin so
repositories/background workers don't have to repeat the module
name on every line.
Sweeps the runtime `console.error` sites that lose request context
(deployment orchestrator background fire-and-forget, system docker
stats/cleanup, backup CRUD, vm getAllContainers) onto the structured
logger. CLI-only modules (`scripts/run-migrations.ts`,
`migrations/index.ts`, `cosmos-init.ts` startup, `azure-keyvault.ts`,
`config.ts` env warnings, `lib/migrations.ts` no-op message) keep
`console.*` for now — they run before Fastify is up and are queued for
a separate cleanup pass.
Tests, typecheck, lint (0 errors), build green. Coverage gate still
passing (≥95% lines on every gated file).
Generated with [Devin](https://cli.devin.ai/docs)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
302 lines
9.7 KiB
TypeScript
302 lines
9.7 KiB
TypeScript
import Fastify from 'fastify';
|
|
import { config } from './lib/config.js';
|
|
import { logger } from './lib/logger.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 { vmRoutes } from './modules/vm/routes.js';
|
|
import rateLimit from '@fastify/rate-limit';
|
|
import swagger from '@fastify/swagger';
|
|
import swaggerUi from '@fastify/swagger-ui';
|
|
|
|
// Hand Fastify the shared pino instance so its per-request logs go through
|
|
// the same formatter + redaction config as everything else in the backend.
|
|
const fastify = Fastify({
|
|
loggerInstance: logger,
|
|
});
|
|
|
|
// NOTE: there is no Server-Sent-Events log stream. `fastify-sse-v2 ^4` is not
|
|
// compatible with Fastify 5 and was never functional here. The deployment-log
|
|
// endpoint (`GET /api/deployments/:id/logs`) returns JSON; the web client
|
|
// polls it. If a real-time stream is wanted later, ship it explicitly via
|
|
// `reply.raw` or a Fastify-5-compatible plugin and update the docs in the
|
|
// same change.
|
|
|
|
// 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' });
|
|
await fastify.register(vmRoutes, { 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();
|