diff --git a/.env.example b/.env.example
index 9cfa0a2e..c3f62d94 100644
--- a/.env.example
+++ b/.env.example
@@ -208,6 +208,20 @@ DB_PASSWORD=pedscribe_secret_change_me
# ── Mail ────────────────────────────────────────────────────────────────────
# SMTP_SECURE=false # true for implicit TLS (port 465)
+# ── Admin lockdown ──────────────────────────────────────────────────────────
+# With several admins, everything in the admin panel is editable by all of
+# them. Set this and configuration becomes read-only in the panel and refused
+# by the server: prompts, model policy, retrieval limits and budgets, TTS/STT,
+# embeddings, SMTP and email templates. Day-to-day operation stays available —
+# announcements, registration and invitations, feature flags, site details.
+# A setting added later is locked until it is deliberately added to the
+# editable list, rather than defaulting to open.
+#
+# It is deliberately an environment variable: a setting could be switched off
+# by the very admin it restrains, so lifting it needs host access and a
+# restart. Read access is unaffected — locked settings stay visible.
+# ADMIN_LOCKDOWN=false
+
# ── Identity and limits ─────────────────────────────────────────────────────
# SITE_NAME=Pediatric AI Scribe
# API_RATE_LIMIT_MAX=200 # requests per window across /api
diff --git a/public/js/admin.js b/public/js/admin.js
index 4644d426..7ed48c36 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -1462,6 +1462,50 @@ initImageSettings();
}
+// ============================================================
+// ADMIN LOCKDOWN (display)
+// The server refuses locked writes regardless; this only stops an admin
+// filling in a field that was never going to save. Settings stay visible, so
+// the configuration can still be read.
+//
+// The state rides along on the invites response rather than a request of its
+// own: the admin panel already makes enough calls on open.
+// ============================================================
+{
+ window.applyAdminLockdown = function(state) {
+ if (!state || !state.enabled) return;
+ var panel = document.getElementById('admin-tab');
+ if (!panel || !panel.querySelector('.card')) return;
+ lockdownBanner(panel, state);
+ lockdownFields(panel, state);
+ };
+
+ function lockdownBanner(panel, state) {
+ if (document.getElementById('admin-lockdown-banner')) return;
+ var note = document.createElement('div');
+ note.id = 'admin-lockdown-banner';
+ note.style.cssText = 'margin:0 0 12px;padding:10px 14px;border:1px solid var(--amber);background:var(--amber-light);border-radius:8px;font-size:13px;color:var(--g800);';
+ note.innerHTML = ' Admin lockdown is on. ' +
+ adminEscapeHtml(state.reason) + ' Settings are shown but cannot be changed here.';
+ panel.insertBefore(note, panel.firstChild);
+ }
+
+ // Everything inside the panel, except the controls that stay operational and
+ // the buttons that only read.
+ function lockdownFields(panel, state) {
+ var editable = ['admin-invite', 'btn-create-invite', 'cms-ann', 'announcement',
+ 'admin-users-search', 'registration'];
+ panel.querySelectorAll('input, select, textarea, button').forEach(function(el) {
+ var id = el.id || '';
+ if (editable.some(function(prefix) { return id.indexOf(prefix) === 0; })) return;
+ var label = (el.textContent || '') + ' ' + id;
+ if (/search|test|discover|refresh|reload|retry|copy/i.test(label)) return;
+ el.disabled = true;
+ el.title = state.reason;
+ });
+ }
+}
+
// ============================================================
// ADMIN REGISTRATION INVITES
// The code exists in readable form exactly once: in the response to creating
@@ -1500,6 +1544,8 @@ initImageSettings();
var toggle = document.getElementById('admin-invite-only');
if (toggle) toggle.checked = !!data.inviteOnly;
renderInvites(data.invites || []);
+ // Painted after the panel has rendered, from the same response.
+ if (typeof window.applyAdminLockdown === 'function') window.applyAdminLockdown(data.lockdown);
})
.catch(function() {});
}
diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js
index 30099f4e..0cfea4bd 100644
--- a/src/routes/adminConfig.js
+++ b/src/routes/adminConfig.js
@@ -19,6 +19,26 @@ var { getLiteLLMEmbeddingModels } = require('../utils/embeddings');
router.use(authMiddleware);
+var lockdown = require('../utils/adminLockdown');
+
+// One gate rather than a check in every write route, because that list grows
+// and a new route added later would quietly miss it. Reads always pass —
+// lockdown hides nothing, it only refuses changes.
+//
+// Under lockdown a write is refused unless it is day-to-day operation:
+// - invitations, which is running the service, not configuring it;
+// - the "test" endpoints, which send a probe and persist nothing;
+// - /config/:key, which decides per key — some keys stay editable, and that
+// route applies lockdown.isLocked() itself.
+// Everything else — model policy, SMTP, prompts, resets — is configuration.
+var OPERATIONAL_WRITE = /^\/invites(\/|$)|\/test(-email)?$|^\/config\/[^/]+$/;
+
+router.use(function(req, res, next) {
+ if (!lockdown.enabled() || req.method === 'GET' || req.method === 'HEAD') return next();
+ if (OPERATIONAL_WRITE.test(req.path)) return next();
+ return res.status(403).json({ error: lockdown.refusal(req.path.replace(/^\/config\//, '')) });
+});
+
function liteLLMBaseUrl() {
return (process.env.LITELLM_API_BASE || '').replace(/\/+$/, '').replace(/\/v1\/?$/, '');
}
@@ -91,7 +111,7 @@ router.get('/config', async function(req, res) {
rows.push({ key: dbKey, value: PROMPTS[key], updated_at: null });
}
});
- res.json({ success: true, config: rows, conversationBudget: budget });
+ res.json({ success: true, config: rows, conversationBudget: budget, lockdown: lockdown.state() });
} catch (e) { res.status(e.statusCode || 500).json({ error: 'Request failed' }); }
});
@@ -680,7 +700,7 @@ var invites = require('../utils/registrationInvites');
router.get('/invites', async function(req, res) {
try {
- res.json({ success: true, invites: await invites.list(), inviteOnly: await invites.inviteOnly() });
+ res.json({ success: true, invites: await invites.list(), inviteOnly: await invites.inviteOnly(), lockdown: lockdown.state() });
} catch (e) { return serverError(res, 'Invites list', e, 'Could not list invitations'); }
});
@@ -884,6 +904,12 @@ router.put('/config/:key(*)', async function(req, res) {
return res.status(400).json({ error: 'Unknown config key' });
}
+ // Some settings stay editable under lockdown; the rest are refused here,
+ // whatever the UI showed.
+ if (lockdown.isLocked(key)) {
+ return res.status(403).json({ error: lockdown.refusal(key) });
+ }
+
// Model policy mutations must use the validated model endpoints.
if (key.startsWith('models.')) return res.status(400).json({ error: 'Use the model configuration endpoints' });
if (key.startsWith('feature.') && !['true', 'false'].includes(String(value))) return res.status(400).json({ error: 'Feature value must be true or false' });
diff --git a/src/utils/adminLockdown.js b/src/utils/adminLockdown.js
new file mode 100644
index 00000000..329773b8
--- /dev/null
+++ b/src/utils/adminLockdown.js
@@ -0,0 +1,79 @@
+// ============================================================
+// ADMIN LOCKDOWN
+// With several admins, everything in the admin panel is editable by all of
+// them — prompts, model policy, retrieval limits, SMTP. Lockdown separates
+// "runs the service day to day" from "changes how the service behaves".
+//
+// It is an environment variable, deliberately, not a setting. A setting could
+// be switched off by any admin, which would make it decoration. Only someone
+// with access to the host and a restart can lift it.
+//
+// Locked settings stay visible — an admin should be able to see how the system
+// is configured — but the field is read-only and the server refuses the write.
+// The refusal is the real control; the read-only field is only courtesy.
+// ============================================================
+
+// Everything that changes how the assistant answers, what it costs, or how mail
+// leaves the building.
+var LOCKED_PREFIXES = Object.freeze([
+ 'prompt.', // every prompt, including the clinical ones
+ 'clinical_assistant.', // models, allowlists, retrieval limits, budgets
+ 'models.', // model policy: default, custom, enabled set
+ 'tts.',
+ 'stt.',
+ 'embeddings.',
+ 'smtp.', // where mail goes and who it authenticates as
+ 'email.' // the templates that mail sends
+]);
+
+// Day-to-day operation stays with ordinary admins.
+var EDITABLE_WHEN_LOCKED = Object.freeze([
+ 'announcement.',
+ 'registration_enabled',
+ 'registration_invite_only',
+ 'feature.',
+ 'site.'
+]);
+
+function enabled(env) {
+ var raw = (env || process.env).ADMIN_LOCKDOWN;
+ return String(raw == null ? '' : raw).toLowerCase() === 'true';
+}
+
+// A key is locked when lockdown is on and it is not on the day-to-day list.
+// Anything unrecognised is locked: a setting added later should need a
+// deliberate decision to become editable, rather than defaulting to open.
+function isLocked(key, env) {
+ if (!enabled(env)) return false;
+ var name = String(key || '');
+ for (var i = 0; i < EDITABLE_WHEN_LOCKED.length; i++) {
+ var allowed = EDITABLE_WHEN_LOCKED[i];
+ if (name === allowed || (allowed.endsWith('.') && name.startsWith(allowed))) return false;
+ }
+ return true;
+}
+
+// What the admin UI needs to render itself correctly.
+function state(env) {
+ return {
+ enabled: enabled(env),
+ lockedPrefixes: LOCKED_PREFIXES.slice(),
+ editablePrefixes: EDITABLE_WHEN_LOCKED.slice(),
+ reason: 'ADMIN_LOCKDOWN is set on the server. Locked settings can only be changed by someone with host access.'
+ };
+}
+
+// One message, so every refusal reads the same wherever it comes from.
+function refusal(key) {
+ return 'This setting is locked. The server is in admin lockdown (ADMIN_LOCKDOWN), ' +
+ 'so "' + key + '" can only be changed by someone with host access.';
+}
+
+module.exports = {
+ LOCKED_PREFIXES,
+ EDITABLE_WHEN_LOCKED,
+ enabled,
+ isLocked,
+ state,
+ refusal
+};
diff --git a/test/backend-hardening.test.js b/test/backend-hardening.test.js
index 26067df2..d272ec8b 100644
--- a/test/backend-hardening.test.js
+++ b/test/backend-hardening.test.js
@@ -192,3 +192,50 @@ test('registration invites are single-use, expiring, and safe to store', () => {
// And the rejection must not say which of the four reasons applied.
assert.match(auth, /may have expired, been revoked, or already been used/);
});
+
+// With several admins, everything in the panel was editable by all of them.
+// Lockdown separates running the service from changing how it behaves.
+test('admin lockdown refuses configuration writes at the server', () => {
+ const lockdown = require('../src/utils/adminLockdown');
+ const off = {};
+ const on = { ADMIN_LOCKDOWN: 'true' };
+
+ // It is an environment variable, not a setting: a setting could be switched
+ // off by the very admin it restrains.
+ assert.equal(lockdown.enabled(off), false);
+ assert.equal(lockdown.enabled({ ADMIN_LOCKDOWN: 'false' }), false);
+ assert.equal(lockdown.enabled(on), true);
+
+ // Off, nothing is locked.
+ assert.equal(lockdown.isLocked('prompt.hpi', off), false);
+
+ // On, configuration is locked and day-to-day operation is not.
+ for (const key of ['prompt.hpi', 'clinical_assistant.chat_model', 'models.default',
+ 'tts.voice', 'stt.model', 'embeddings.model', 'smtp.host', 'email.verify.subject']) {
+ assert.equal(lockdown.isLocked(key, on), true, key + ' is locked');
+ }
+ for (const key of ['announcement.text', 'registration_enabled',
+ 'registration_invite_only', 'feature.memories', 'site.name']) {
+ assert.equal(lockdown.isLocked(key, on), false, key + ' stays editable');
+ }
+
+ // A setting added later is locked until someone decides otherwise, rather
+ // than defaulting to open.
+ assert.equal(lockdown.isLocked('something.invented.later', on), true);
+
+ const fs = require('node:fs');
+ const path = require('node:path');
+ const admin = fs.readFileSync(path.join(__dirname, '..', 'src/routes/adminConfig.js'), 'utf8');
+ // One gate, not a check per route: a route added later cannot miss it.
+ assert.match(admin, /if \(!lockdown\.enabled\(\) \|\| req\.method === 'GET' \|\| req\.method === 'HEAD'\) return next\(\);/);
+ assert.match(admin, /if \(OPERATIONAL_WRITE\.test\(req\.path\)\) return next\(\);/);
+ assert.match(admin, /return res\.status\(403\)\.json\(\{ error: lockdown\.refusal/);
+ // And the per-key rule for the generic settings route.
+ assert.match(admin, /if \(lockdown\.isLocked\(key\)\) \{\s*\n\s*return res\.status\(403\)/);
+
+ // The panel learns the state from a response it already fetches, rather than
+ // adding a request of its own on every admin open.
+ assert.match(admin, /invites: await invites\.list\(\), inviteOnly: await invites\.inviteOnly\(\), lockdown: lockdown\.state\(\)/);
+ const panel = fs.readFileSync(path.join(__dirname, '..', 'public/js/admin.js'), 'utf8');
+ assert.match(panel, /window\.applyAdminLockdown\(data\.lockdown\)/);
+});