Four changes batched: 1. ED Encounters tab (new) — multi-stage emergency note with don't-miss tooltips and 2023 E/M MDM finalize. New route /api/ed-encounters (generate per-stage + finalize MDM), new ed-encounters.js owning all client logic, new ed-encounter.html component, new template_ed memory category. Persists draft to localStorage every keystroke and to saved_encounters on stage advance. encounters.js touched only to register the new tab in sessionStorage restore + tabMap (save and idempotency code untouched). 2. Notes model selector — /notes/from-voice now accepts a client-supplied model (validated by the existing callAI allow-list); falls back to the admin default. Added <select class="tab-model-select"> to notes.html so the existing app.js populator handles options + default. 3. Remove AI-learning-from-corrections — deleted correctionTracker.js, POST /memories/correction, the corrections branch in /memories/context, the settings UI section, the FAQ entry, and all dead trackAIOutput/saveCorrection guards in callers. Legacy correction_* DB rows are filtered (NOT LIKE) rather than dropped, so no destructive migration. 4. Fix notes AI framing — /notes/from-voice prompt no longer assumes "physician dictation". Plain notes (shopping lists, reminders, ideas) now match the dictation tone instead of being forced into clinical structure. All 46 tests pass.
386 lines
17 KiB
JavaScript
386 lines
17 KiB
JavaScript
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 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'"],
|
|
// 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());
|
|
|
|
// ============================================================
|
|
// 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
|
|
}));
|
|
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
|
|
// ============================================================
|
|
// Compute a per-boot BUILD_ID (short hex). Inject it as ?v=BUILD_ID
|
|
// on every local /js/*.js and /css/*.css reference in index.html so
|
|
// browsers always fetch fresh JS/CSS after a deploy instead of
|
|
// serving from the 1-hour cache.
|
|
var fs = require('fs');
|
|
var crypto = require('crypto');
|
|
var BUILD_ID = crypto.randomBytes(4).toString('hex');
|
|
try {
|
|
var gitHead = fs.readFileSync(path.join(__dirname, '.git/HEAD'), 'utf8').trim();
|
|
if (gitHead.indexOf('ref:') === 0) {
|
|
var refPath = gitHead.split(' ')[1];
|
|
BUILD_ID = fs.readFileSync(path.join(__dirname, '.git', refPath), 'utf8').trim().slice(0, 7);
|
|
} else {
|
|
BUILD_ID = gitHead.slice(0, 7);
|
|
}
|
|
} catch (e) {
|
|
// Non-git environment (Docker image) — use /app/BUILD_ID file if present,
|
|
// otherwise stick with the random-on-boot value generated above.
|
|
try {
|
|
BUILD_ID = fs.readFileSync(path.join(__dirname, 'BUILD_ID'), 'utf8').trim() || BUILD_ID;
|
|
} catch (_) {}
|
|
}
|
|
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)\/[^"'?]+)(["'])/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'], 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 }); });
|
|
|
|
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: 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',
|
|
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/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/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/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 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…');
|
|
// 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');
|
|
// 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) {}
|
|
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'); });
|