bytelyst-devops-tools/dashboard/backend/src/server.ts
Hermes VM 1e64d75fd4 feat(dashboard): Phase 5 P2 — structured pino logging with redaction
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>
2026-05-30 07:18:44 +00:00

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();