diff --git a/src/routes/audioBackups.js b/src/routes/audioBackups.js index df1e9a84..41c99156 100644 --- a/src/routes/audioBackups.js +++ b/src/routes/audioBackups.js @@ -28,7 +28,10 @@ var upload = multer({ limits: { fileSize: 25 * 1024 * 1024 }, }); -router.use(authMiddleware); +// Scoped to this router's own prefix. Mounted on /api, a bare +// router.use(authMiddleware) gated every /api path — including routes owned by +// routers mounted after it in server.js. +router.use('/audio-backups', authMiddleware); // ── POST save audio backup (compressed) ────────────────────────────────── router.post('/audio-backups', upload.single('audio'), async function(req, res) { diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js index 0b856245..e8636bde 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -79,7 +79,17 @@ router.use(async function(req, res, next) { next(); }); -router.use(authMiddleware); +// The middleware above may have assigned the anonymous preview identity. +// authMiddleware knows nothing about that — it only looks for a token — so +// calling it unconditionally here rejected exactly the requests preview exists +// to allow, and the feature never worked at all. authMiddleware itself stays +// strict: it is used everywhere else and must keep refusing anyone without a +// credential. Only the preview identity, which this router assigns on +// allow-listed paths when an admin has opted in, may pass. +router.use(function(req, res, next) { + if (req.user && req.user.preview) return next(); + return authMiddleware(req, res, next); +}); var MAX_SAVED_CHATS_PER_USER = 100; var MAX_SAVED_CHAT_TITLE = 160; diff --git a/src/routes/diagrams.js b/src/routes/diagrams.js index 7589a8c4..56b70364 100644 --- a/src/routes/diagrams.js +++ b/src/routes/diagrams.js @@ -11,7 +11,11 @@ var { authMiddleware } = require('../middleware/auth'); var logger = require('../utils/logger'); var cryptoUtil = require('../utils/crypto'); -router.use(authMiddleware); +// Scoped to this router's own prefix. These routers are mounted on /api, so a +// bare router.use(authMiddleware) gated every /api path — including routes +// belonging to routers mounted after it. That is what kept the signed-out +// assistant preview returning 401 no matter what the admin setting said. +router.use('/diagrams', authMiddleware); var MAX_TITLE = 200; var MAX_SOURCE = 50000; diff --git a/src/routes/documents.js b/src/routes/documents.js index 3f083e8d..c1ac304b 100644 --- a/src/routes/documents.js +++ b/src/routes/documents.js @@ -10,7 +10,10 @@ var db = require('../db/database'); var { authMiddleware } = require('../middleware/auth'); var logger = require('../utils/logger'); -router.use(authMiddleware); +// Scoped to this router's own prefix. Mounted on /api, a bare +// router.use(authMiddleware) gated every /api path — including routes owned by +// routers mounted after it in server.js. +router.use('/documents', authMiddleware); var upload = multer({ storage: multer.memoryStorage(), diff --git a/src/routes/dontMiss.js b/src/routes/dontMiss.js index 6c86e6dd..9c93477e 100644 --- a/src/routes/dontMiss.js +++ b/src/routes/dontMiss.js @@ -13,7 +13,10 @@ var { authMiddleware } = require('../middleware/auth'); var logger = require('../utils/logger'); var { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe'); -router.use(authMiddleware); +// Scoped to this router's own prefix. Mounted on /api, a bare +// router.use(authMiddleware) gated every /api path — including routes owned by +// routers mounted after it in server.js. +router.use('/dont-miss', authMiddleware); function extractJson(raw) { var t = String(raw || '').trim(); diff --git a/src/routes/edEncounters.js b/src/routes/edEncounters.js index 1433ab1c..953a339f 100644 --- a/src/routes/edEncounters.js +++ b/src/routes/edEncounters.js @@ -19,7 +19,10 @@ var { authMiddleware } = require('../middleware/auth'); var logger = require('../utils/logger'); var { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe'); -router.use(authMiddleware); +// Scoped to this router's own prefix. Mounted on /api, a bare +// router.use(authMiddleware) gated every /api path — including routes owned by +// routers mounted after it in server.js. +router.use('/ed-encounters', authMiddleware); // Models sometimes wrap JSON in ```json fences or prepend prose. Strip the // fence and recover the JSON object between the first { and last }. diff --git a/src/routes/encounters.js b/src/routes/encounters.js index 8e8090e7..f336e327 100644 --- a/src/routes/encounters.js +++ b/src/routes/encounters.js @@ -9,7 +9,11 @@ var { authMiddleware } = require('../middleware/auth'); var logger = require('../utils/logger'); var cryptoUtil = require('../utils/crypto'); -router.use(authMiddleware); +// Scoped to this router's own prefix. These routers are mounted on /api, so a +// bare router.use(authMiddleware) gated every /api path — including routes +// belonging to routers mounted after it. That is what kept the signed-out +// assistant preview returning 401 no matter what the admin setting said. +router.use('/encounters', authMiddleware); // Normalise partial_data to a string before encrypting so JSON objects and // raw strings round-trip identically. Decryption returns the original string. diff --git a/src/routes/extensions.js b/src/routes/extensions.js index 5db28142..655ffc53 100644 --- a/src/routes/extensions.js +++ b/src/routes/extensions.js @@ -14,7 +14,11 @@ var transfer = require('../utils/extensionTransfer'); var upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 1024 * 1024 } }); -router.use(authMiddleware); +// Scoped to this router's own prefix. These routers are mounted on /api, so a +// bare router.use(authMiddleware) gated every /api path — including routes +// belonging to routers mounted after it. That is what kept the signed-out +// assistant preview returning 401 no matter what the admin setting said. +router.use('/extensions', authMiddleware); function sanitizeType(t) { return transfer.sanitizeType(t); diff --git a/src/routes/generatedImages.js b/src/routes/generatedImages.js index 82c2e1f7..c3234d7b 100644 --- a/src/routes/generatedImages.js +++ b/src/routes/generatedImages.js @@ -2,7 +2,13 @@ const router = require('express').Router(); const { authMiddleware, moderatorMiddleware, adminMiddleware } = require('../middleware/auth'); const images = require('../utils/generatedImages'); const db = require('../db/database'); -router.use(authMiddleware); +// Scoped to this router's own prefixes. Mounted on /api, a bare +// router.use(authMiddleware) gated every /api path, including routes owned by +// routers mounted after it in server.js — documents, audio backups, billing +// and well visits all sat behind this line by accident. +['/generated-images', '/image-jobs', '/admin/image-settings'].forEach(function (prefix) { + router.use(prefix, authMiddleware); +}); function fail(res, e) { res.status(e.statusCode || 503).json({ error: e.statusCode ? e.message : 'Image service unavailable' }); } async function sendAsset(req, res, download) { // ?w= serves a stored preview of the SAME asset. Permission is checked against diff --git a/src/routes/memories.js b/src/routes/memories.js index a20c3abf..86f99fd9 100644 --- a/src/routes/memories.js +++ b/src/routes/memories.js @@ -9,7 +9,11 @@ var { authMiddleware } = require('../middleware/auth'); var logger = require('../utils/logger'); var cryptoUtil = require('../utils/crypto'); -router.use(authMiddleware); +// Scoped to this router's own prefix. These routers are mounted on /api, so a +// bare router.use(authMiddleware) gated every /api path — including routes +// belonging to routers mounted after it. That is what kept the signed-out +// assistant preview returning 401 no matter what the admin setting said. +router.use('/memories', authMiddleware); router.use('/memories', require('../utils/policy').requireFeature('memories')); // Decrypt a row's user-facing fields. Safe against legacy plaintext rows — diff --git a/src/routes/notes.js b/src/routes/notes.js index 049a11f9..d0e9a9c4 100644 --- a/src/routes/notes.js +++ b/src/routes/notes.js @@ -36,7 +36,11 @@ function toHtmlBody(s) { } } -router.use(authMiddleware); +// Scoped to this router's own prefix. These routers are mounted on /api, so a +// bare router.use(authMiddleware) gated every /api path — including routes +// belonging to routers mounted after it. That is what kept the signed-out +// assistant preview returning 401 no matter what the admin setting said. +router.use('/notes', authMiddleware); var MAX_TITLE = 200; var MAX_BODY = 50000; // 50 KB of rich-text HTML is plenty for a clinical note diff --git a/src/routes/patientEducation.js b/src/routes/patientEducation.js index 1968d3e9..00040652 100644 --- a/src/routes/patientEducation.js +++ b/src/routes/patientEducation.js @@ -11,7 +11,10 @@ var { authMiddleware } = require('../middleware/auth'); var logger = require('../utils/logger'); var { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe'); -router.use(authMiddleware); +// Scoped to this router's own prefix. Mounted on /api, a bare +// router.use(authMiddleware) gated every /api path — including routes owned by +// routers mounted after it in server.js. +router.use('/patient-education', authMiddleware); // ── POST /patient-education ───────────────────────────────── // Body: diff --git a/test/policy-flows.test.js b/test/policy-flows.test.js index 8ad3de01..3ce84e2c 100644 --- a/test/policy-flows.test.js +++ b/test/policy-flows.test.js @@ -554,3 +554,41 @@ test('actual native admin script disables selected default, displays backend rep assertDisabledAbsent(); assert.ok(Array.from(select.options).some(o => o.value === 'other'), 'enabled custom model retained'); }); + +const readSource = file => fs.readFileSync(path.join(root, file), 'utf8'); + +test('a router mounted on /api must not gate the whole namespace', () => { + // These routers are all mounted on '/api', so `router.use(authMiddleware)` + // with no path applies to every /api request that reaches them — including + // routes owned by routers mounted further down server.js. extensions.js did + // exactly that from line 295, which is why the signed-out assistant preview + // returned 401 no matter what the admin setting said: the request never got + // as far as the preview middleware. Each gate must name its own prefix. + const server = readSource('server.js'); + const mounted = [...server.matchAll(/app\.use\('\/api', require\('\.\/src\/routes\/([\w-]+)'\)\)/g)] + .map(m => m[1]); + assert.ok(mounted.length > 10, 'expected the /api routers to be found'); + for (const name of mounted) { + let source; + try { source = readSource('src/routes/' + name + '.js'); } catch (e) { continue; } + assert.doesNotMatch(source, /^router\.use\(\s*authMiddleware\s*\)/m, + name + '.js gates every /api path; scope it, e.g. router.use(\'/' + name + '\', authMiddleware)'); + } +}); + +test('the signed-out preview is reachable, and stays narrow', () => { + const route = readSource('src/routes/clinicalAssistant.js'); + // authMiddleware only ever looks for a token, so calling it unconditionally + // after the preview identity was assigned rejected the very requests preview + // exists to serve. Only the preview identity may skip it. + assert.match(route, /if \(req\.user && req\.user\.preview\) return next\(\);/); + assert.match(route, /return authMiddleware\(req, res, next\);/); + // Allow-listed by exact path: a route added later is private unless someone + // puts it on this list deliberately. + const list = route.slice(route.indexOf('var PREVIEW_PATHS'), route.indexOf('var PREVIEW_USER')); + assert.match(list, /'\/clinical-assistant\/chat'/); + assert.match(list, /'\/clinical-assistant\/chat\/stream'/); + assert.doesNotMatch(list, /saved-chats|\/config|\/images/); + // A preview visitor has no identity, so nothing can be owned or billed. + assert.match(route, /PREVIEW_USER = Object\.freeze\(\{ id: null, preview: true/); +});