require('dotenv').config(); const { version: APP_VERSION } = require('./package.json'); const express = require('express'); const cors = require('cors'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const cookieParser = require('cookie-parser'); const path = require('path'); const http = require('http'); const loggingMiddleware = require('./src/middleware/logging'); const { metricsHandler, metricsMiddleware } = require('./src/utils/metrics'); const app = express(); const server = http.createServer(app); // Trust first proxy (Nginx/Caddy) — required for correct client IP in rate limiting and audit logs app.set('trust proxy', 1); // ============================================================ // SECURITY — Helmet with CSP // ============================================================ app.use(helmet({ crossOriginEmbedderPolicy: false, hsts: { maxAge: 31536000, includeSubDomains: true, preload: true }, contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", 'https://cdn.jsdelivr.net', 'https://cdnjs.cloudflare.com', 'https://challenges.cloudflare.com'], scriptSrcAttr: ["'none'"], styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'], fontSrc: ["'self'", 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'], imgSrc: ["'self'", 'data:', 'blob:'], mediaSrc: ["'self'", 'blob:'], connectSrc: ["'self'", 'https://cdnjs.cloudflare.com', 'https://fonts.googleapis.com', 'https://fonts.gstatic.com', 'https://www.google.com', 'wss://www.google.com', 'https://clinicaltables.nlm.nih.gov', // Cloudflare Turnstile 'https://challenges.cloudflare.com'], workerSrc: ["'self'", 'blob:'], childSrc: ["'self'", 'blob:'], frameSrc: ["'self'", 'https://challenges.cloudflare.com'], objectSrc: ["'none'"], // Disable helmet's default upgrade-insecure-requests so tests can hit // the container over plain HTTP. In production, Caddy terminates TLS // and serves HTTPS — mixed-content is a non-issue there. upgradeInsecureRequests: null, } } })); // ============================================================ // CORS — restrict to APP_URL origin in production // ============================================================ var allowedOrigins = []; if (process.env.APP_URL) allowedOrigins.push(process.env.APP_URL.replace(/\/$/, '')); if (process.env.CORS_ORIGINS) process.env.CORS_ORIGINS.split(',').forEach(function(o) { allowedOrigins.push(o.trim().replace(/\/$/, '')); }); var IS_PROD = process.env.NODE_ENV === 'production' || !!process.env.APP_URL; if (IS_PROD && allowedOrigins.length === 0) { console.error('[FATAL] Production mode but no APP_URL or CORS_ORIGINS set. Refusing to run with open CORS.'); process.exit(1); } // Scoped to /api only — static assets (including type="module" scripts which // always send an Origin header) must not be subject to CORS checks. app.use('/api', cors({ origin: function(origin, callback) { // Allow requests with no origin (mobile apps, curl, server-to-server) if (!origin) return callback(null, true); // Development only: if no origins configured, allow all if (!IS_PROD && allowedOrigins.length === 0) return callback(null, true); if (allowedOrigins.indexOf(origin) !== -1) return callback(null, true); callback(new Error('CORS: origin not allowed')); }, credentials: true, exposedHeaders: ['X-TTS-Provider'] })); app.use(cookieParser()); app.use(metricsMiddleware); // ============================================================ // BODY LIMITS — 10mb for JSON (chart review with many notes can be large), // transcribe uses multipart (25mb limit set in multer) // ============================================================ app.use(express.json({ limit: '10mb' })); // ============================================================ // RATE LIMITING // ============================================================ // General API: 200 req/min default; configurable so the e2e container // can raise it without weakening production. app.use('/api/', rateLimit({ windowMs: 60000, max: parseInt(process.env.API_RATE_LIMIT_MAX || '200', 10), message: { error: 'Too many requests' }, standardHeaders: true, legacyHeaders: false })); // Auth routes: 10 attempts/15min per IP (brute-force protection). // LOGIN_RATE_LIMIT_MAX env var lets the e2e container raise it to a // level that accommodates multi-worker Playwright runs without // weakening production. app.use('/api/auth/login', rateLimit({ windowMs: 15 * 60 * 1000, max: parseInt(process.env.LOGIN_RATE_LIMIT_MAX || '10', 10), message: { error: 'Too many login attempts. Try again in 15 minutes.' }, standardHeaders: true, legacyHeaders: false })); // Asking for a code sends mail to someone else's address, so it is limited more // tightly than a login attempt: the cost of abuse lands on the mailbox owner. // Note these are separate limiters rather than covered by the one above — // Express matches app.use paths on segment boundaries, so '/api/auth/login' // does not match '/api/auth/login-code'. app.use('/api/auth/login-code/request', rateLimit({ windowMs: 60 * 60 * 1000, max: parseInt(process.env.LOGIN_CODE_RATE_LIMIT_MAX || '5', 10), message: { error: 'Too many code requests. Try again later, or sign in with your password.' }, standardHeaders: true, legacyHeaders: false })); app.use('/api/auth/login-code/verify', rateLimit({ windowMs: 15 * 60 * 1000, max: parseInt(process.env.LOGIN_RATE_LIMIT_MAX || '10', 10), message: { error: 'Too many attempts. Try again in 15 minutes.' }, standardHeaders: true, legacyHeaders: false })); app.use('/api/auth/register', rateLimit({ windowMs: 60 * 60 * 1000, max: 5, message: { error: 'Too many registration attempts.' }, standardHeaders: true, legacyHeaders: false })); app.use('/api/auth/forgot-password', rateLimit({ windowMs: 60 * 60 * 1000, max: 5, message: { error: 'Too many password reset attempts.' }, standardHeaders: true, legacyHeaders: false })); app.use('/api/auth/resend-verification', rateLimit({ windowMs: 15 * 60 * 1000, max: 3, message: { error: 'Too many verification requests. Try again in 15 minutes.' }, standardHeaders: true, legacyHeaders: false })); // Post-login sensitive endpoints — protect against brute force with a stolen cookie. // Keyed per-IP; 20 attempts / 15 min covers legitimate use, blocks TOTP brute force (10^6 space). var sensitiveAuthLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20, message: { error: 'Too many attempts. Try again in 15 minutes.' }, standardHeaders: true, legacyHeaders: false }); app.use('/api/auth/change-password', sensitiveAuthLimiter); app.use('/api/auth/setup-2fa', sensitiveAuthLimiter); app.use('/api/auth/verify-2fa', sensitiveAuthLimiter); app.use('/api/auth/disable-2fa', sensitiveAuthLimiter); // Serve .well-known/assetlinks.json for TWA verification (must be before static) app.get('/.well-known/assetlinks.json', (req, res) => { res.setHeader('Content-Type', 'application/json'); res.setHeader('Cache-Control', 'public, max-age=86400'); res.sendFile(path.join(__dirname, 'public', '.well-known', 'assetlinks.json')); }); // ============================================================ // CACHE-BUSTING VERSION STAMP // ============================================================ // Use the full source revision for asset cache busting and /api/build. // Unversioned development builds are explicitly "unknown", never random SHAs. var fs = require('fs'); var BUILD_ID = require('./src/utils/buildId').getBuildId(__dirname); console.log('🔖 Build ID:', BUILD_ID); // Template index.html on each request if the file changed (mtime-based). // Avoids a stale in-memory copy after static edits / script additions while // still being essentially free — just an fs.stat per request, zero template // rebuild when mtime hasn't changed. var INDEX_PATH = path.join(__dirname, 'public', 'index.html'); var _indexCached = { mtime: 0, html: null }; function getTemplatedIndex() { try { var st = fs.statSync(INDEX_PATH); if (st.mtimeMs === _indexCached.mtime && _indexCached.html) return _indexCached.html; var raw = fs.readFileSync(INDEX_PATH, 'utf8'); _indexCached.html = raw.replace( /(<(?:script|link)[^>]+(?:src|href)=["'])(\/(?:js|css)\/[^"'?]+)(?:\?v=[^"']*)?(["'])/g, '$1$2?v=' + BUILD_ID + '$3' ); _indexCached.mtime = st.mtimeMs; return _indexCached.html; } catch (e) { console.warn('[build-id] index.html read failed:', e.message); return null; } } // Prime once at boot so first request is fast getTemplatedIndex(); app.get(['/', '/index.html', '/assistant'], function(req, res) { var html = getTemplatedIndex(); if (!html) return res.sendFile(INDEX_PATH); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('X-Build-Id', BUILD_ID); res.send(html); }); // Public endpoint for cache-bust debugging + build-info app.get('/api/build', function(req, res) { res.json({ buildId: BUILD_ID }); }); // Registers the citation-quality counters at boot. Without this they appear // only after the first answer, so a fresh Grafana panel reads "no data" // instead of zero — which looks like broken wiring rather than a quiet day. require('./src/utils/citationAudit'); // Metrics are for Prometheus, which scrapes pediatric-ai-scribe:3000 directly // over the Docker network. Served unguarded, this endpoint answered on every // public hostname: 201 lines naming routes, traffic volumes, event-loop timings // and process internals to anyone who asked. Nothing here is a credential, but // it is a free map of the application for someone probing it. // // A request that arrived through the reverse proxy carries X-Forwarded-For; // one from a container on our own network does not. That is the distinction // that matters here, and it does not depend on getting a CIDR list right. // METRICS_TOKEN allows an explicit override for scraping from elsewhere. app.get('/metrics', function (req, res, next) { var token = process.env.METRICS_TOKEN; if (token && req.get('authorization') === 'Bearer ' + token) return next(); if (req.get('x-forwarded-for')) return res.status(404).end(); return next(); }, metricsHandler); app.use(loggingMiddleware); app.use('/vendor/dompurify', express.static(path.join(__dirname, 'node_modules', 'dompurify', 'dist'), { setHeaders: function(res) { res.setHeader('Cache-Control', 'public, max-age=3600'); } })); app.use('/vendor/markdown-it', express.static(path.join(__dirname, 'node_modules', 'markdown-it', 'dist'), { setHeaders: function(res) { res.setHeader('Cache-Control', 'public, max-age=3600'); } })); app.use('/vendor/marked', express.static(path.join(__dirname, 'node_modules', 'marked', 'lib'), { setHeaders: function(res) { res.setHeader('Cache-Control', 'public, max-age=3600'); } })); app.use('/vendor/katex', express.static(path.join(__dirname, 'node_modules', 'katex', 'dist'), { setHeaders: function(res) { res.setHeader('Cache-Control', 'public, max-age=3600'); } })); app.use('/vendor/mathjax', express.static(path.join(__dirname, 'node_modules', 'mathjax-full', 'es5'), { setHeaders: function(res) { res.setHeader('Cache-Control', 'public, max-age=3600'); } })); app.use(express.static(path.join(__dirname, 'public'), { setHeaders: (res, filePath) => { if (filePath.endsWith('.html')) { // Never cache HTML — ensures new deployments are picked up immediately res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); } else if (filePath.match(/\.(js|css)$/) || filePath.indexOf('/components/') !== -1) { // Revalidate every time, never serve stale. ES module imports // (clinicalAssistant.js → ./assistant/citations.js) carry no version // query, so the hour of max-age this used to set meant a deployment's // renderer sat behind the old one in every open browser for an hour — // "not fixed" from a reader who had just watched it deploy. no-cache // keeps the ETag round-trip (a 304, a few bytes) and drops the wait. res.setHeader('Cache-Control', 'no-cache'); } } })); // Routes app.use('/api/auth', require('./src/routes/auth')); app.use('/api/auth', require('./src/routes/oidc')); // Config exposes only its authenticated announcement before its own admin guard. app.use('/api/admin', require('./src/routes/adminConfig')); app.use('/api/admin', require('./src/routes/admin')); app.use('/api/admin', require('./src/routes/adminMilestones')); app.use('/api/admin/docs', require('./src/routes/adminDocs')); // Public endpoints — must come BEFORE any router that applies authMiddleware to /api/* const { getAvailableModels, activeProvider: modelsProvider } = require('./src/utils/models'); app.get('/api/models', async (req, res) => { try { var { getAvailableModelsWithOverrides, getEffectiveDefaultModel } = require('./src/utils/models'); var db = require('./src/db/database'); var models = await getAvailableModelsWithOverrides(db); var defaultModel = await getEffectiveDefaultModel(db, models); res.json({ models: models, provider: modelsProvider, defaultModel: defaultModel }); } catch(e) { res.status(503).json({ models: [], provider: modelsProvider, defaultModel: '', error: 'Model policy unavailable' }); } }); const { activeProvider } = require('./src/utils/ai'); // Public health: minimal — just confirm the server is up. app.get('/api/health', (req, res) => { res.json({ ok: true }); }); // Detailed health: admin-only — exposes which providers are configured. const { authMiddleware: _hcAuth, adminMiddleware: _hcAdmin } = require('./src/middleware/auth'); app.get('/api/health/detailed', _hcAuth, _hcAdmin, (req, res) => { res.json({ status: 'running', version: APP_VERSION, provider: activeProvider, timestamp: new Date().toISOString(), openrouter: process.env.OPENROUTER_API_KEY ? 'configured' : 'missing', bedrock: process.env.AWS_BEDROCK_REGION ? 'configured' : 'not configured', azure: process.env.AZURE_OPENAI_ENDPOINT ? 'configured' : 'not configured', litellm: process.env.LITELLM_API_BASE ? 'configured' : 'not configured', // Asked of the same functions the routes ask, so this cannot drift from what // speech actually does. It used to answer from environment variables of its // own and would report tts: 'elevenlabs' for a provider that does not exist // in this app — a health check that lies is worse than one that is absent. stt: require('./src/utils/sttProvider').getSTTProvider(), tts: require('./src/utils/ttsProvider').getTTSProvider() }); }); // Learning Hub routes (all authenticated users can read content & take quizzes) // A person's own generated teaching material. Separate from Learning, which is // moderator-owned and published; this is private to whoever made it. app.use('/api', require('./src/routes/myResources')); // Authenticated feature routes app.use('/api', require('./src/routes/transcribe')); app.use('/api', require('./src/routes/hpi')); app.use('/api', require('./src/routes/hospitalCourse')); app.use('/api', require('./src/routes/chartReview')); app.use('/api', require('./src/routes/milestones')); app.use('/api', require('./src/routes/peGuide')); app.use('/api', require('./src/routes/extensions')); app.use('/api', require('./src/routes/soap')); app.use('/api', require('./src/routes/tts')); app.use('/api', require('./src/routes/nextcloud')); app.use('/api', require('./src/routes/refine')); app.use('/api', require('./src/routes/logs')); app.use('/api', require('./src/routes/encounters')); app.use('/api', require('./src/routes/memories')); app.use('/api', require('./src/routes/notes')); app.use('/api', require('./src/routes/diagrams')); app.use('/api', require('./src/routes/clinicalAssistant')); app.use('/api', require('./src/routes/generatedImages')); var imageWorker = require('./src/utils/generatedImages').service(); imageWorker.start(); app.use('/api', require('./src/routes/documents')); app.use('/api', require('./src/routes/audioBackups')); app.use('/api', require('./src/routes/billing')); app.use('/api/sessions', require('./src/routes/sessions')); app.use('/api', require('./src/routes/wellVisit')); app.use('/api', require('./src/routes/sickVisit')); app.use('/api', require('./src/routes/edEncounters')); app.use('/api', require('./src/routes/dontMiss')); app.use('/api', require('./src/routes/patientEducation')); app.use('/api/user', require('./src/routes/userPreferences')); // ── The API, describing itself ──────────────────────────────────────── // Mounted after every router, because it reads the router stack: anything // added below this line would be missing from the document. Generated per // request rather than cached — it costs a walk of a few hundred layers, and a // cache is one more thing that can be stale, which is the exact failure this // endpoint exists to end. // // Behind auth. The paths are not secret, but there is no third-party consumer // to serve and no reason to hand an unauthenticated visitor a map. app.get('/api/openapi.json', require('./src/middleware/auth').authMiddleware, (req, res) => { try { res.json(require('./src/utils/openapi').document(app, { version: APP_VERSION })); } catch (err) { console.error('[openapi] could not build the document:', err.message); res.status(500).json({ error: 'Could not build the API document' }); } }); app.get('/', (req, res) => { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); res.sendFile(path.join(__dirname, 'public', 'index.html')); }); // 404 handler — must be last app.use((req, res) => { res.status(404).sendFile(path.join(__dirname, 'public', '404.html')); }); // Load prompt DB overrides after DB is ready (3s grace period) const PROMPTS = require('./src/utils/prompts'); const db = require('./src/db/database'); setTimeout(() => { PROMPTS.loadFromDb(db); }, 3000); const PORT = process.env.PORT || 3000; server.listen(PORT, () => { console.log(''); console.log('=========================================='); console.log('🏥 PEDIATRIC AI SCRIBE v' + APP_VERSION); console.log('=========================================='); console.log('🌐 http://localhost:' + PORT); console.log('🤖 Provider: ' + activeProvider); console.log('=========================================='); }); // Graceful shutdown — drain in-flight requests (including note writes) // before Docker kills us. Without this, a `docker restart` mid-save // truncates the write. Express accepts SIGTERM immediately by default. var shuttingDown = false; function shutdown(signal) { if (shuttingDown) return; shuttingDown = true; console.log('[' + signal + '] starting graceful shutdown…'); var imageWorkerStopped = imageWorker.stop(); // Stop accepting new connections; finish the ones already in-flight. server.close(async function(err) { if (err) console.error('[shutdown] server.close error:', err.message); console.log('[shutdown] HTTP server closed.'); // Drain batched audit/api/access log queues before the DB pool ends. try { var queues = require('./src/utils/auditQueue'); if (queues && typeof queues.drainAll === 'function') { await queues.drainAll(); console.log('[shutdown] Audit queues flushed.'); } } catch (e) { console.error('[shutdown] audit drain:', e.message); } // Stop image claims and abort/drain the active worker before closing its DB. await imageWorkerStopped; // Close the Postgres pool so pending queries finish/reject cleanly. try { var dbMod = require('./src/db/database'); // Clear the hourly cleanup interval first — otherwise its handle // keeps the event loop alive past process.exit(0) and Docker // ends up SIGKILL'ing us mid-shutdown at the 10s hard limit. if (dbMod && dbMod._cleanupInterval) clearInterval(dbMod._cleanupInterval); if (dbMod && dbMod.pool && typeof dbMod.pool.end === 'function') { await dbMod.pool.end(); console.log('[shutdown] DB pool drained.'); } } catch (e) {} // Shared corpus client belongs to the app, not to individual user logouts. // Data drainage above takes priority; the existing hard deadline still applies. try { await require('./src/utils/clinicalMcpClient').closeMcpSession(); } catch (e) { console.error('[shutdown] MCP cleanup incomplete'); } process.exit(0); }); // Hard deadline — Docker sends SIGKILL after 10s by default, so beat it // to the punch with a clean exit if we're still hanging. setTimeout(function() { console.error('[shutdown] forcing exit after 9s'); process.exit(1); }, 9000).unref(); } process.on('SIGTERM', function() { shutdown('SIGTERM'); }); process.on('SIGINT', function() { shutdown('SIGINT'); });