pediatric-ai-scribe-v3/server.js
Daniel 63f77aa9cf Batch of security + scale fixes
Age parser (src/routes/billing.js):
  - Now sums year + month + week + day matches so "4 yr 11 mo"
    (59 months) correctly maps to the 5-11y billing bracket instead
    of being billed as 1-4y. Added bounds sanity check.

Graceful SIGTERM shutdown (server.js):
  - Closes the HTTP listener first, then drains batched audit queues,
    then ends the Postgres pool. 9-second hard deadline to beat
    Docker's 10-second SIGKILL. Previously an in-flight note save
    during a container restart could truncate the write.

Explicit LLM fallback opt-in (src/utils/ai.js):
  - The OpenRouter / LiteLLM silent fallback now requires admin
    setting `ai.allow_model_fallback = true` (default: false). If
    primary fails and fallback is disabled, the error is surfaced
    to the caller. Prevents silent spillover from a BAA-covered
    primary to a non-covered fallback.

Prompt injection delimiters (src/utils/promptSafe.js):
  - Wraps user transcripts, dictations, refine-instructions, and
    pasted documents in <UNTRUSTED_*>...</UNTRUSTED_*> tags and
    appends an explicit system instruction telling the model to
    treat the wrapped content as data rather than commands.
  - Applied to soap.js, hpi.js, refine.js. Extend to other AI
    routes incrementally.

Cross-tab logout sync (public/js/authFetch.js, auth.js):
  - BroadcastChannel('pedscribe-auth') — logout in one tab posts
    a message; all sibling tabs clear state and reload, dropping
    any PHI-containing UI immediately.

Backup code race-free consumption (src/routes/auth.js):
  - tryConsumeBackupCode() now uses a Postgres transaction with
    SELECT ... FOR UPDATE so concurrent login attempts using the
    same code serialize. First wins, second sees the already-
    shortened array.

Optimistic encounter locking (migrations/...add-encounter-version):
  - saved_encounters.version INTEGER NOT NULL DEFAULT 1
  - POST /api/encounters/saved accepts an expected_version and
    rejects with 409 if the row has advanced. Falls back to
    last-write-wins if the client doesn't pass one (backward compat).

Audit log batching (src/utils/auditQueue.js):
  - Audit / api_log / access_log writes are buffered in memory and
    flushed every 1s or every 50 entries via one multi-row INSERT.
    Under load this reduces DB pressure by ~50x. On SIGTERM the
    shutdown path drains the queue before exiting.
2026-04-14 05:24:40 +02:00

301 lines
13 KiB
JavaScript

require('dotenv').config();
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 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'"],
// 'wasm-unsafe-eval' required for WebAssembly (Whisper in-browser transcription)
// cdn.jsdelivr.net required for @xenova/transformers worker script
// 'unsafe-eval' needed for transformers.js dynamic imports in worker
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'", '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',
// HuggingFace CDN for Whisper model downloads
'https://huggingface.co', 'https://cdn-lfs.huggingface.co', 'https://cdn-lfs-us-1.huggingface.co', 'https://cdn-lfs-us-2.huggingface.co',
// jsdelivr for worker importScripts
'https://cdn.jsdelivr.net',
// Cloudflare Turnstile
'https://challenges.cloudflare.com'],
workerSrc: ["'self'", 'blob:'],
// Allow workers to load scripts from jsdelivr
childSrc: ["'self'", 'blob:', 'https://cdn.jsdelivr.net'],
frameSrc: ["'self'", 'https://challenges.cloudflare.com'],
objectSrc: ["'none'"],
}
}
}));
// ============================================================
// 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);
}
app.use(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());
// ============================================================
// 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 (increased — app makes many calls on login)
app.use('/api/', rateLimit({
windowMs: 60000, max: 200,
message: { error: 'Too many requests' },
standardHeaders: true, legacyHeaders: false
}));
// Auth routes: 10 attempts/15min per IP (brute-force protection)
app.use('/api/auth/login', rateLimit({
windowMs: 15 * 60 * 1000, max: 10,
message: { error: 'Too many login 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'));
});
app.use(loggingMiddleware);
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)$/)) {
// Short cache for JS/CSS — 1 hour
res.setHeader('Cache-Control', 'public, max-age=3600');
} else if (filePath.indexOf('/components/') !== -1) {
// Component HTML — 1 hour cache
res.setHeader('Cache-Control', 'public, max-age=3600');
}
}
}));
// Routes
app.use('/api/auth', require('./src/routes/auth'));
app.use('/api/auth', require('./src/routes/oidc'));
// Learning Hub CMS — must come BEFORE general /api/admin to avoid adminMiddleware conflict
// (moderators need access to /api/admin/learning but not other /api/admin routes)
app.use('/api/admin/learning', require('./src/routes/learningAdmin'));
app.use('/api/admin', require('./src/routes/admin'));
app.use('/api/admin', require('./src/routes/adminConfig'));
app.use('/api/admin', require('./src/routes/adminMilestones'));
// 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, DEFAULT_MODEL } = require('./src/utils/models');
var db = require('./src/db/database');
var models = await getAvailableModelsWithOverrides(db);
var defaultOverride = await db.getSetting('models.default');
res.json({ models: models, provider: modelsProvider, defaultModel: defaultOverride || DEFAULT_MODEL });
} catch(e) {
res.json({ models: getAvailableModels(), provider: modelsProvider });
}
});
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: '6.0.0', 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',
vertex: process.env.GOOGLE_VERTEX_PROJECT ? 'configured' : 'not configured',
litellm: process.env.LITELLM_API_BASE ? 'configured' : 'not configured',
whisper: process.env.OPENAI_API_KEY ? 'configured' : 'missing',
tts: process.env.LITELLM_API_BASE ? 'litellm' : (process.env.ELEVENLABS_API_KEY ? 'elevenlabs' : 'none')
});
});
// Learning Hub routes (all authenticated users can read content & take quizzes)
app.use('/api/learning', require('./src/routes/learningHub'));
// 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/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/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/user', require('./src/routes/userPreferences'));
app.use('/api/admin/learning', require('./src/routes/learningAI'));
// User-level preference: save WebDAV learning path (auth only, not moderator-only)
(function() {
var { authMiddleware } = require('./src/middleware/auth');
var db = require('./src/db/database');
app.post('/api/user/webdav-path', authMiddleware, async function(req, res) {
try {
await db.run('UPDATE users SET webdav_learning_path = ? WHERE id = ?', [req.body.path || null, req.user.id]);
res.json({ success: true });
} catch(e) { res.status(500).json({ error: e.message }); }
});
})();
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 v6.0');
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…');
// 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); }
// Close the Postgres pool so pending queries finish/reject cleanly.
try {
var dbMod = require('./src/db/database');
if (dbMod && dbMod.pool && typeof dbMod.pool.end === 'function') {
await dbMod.pool.end();
console.log('[shutdown] DB pool drained.');
}
} catch (e) {}
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'); });