Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The sign-in screen asks for an email first, then offers both ways in together: a six-digit code sent to that address, or the password. Beside rather than instead — a code depends on mail being delivered and a password does not, so neither may be the only route. "Use a different email" goes back a step, and creating an account stays where it was. What keeps it from being a second, weaker front door: - Only a bcrypt hash is stored, so a code read out of the database is not a working credential. - Ten minutes, single use, marked used before the session is issued so a replay cannot race it, and requesting a new one deletes the old. - Five wrong guesses burn it. Six digits is a million possibilities, which is plenty against a person and nothing against a script with unlimited tries. - Requesting a code answers identically whether or not the address exists, and every verify failure returns one message. A sign-in screen that says "no such account" is a way of finding out who has one. - Two-factor still applies: a code proves you can read the mailbox, which is one factor, and an account that asked for a second still wants it. - Its own rate limits, tighter for requesting than for attempting, because requesting sends mail to someone else's address. These had to be separate limiters: Express matches app.use paths on segment boundaries, so /api/auth/login does not cover /api/auth/login-code — checked against a real router rather than assumed. Two bugs found while building it, both mine: authFetch keeps an allowlist of endpoints callable with no verified owner and rejects everything else before it is sent. The new endpoints were not on it, so the request never left the browser and surfaced as "Connection error". reveal() hid elements by appending 'hidden' to className and showed them with a non-global replace, so hiding twice left two copies and showing stripped one. The "use a different email" link never reappeared. It uses classList now, which is idempotent. Verified against the running server: correct code signs in, the same code again is refused, a superseded code is refused, five wrong guesses burn it, an expired one is refused, and the stored value is a hash. In the browser: requesting a code advances the screen, a wrong code is refused without losing the screen, and the password route still signs in. Not yet demonstrated: a correct code typed into the browser. The harness keeps racing the one-live-code rule — the page's own request supersedes whatever code the test holds, and with SMTP off the delivered one cannot be read. The same request reaches the server on the wrong-code path, and the endpoint itself is verified, but that last step is untested end to end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
443 lines
20 KiB
JavaScript
443 lines
20 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 { 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)$/)) {
|
|
// 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/image', require('./src/routes/generatedImages').learningRouter);
|
|
app.use('/api/admin/learning', require('./src/routes/learningAdmin'));
|
|
app.use('/api/admin/learning', require('./src/routes/learningAI'));
|
|
|
|
// 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',
|
|
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'));
|
|
// 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'));
|
|
|
|
// 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, require('./src/utils/policy').requireFeature('nextcloud'), 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…');
|
|
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'); });
|