import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import type { IncomingHttpHeaders } from "node:http"; import { resolve } from "node:path"; import cookie from "@fastify/cookie"; import cors from "@fastify/cors"; import helmet from "@fastify/helmet"; import fastifyMultipart from "@fastify/multipart"; import rateLimit from "@fastify/rate-limit"; import sensible from "@fastify/sensible"; import fastifyStatic from "@fastify/static"; import fastifySwagger from "@fastify/swagger"; import fastifySwaggerUi from "@fastify/swagger-ui"; import Fastify, { type FastifyInstance } from "fastify"; import { migrationsReady } from "./db/client.js"; import { getDataDir } from "./db/db-utils.js"; import { env } from "./plugins/env.js"; import { jwtPlugin } from "./plugins/jwt.js"; import { apiKeyRoutes } from "./routes/api-keys.js"; import { authRoutes } from "./routes/auth.js"; import { doseRoutes } from "./routes/doses.js"; import { exportRoutes } from "./routes/export.js"; import { healthRoutes } from "./routes/health.js"; import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js"; import { medicationRoutes } from "./routes/medications.js"; import { oidcRoutes } from "./routes/oidc.js"; import { plannerRoutes } from "./routes/planner.js"; import { refillRoutes } from "./routes/refills.js"; import { reportRoutes } from "./routes/report.js"; import { settingsRoutes } from "./routes/settings.js"; import { shareRoutes } from "./routes/share.js"; import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js"; import { startMedicationEnrichmentCatalogRefresh } from "./services/medication-enrichment/index.js"; import { startReminderScheduler } from "./services/reminder-scheduler.js"; import { documentationSchemaAjv } from "./utils/documentation-schema-keywords.js"; // Re-export utilities from server-config for external use export { buildAppConfig, buildBaseCookieOptions, buildRefreshCookieOptions, ensureImagesDirectory, getJwtConfig, parseCorsOrigins, } from "./utils/server-config.js"; import { buildAppConfig, buildBaseCookieOptions, buildRefreshCookieOptions, ensureImagesDirectory, getJwtConfig, parseCorsOrigins, } from "./utils/server-config.js"; function sanitizeCorrelationId(headers: IncomingHttpHeaders): string | null { const rawHeader = headers["x-correlation-id"]; if (typeof rawHeader !== "string") return null; const trimmed = rawHeader.trim(); if (!trimmed) return null; if (trimmed.length > 128) return null; if (!/^[A-Za-z0-9._:-]+$/.test(trimmed)) return null; return trimmed; } function buildLoggerOptions(level: string) { const runtimeEnv = process.env.NODE_ENV ?? "production"; const base = { level, timestamp: () => `,"time":"${new Date().toISOString()}"`, }; // Human-readable logs in development, structured JSON in production/test if (runtimeEnv === "development") { return { ...base, transport: { target: "pino-pretty", options: { translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l" } }, }; } return base; } async function registerApiDocs(app: FastifyInstance, enabled: boolean) { if (!enabled) return; await app.register(fastifySwagger, { openapi: { openapi: "3.0.3", info: { title: "MedAssist-ng API", description: "MedAssist-ng backend API", version: process.env.npm_package_version ?? "dev", }, servers: [{ url: "/", description: "Current server" }], tags: [ { name: "health", description: "Service health endpoints" }, { name: "auth", description: "Authentication and profile endpoints" }, { name: "api-keys", description: "Programmatic API key management" }, { name: "medication-enrichment", description: "Medication search and enrichment endpoints" }, { name: "settings", description: "User settings and notification test endpoints" }, ], components: { securitySchemes: { bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "API key or JWT", description: "Use Authorization: Bearer ma_... (API key) or a JWT token.", }, cookieAuth: { type: "apiKey", in: "cookie", name: "access_token", description: "Session cookie set by login.", }, }, }, }, hideUntagged: false, }); await app.register(fastifySwaggerUi, { routePrefix: "/docs", staticCSP: true, transformSpecificationClone: true, uiConfig: { docExpansion: "list", deepLinking: false, }, }); } /** Create and configure Fastify app (without starting) */ export async function createApp(options?: { logLevel?: string; corsOrigins?: string[]; authEnabled?: boolean; jwtSecret?: string; refreshSecret?: string; cookieSecret?: string; accessTtlMinutes?: number; refreshTtlDays?: number; isProduction?: boolean; imagesDir?: string; openApiDocsEnabled?: boolean; }): Promise { const opts = { logLevel: options?.logLevel ?? "info", corsOrigins: options?.corsOrigins ?? ["http://localhost:5173"], authEnabled: options?.authEnabled ?? false, jwtSecret: options?.jwtSecret, refreshSecret: options?.refreshSecret, cookieSecret: options?.cookieSecret ?? "dev-cookie-secret", accessTtlMinutes: options?.accessTtlMinutes ?? 15, refreshTtlDays: options?.refreshTtlDays ?? 7, isProduction: options?.isProduction ?? false, imagesDir: options?.imagesDir ?? resolve(getDataDir(), "images"), openApiDocsEnabled: options?.openApiDocsEnabled ?? false, }; const app = Fastify({ logger: buildLoggerOptions(opts.logLevel), genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(), ajv: documentationSchemaAjv, }); app.addHook("onRequest", (request, reply, done) => { request.correlationId = request.id; reply.header("x-correlation-id", request.id); done(); }); // Build config const appConfig = buildAppConfig({ jwtSecret: opts.jwtSecret, refreshSecret: opts.refreshSecret, accessTtlMinutes: opts.accessTtlMinutes, refreshTtlDays: opts.refreshTtlDays, isProduction: opts.isProduction, }); app.decorate("config", appConfig); // Register plugins await app.register(sensible); await app.register(helmet); await app.register(cors, { origin: opts.corsOrigins, credentials: true }); await app.register(rateLimit, { max: 300, timeWindow: "1 minute" }); await app.register(cookie, { secret: opts.cookieSecret }); // JWT plugin const jwtConfig = getJwtConfig(opts.authEnabled, opts.jwtSecret); await app.register(jwtPlugin, jwtConfig); await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); await registerApiDocs(app, opts.openApiDocsEnabled); // Only register static if directory exists if (existsSync(opts.imagesDir)) { await app.register(fastifyStatic, { root: opts.imagesDir, prefix: "/images/", decorateReply: false, }); } // Register routes await app.register(healthRoutes); await app.register(authRoutes); await app.register(apiKeyRoutes); await app.register(oidcRoutes); await app.register(medicationRoutes); await app.register(medicationEnrichmentRoutes); await app.register(settingsRoutes); await app.register(plannerRoutes); await app.register(shareRoutes); await app.register(doseRoutes); await app.register(exportRoutes); await app.register(refillRoutes); await app.register(reportRoutes); return app; } // ============================================================================= // Server initialization (runs on import) // ============================================================================= import { log } from "./utils/logger.js"; // Wait for database migrations before anything else await migrationsReady; log.info("[DB] Migrations complete, starting server..."); // Ensure images directory exists const imagesDir = ensureImagesDirectory(); const app = Fastify({ logger: buildLoggerOptions(env.LOG_LEVEL), genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(), ajv: documentationSchemaAjv, }); app.addHook("onRequest", (request, reply, done) => { request.correlationId = request.id; reply.header("x-correlation-id", request.id); done(); }); const origins = parseCorsOrigins(env.CORS_ORIGINS); // Auth token TTLs (hardcoded - no need for user configuration) const accessTtlMinutes = env.ACCESS_TOKEN_TTL_MINUTES; // Access token TTL const refreshTtlDays = env.REFRESH_TOKEN_TTL_DAYS; // Refresh token TTL const baseCookieOptions = buildBaseCookieOptions(accessTtlMinutes, env.NODE_ENV === "production"); const refreshCookieOptions = buildRefreshCookieOptions(baseCookieOptions, refreshTtlDays); // Config decorator - only include secrets if auth is enabled app.decorate("config", { accessSecret: env.JWT_SECRET ?? "", refreshSecret: env.REFRESH_SECRET ?? "", accessTtl: accessTtlMinutes, refreshTtl: refreshTtlDays, cookieOptions: baseCookieOptions, refreshCookieOptions, }); await app.register(sensible); await app.register(helmet); await app.register(cors, { origin: origins, credentials: true }); await app.register(rateLimit, { max: Number(process.env.RATE_LIMIT_MAX) || 100, timeWindow: "1 minute", }); await app.register(cookie, { secret: env.COOKIE_SECRET ?? "dev-cookie-secret" }); // JWT plugin - only register with valid secret if auth is enabled const jwtConfig = getJwtConfig(env.AUTH_ENABLED, env.JWT_SECRET); await app.register(jwtPlugin, jwtConfig); await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); // 10MB limit await registerApiDocs(app, env.OPENAPI_DOCS_ENABLED); await app.register(fastifyStatic, { root: imagesDir, prefix: "/images/", decorateReply: false, }); await app.register(healthRoutes); await app.register(authRoutes); await app.register(apiKeyRoutes); await app.register(oidcRoutes); await app.register(medicationRoutes); await app.register(medicationEnrichmentRoutes); await app.register(settingsRoutes); await app.register(plannerRoutes); await app.register(shareRoutes); await app.register(doseRoutes); await app.register(exportRoutes); await app.register(refillRoutes); await app.register(reportRoutes); const start = async () => { try { await app.listen({ port: env.PORT, host: "0.0.0.0" }); app.log.info(`Server running on ${env.PORT}`); // Start the automatic reminder scheduler startReminderScheduler({ info: (msg) => app.log.info(msg), debug: (msg) => app.log.debug(msg), error: (msg) => app.log.error(msg), }); startMedicationEnrichmentCatalogRefresh({ info: (msg: string) => app.log.info(msg), debug: (msg: string) => app.log.debug(msg), warn: (msg: string) => app.log.warn(msg), error: (msg: string) => app.log.error(msg), }); // Start the intake reminder scheduler (checks every minute) startIntakeReminderScheduler({ info: (msg) => app.log.info(msg), debug: (msg) => app.log.debug(msg), error: (msg) => app.log.error(msg), }); } catch (err) { app.log.error(err); process.exit(1); } }; start();