diff --git a/migrations/1780100000000_registration-invites.js b/migrations/1780100000000_registration-invites.js
new file mode 100644
index 00000000..f29f5ab6
--- /dev/null
+++ b/migrations/1780100000000_registration-invites.js
@@ -0,0 +1,38 @@
+// Invite-only registration.
+//
+// registration_enabled is a single on/off switch: open to anyone, or closed to
+// everyone. This adds the middle setting an operator actually wants — open to
+// people you invited. A code is single-use, expires, and can be revoked or
+// deleted without touching the account it created.
+//
+// The code is stored hashed. An invite grants account creation, so a leaked
+// settings dump or database backup should not hand someone a working code, the
+// same reason password reset tokens are not stored in the clear.
+
+exports.up = pgm => {
+ pgm.sql(`
+ CREATE TABLE IF NOT EXISTS registration_invites (
+ id SERIAL PRIMARY KEY,
+ code_hash TEXT NOT NULL UNIQUE,
+ -- The last few characters, so the list can show which code a row is
+ -- without being able to reconstruct it.
+ code_hint TEXT NOT NULL,
+ note TEXT NOT NULL DEFAULT '',
+ created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ expires_at TIMESTAMPTZ NOT NULL,
+ -- Set when used. The row is kept so an admin can see who used which code.
+ used_at TIMESTAMPTZ,
+ used_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ -- Set when revoked. Separate from deletion: a revoked code stays visible.
+ revoked_at TIMESTAMPTZ,
+ revoked_by INTEGER REFERENCES users(id) ON DELETE SET NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_registration_invites_hash ON registration_invites(code_hash);
+ CREATE INDEX IF NOT EXISTS idx_registration_invites_expires ON registration_invites(expires_at);
+ `);
+};
+
+exports.down = pgm => {
+ pgm.sql('DROP TABLE IF EXISTS registration_invites;');
+};
diff --git a/public/components/admin.html b/public/components/admin.html
index 15e526fe..3d0f3ccb 100644
--- a/public/components/admin.html
+++ b/public/components/admin.html
@@ -373,6 +373,40 @@
+
+
+
+
Registration Invitations
+
+
+
+ Invite only
+
+
+
Sits between open and closed registration. With registration disabled entirely, nobody can register even with a code.
+
+
+
+
+
+
+
+
+
+
+
+
The code is shown once, here. Only its hash is stored, so it cannot be read again afterwards.
';
+ }).join('');
+ }
+}
+
// ============================================================
// ADMIN IMAGE MODEL MANAGEMENT
// ============================================================
diff --git a/public/js/auth.js b/public/js/auth.js
index 0244f2a6..00c8694f 100644
--- a/public/js/auth.js
+++ b/public/js/auth.js
@@ -881,7 +881,12 @@ document.addEventListener('DOMContentLoaded', function() {
var request = fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name: name, email: email, password: password, turnstileToken: regToken })
+ body: JSON.stringify({
+ name: name, email: email, password: password, turnstileToken: regToken,
+ // Sent whether or not the field is showing; the server decides
+ // whether it is required.
+ inviteCode: (document.getElementById('reg-invite') || {}).value || ''
+ })
});
var attempt = authState();
return request
@@ -1192,6 +1197,12 @@ document.addEventListener('DOMContentLoaded', function() {
var link = document.getElementById('show-register');
if (link) link.style.display = '';
}
+ // Only ask for a code when one is actually required, so ordinary
+ // registration is not cluttered by a field nobody can fill in.
+ var inviteGroup = document.getElementById('reg-invite-group');
+ var inviteInput = document.getElementById('reg-invite');
+ if (inviteGroup) inviteGroup.classList.toggle('hidden', !data.inviteOnly);
+ if (inviteInput) inviteInput.required = !!data.inviteOnly;
})
.catch(function() {});
diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js
index f2d67206..5cb36b67 100644
--- a/public/js/clinicalAssistant.js
+++ b/public/js/clinicalAssistant.js
@@ -2353,20 +2353,24 @@ import {
});
}
+ // Opening a saved chat is not the assistant working, so it does not flip the
+ // send button into Stop and does not announce itself: the transcript
+ // appearing is the confirmation. It still takes the busy lock, so two rapid
+ // clicks cannot interleave two transcripts — but silently.
+ var loadingSavedChat = false;
function loadSavedChat(id) {
- if (assistantBusy) return;
+ if (assistantBusy || loadingSavedChat) return;
var layout = document.getElementById('assistant-layout');
if (layout) layout.classList.remove('mobile-chats-open');
- setBusy(true, 'Loading chat...');
+ loadingSavedChat = true;
return fetchSavedAssistantChat(id)
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Load failed');
currentChatId = data.chat ? data.chat.id : null;
restoreSavedChat(data.chat && data.chat.payload || {});
- if (typeof showToast === 'function') showToast('Loaded saved chat', 'success');
})
.catch(function (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); })
- .finally(function() { setBusy(false, 'Ready'); });
+ .finally(function() { loadingSavedChat = false; });
}
function restoreSavedChat(payload) {
diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js
index 19cb2eeb..30099f4e 100644
--- a/src/routes/adminConfig.js
+++ b/src/routes/adminConfig.js
@@ -11,7 +11,7 @@ var promptCatalog = require('../utils/promptCatalog');
var promptRevisions = require('../utils/promptRevisions');
var { conversationBudget, conversationLimit } = require('../utils/clinicalConversation');
var logger = require('../utils/logger');
-var { gatewayUrl } = require('../utils/errors');
+var { gatewayUrl, serverError } = require('../utils/errors');
var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = require('../utils/ttsProvider');
var { getLiteLLMHeaders, getLiteLLMAdminHeaders } = require('../utils/litellm');
var { getSTTDependencies, getLiteLLMSTTModels, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider');
@@ -673,6 +673,42 @@ router.get('/config/stt', async function(req, res) {
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
+// ── Registration invites ─────────────────────────────────────────────────
+// A code is shown once, at creation. Only its hash and last four characters
+// are stored, so this endpoint is the only time it can be read.
+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() });
+ } catch (e) { return serverError(res, 'Invites list', e, 'Could not list invitations'); }
+});
+
+router.post('/invites', async function(req, res) {
+ try {
+ var created = await invites.create(req.user.id, { days: req.body.days, note: req.body.note });
+ logger.audit(req.user.id, 'invite_create', 'Created a registration invite valid ' + created.days + ' days', req, { category: 'admin' });
+ res.json({ success: true, code: created.code, days: created.days });
+ } catch (e) { return serverError(res, 'Invite create', e, 'Could not create an invitation'); }
+});
+
+router.post('/invites/:id/revoke', async function(req, res) {
+ try {
+ var done = await invites.revoke(req.params.id, req.user.id);
+ if (!done) return res.status(400).json({ error: 'Only an unused, unrevoked invitation can be revoked' });
+ logger.audit(req.user.id, 'invite_revoke', 'Revoked invitation ' + req.params.id, req, { category: 'admin' });
+ res.json({ success: true });
+ } catch (e) { return serverError(res, 'Invite revoke', e, 'Could not revoke the invitation'); }
+});
+
+router.delete('/invites/:id', async function(req, res) {
+ try {
+ if (!await invites.remove(req.params.id)) return res.status(404).json({ error: 'Not found' });
+ logger.audit(req.user.id, 'invite_delete', 'Deleted invitation ' + req.params.id, req, { category: 'admin' });
+ res.json({ success: true });
+ } catch (e) { return serverError(res, 'Invite delete', e, 'Could not delete the invitation'); }
+});
+
// ── GET discover STT models from provider ────────────────────────────────
router.get('/config/stt/discover', async function(req, res) {
try {
@@ -842,7 +878,7 @@ router.put('/config/:key(*)', async function(req, res) {
}
// Security: only allow known key prefixes
- var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'site.', 'smtp.', 'models.', 'tts.', 'stt.', 'embeddings.', 'clinical_assistant.'];
+ var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'registration_invite_only', 'site.', 'smtp.', 'models.', 'tts.', 'stt.', 'embeddings.', 'clinical_assistant.'];
var isAllowed = allowed.some(function(p) { return key === p || key.startsWith(p); });
if (!isAllowed) {
return res.status(400).json({ error: 'Unknown config key' });
@@ -866,6 +902,9 @@ router.put('/config/:key(*)', async function(req, res) {
if (key === 'clinical_assistant.show_sources' && !['true', 'false'].includes(String(value))) {
return res.status(400).json({ error: 'Show sources must be true or false' });
}
+ if (key === 'registration_invite_only' && !['true', 'false'].includes(String(value))) {
+ return res.status(400).json({ error: 'Invite-only must be true or false' });
+ }
// The admin's image-model roster: comma-separated gateway model ids.
if (key === 'clinical_assistant.image_model_roster') {
var rosterIds = String(value).split(',').map(function(s) { return s.trim(); }).filter(Boolean);
diff --git a/src/routes/auth.js b/src/routes/auth.js
index 2fdd68a9..0f19c3ae 100644
--- a/src/routes/auth.js
+++ b/src/routes/auth.js
@@ -9,6 +9,7 @@ const QRCode = require('qrcode');
const crypto = require('crypto');
const db = require('../db/database');
const { JWT_SECRET, authMiddleware } = require('../middleware/auth');
+const invites = require('../utils/registrationInvites');
const { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions');
const { notifyNewLogin, notifyPasswordChanged, notifyNewRegistration } = require('../utils/notify');
var logger = require('../utils/logger');
@@ -182,8 +183,16 @@ router.post('/register', requireLocalAuth, async (req, res) => {
return res.status(403).json({ error: 'Registration is currently disabled. Contact an administrator.' });
}
- var { email, password, name, turnstileToken } = req.body;
+ var { email, password, name, turnstileToken, inviteCode } = req.body;
if (!email || !password || !name) return res.status(400).json({ error: 'All fields required' });
+
+ // Invite-only sits between "open" and "closed": anyone with a code, nobody
+ // without one. Checked before the work of hashing a password, and claimed
+ // atomically once the account exists.
+ var inviteRequired = await invites.inviteOnly();
+ if (inviteRequired && !String(inviteCode || '').trim()) {
+ return res.status(400).json({ error: 'An invitation code is required to register.' });
+ }
if (password.length < 8) return res.status(400).json({ error: 'Password must be 8+ characters' });
// Cloudflare Turnstile verification
@@ -217,6 +226,18 @@ router.post('/register', requireLocalAuth, async (req, res) => {
);
var userId = result.lastInsertRowid;
+
+ // Claimed only now, so a code is never spent on a registration that failed.
+ // The claim is a single conditional UPDATE, so two people racing the same
+ // code cannot both win — the loser's account is removed again rather than
+ // left behind as a free registration.
+ if (inviteRequired) {
+ var claimedInvite = await invites.claim(inviteCode, userId);
+ if (!claimedInvite) {
+ await db.run('DELETE FROM users WHERE id = ?', [userId]);
+ return res.status(400).json({ error: 'That invitation code is not valid. It may have expired, been revoked, or already been used.' });
+ }
+ }
var verifyUrl = safeAppUrl() + '/api/auth/verify-email?token=' + verifyToken;
var verifySubject = await db.getSetting('email.verify.subject') || 'Verify your Pediatric AI Scribe account';
var verifyBody = await db.getSetting('email.verify.body') || 'Great to have you on Pediatric AI Scribe. Please verify your email address by clicking the button below.';
@@ -679,7 +700,10 @@ router.get('/me', authMiddleware, async (req, res) => {
router.get('/registration-status', async (req, res) => {
try {
var enabled = await db.getSetting('registration_enabled');
- res.json({ registrationEnabled: enabled !== 'false' && !await isSSOOnly() });
+ res.json({
+ registrationEnabled: enabled !== 'false' && !await isSSOOnly(),
+ inviteOnly: await invites.inviteOnly()
+ });
} catch (err) { res.json({ registrationEnabled: false }); }
});
diff --git a/src/utils/registrationInvites.js b/src/utils/registrationInvites.js
new file mode 100644
index 00000000..03c65acd
--- /dev/null
+++ b/src/utils/registrationInvites.js
@@ -0,0 +1,138 @@
+// ============================================================
+// REGISTRATION INVITES
+// registration_enabled is open-or-closed. This is the middle setting: open to
+// people you invited. One module owns the rules so the register route and the
+// admin routes cannot disagree about what "valid" means.
+//
+// A code is single-use, expires, and can be revoked. It is stored hashed,
+// because an invite grants account creation and a database dump should not
+// hand someone a working one.
+// ============================================================
+
+var crypto = require('crypto');
+
+// Required lazily. The code-generation and hashing helpers are pure, and
+// requiring the database at module load made simply importing this file open a
+// connection — which is why a test that only checked those helpers hung.
+function db() {
+ return require('../db/database');
+}
+
+var DEFAULT_TTL_DAYS = 7;
+var MAX_TTL_DAYS = 90;
+
+// Crockford-style: no I, L, O or U, so a code read aloud or copied off a screen
+// is hard to mistype and cannot spell anything unfortunate.
+var ALPHABET = '23456789ABCDEFGHJKMNPQRSTVWXYZ';
+
+function generateCode() {
+ var bytes = crypto.randomBytes(16);
+ var out = '';
+ for (var i = 0; i < 16; i++) {
+ out += ALPHABET[bytes[i] % ALPHABET.length];
+ if (i % 4 === 3 && i !== 15) out += '-';
+ }
+ return out; // XXXX-XXXX-XXXX-XXXX
+}
+
+function normalize(code) {
+ return String(code == null ? '' : code).toUpperCase().replace(/[^0-9A-Z]/g, '');
+}
+
+function hash(code) {
+ return crypto.createHash('sha256').update(normalize(code)).digest('hex');
+}
+
+function ttlDays(requested) {
+ var days = Number(requested);
+ if (!Number.isFinite(days) || days < 1) return DEFAULT_TTL_DAYS;
+ return Math.min(Math.floor(days), MAX_TTL_DAYS);
+}
+
+// Returns the code once. It is never recoverable afterwards — only its hash and
+// the last four characters are kept.
+async function create(adminUserId, options) {
+ var opts = options || {};
+ var code = generateCode();
+ var days = ttlDays(opts.days);
+ var note = String(opts.note || '').trim().slice(0, 200);
+ await db().run(
+ "INSERT INTO registration_invites (code_hash, code_hint, note, created_by, expires_at) " +
+ "VALUES ($1, $2, $3, $4, NOW() + ($5 || ' days')::interval)",
+ [hash(code), normalize(code).slice(-4), note, adminUserId, String(days)]
+ );
+ return { code: code, days: days, note: note };
+}
+
+async function list() {
+ return db().all(
+ "SELECT i.id, i.code_hint, i.note, i.created_at, i.expires_at, i.used_at, i.revoked_at, " +
+ " c.email AS created_by_email, u.email AS used_by_email, " +
+ " CASE WHEN i.revoked_at IS NOT NULL THEN 'revoked' " +
+ " WHEN i.used_at IS NOT NULL THEN 'used' " +
+ " WHEN i.expires_at <= NOW() THEN 'expired' " +
+ " ELSE 'active' END AS status " +
+ "FROM registration_invites i " +
+ "LEFT JOIN users c ON c.id = i.created_by " +
+ "LEFT JOIN users u ON u.id = i.used_by " +
+ "ORDER BY i.created_at DESC LIMIT 200", []
+ );
+}
+
+async function revoke(id, adminUserId) {
+ // Revoking an already-used code would rewrite history, so only a live one.
+ var result = await db().run(
+ 'UPDATE registration_invites SET revoked_at = NOW(), revoked_by = $1 ' +
+ 'WHERE id = $2 AND revoked_at IS NULL AND used_at IS NULL',
+ [adminUserId, id]
+ );
+ return result.changes > 0;
+}
+
+async function remove(id) {
+ var result = await db().run('DELETE FROM registration_invites WHERE id = $1', [id]);
+ return result.changes > 0;
+}
+
+/**
+ * Claim a code for a registration, atomically.
+ *
+ * The UPDATE carries every condition, so two registrations racing the same code
+ * cannot both succeed: the second matches no row. Checking first and updating
+ * after would leave exactly that gap.
+ *
+ * Returns the invite id, or null if the code is unusable for any reason —
+ * unknown, expired, revoked or already used. The caller must not say which:
+ * distinguishing them tells someone probing codes which guesses were closer.
+ */
+async function claim(code, userId) {
+ var normalized = normalize(code);
+ if (!normalized) return null;
+ var rows = await db().all(
+ 'UPDATE registration_invites SET used_at = NOW(), used_by = $1 ' +
+ 'WHERE code_hash = $2 AND used_at IS NULL AND revoked_at IS NULL AND expires_at > NOW() ' +
+ 'RETURNING id',
+ [userId, hash(normalized)]
+ );
+ return rows && rows.length ? rows[0].id : null;
+}
+
+// True when a code must be supplied to register at all.
+async function inviteOnly() {
+ return String(await db().getSetting('registration_invite_only') || 'false') === 'true';
+}
+
+module.exports = {
+ DEFAULT_TTL_DAYS,
+ MAX_TTL_DAYS,
+ generateCode,
+ normalize,
+ hash,
+ ttlDays,
+ create,
+ list,
+ revoke,
+ remove,
+ claim,
+ inviteOnly
+};
diff --git a/test/backend-hardening.test.js b/test/backend-hardening.test.js
index 88a28845..26067df2 100644
--- a/test/backend-hardening.test.js
+++ b/test/backend-hardening.test.js
@@ -150,3 +150,45 @@ test('.env.example documents every variable the app reads', () => {
const missing = [...used].filter(name => !documented.has(name)).sort();
assert.deepEqual(missing, [], 'undocumented variables: ' + missing.join(', '));
});
+
+// registration_enabled is open-or-closed. Invite-only is the middle setting,
+// and it has to hold up against someone probing codes.
+test('registration invites are single-use, expiring, and safe to store', () => {
+ const fs = require('node:fs');
+ const path = require('node:path');
+ const root = path.join(__dirname, '..');
+ const read = f => fs.readFileSync(path.join(root, f), 'utf8');
+ const invites = require('../src/utils/registrationInvites');
+ const migration = read('migrations/1780100000000_registration-invites.js');
+ const auth = read('src/routes/auth.js');
+
+ // A code must not be recoverable from a database dump.
+ assert.match(migration, /code_hash TEXT NOT NULL UNIQUE/);
+ assert.doesNotMatch(migration, /\bcode TEXT\b/, 'the code itself is never stored');
+ assert.equal(invites.hash('abcd-efgh'), invites.hash('ABCDEFGH'), 'hashing normalises case and separators');
+ assert.notEqual(invites.hash('a'), 'a');
+
+ // Generated codes avoid characters that are misread when typed from a screen.
+ const code = invites.generateCode();
+ assert.match(code, /^[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}$/);
+ assert.doesNotMatch(code, /[ILOU]/, 'no I, L, O or U');
+ assert.notEqual(invites.generateCode(), invites.generateCode());
+
+ // Expiry is bounded, and a nonsense value falls back rather than throwing.
+ assert.equal(invites.ttlDays(undefined), invites.DEFAULT_TTL_DAYS);
+ assert.equal(invites.ttlDays(0), invites.DEFAULT_TTL_DAYS);
+ assert.equal(invites.ttlDays('nonsense'), invites.DEFAULT_TTL_DAYS);
+ assert.equal(invites.ttlDays(10000), invites.MAX_TTL_DAYS);
+
+ // The claim is one conditional UPDATE, so two registrations racing the same
+ // code cannot both succeed.
+ const claim = read('src/utils/registrationInvites.js');
+ assert.match(claim, /UPDATE registration_invites SET used_at = NOW\(\), used_by = \$1 /);
+ assert.match(claim, /WHERE code_hash = \$2 AND used_at IS NULL AND revoked_at IS NULL AND expires_at > NOW\(\)/);
+ assert.match(claim, /RETURNING id/);
+
+ // Losing that race must not leave a free account behind.
+ assert.match(auth, /if \(!claimedInvite\) \{[\s\S]{0,120}DELETE FROM users WHERE id = \?/);
+ // And the rejection must not say which of the four reasons applied.
+ assert.match(auth, /may have expired, been revoked, or already been used/);
+});