diff --git a/migrations/1781000000000_invite-code-recoverable.js b/migrations/1781000000000_invite-code-recoverable.js
new file mode 100644
index 00000000..730bb207
--- /dev/null
+++ b/migrations/1781000000000_invite-code-recoverable.js
@@ -0,0 +1,23 @@
+// Invite codes were shown once and never again: only a SHA-256 hash and the
+// last four characters were kept. That is the right shape for a password and
+// the wrong one for an invitation, which has to be given to somebody — usually
+// not at the moment it is created, and often more than once.
+//
+// The code is now also stored encrypted with DATA_ENCRYPTION_KEY, the same
+// AES-256-GCM treatment as Nextcloud tokens and image prompts. The hash stays:
+// it is what a claim looks up, it is indexed, and a claim must not depend on
+// decrypting every row.
+//
+// What this costs, stated plainly: a code is recoverable by anyone who holds
+// both the database and the encryption key, where before it was recoverable by
+// nobody. An invitation is a low-value secret — it grants registration, not
+// access — and it expires. Existing rows keep working and stay unrecoverable;
+// they simply have no cipher to show.
+
+exports.up = async function (pgm) {
+ pgm.sql('ALTER TABLE registration_invites ADD COLUMN IF NOT EXISTS code_cipher TEXT');
+};
+
+exports.down = async function (pgm) {
+ pgm.sql('ALTER TABLE registration_invites DROP COLUMN IF EXISTS code_cipher');
+};
diff --git a/public/js/admin.js b/public/js/admin.js
index f7d06011..89d092da 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -1815,7 +1815,14 @@ initImageSettings();
var who = row.used_by_email ? ' by ' + esc(row.used_by_email) : '';
return '
' +
'' + esc(row.status) + '' +
- '****-' + esc(row.code_hint) + '' +
+ // The code itself when it is still recoverable, with a button to copy
+ // it: an invitation has to be given to somebody, usually later than the
+ // moment it was made. A row from before codes were kept shows the four
+ // characters it has.
+ (row.code
+ ? '' + esc(row.code) + '' +
+ ''
+ : '****-' + esc(row.code_hint) + '') +
'' + esc(row.note || '') + '' +
'' + esc(when) + who + '' +
(row.status === 'active' ? '' : '') +
@@ -1830,6 +1837,34 @@ initImageSettings();
'
';
}).join('');
+ container.querySelectorAll('.admin-invite-copy').forEach(function(btn) {
+ btn.addEventListener('click', function() {
+ var code = btn.dataset.code || '';
+ // The async clipboard API needs a secure context and permission; the
+ // textarea fallback is what works everywhere else, including plain http
+ // on a local network.
+ var done = function() {
+ var icon = btn.querySelector('i');
+ if (icon) { icon.className = 'fas fa-check'; setTimeout(function() { icon.className = 'fas fa-copy'; }, 1200); }
+ showToast('Code copied', 'success');
+ };
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard.writeText(code).then(done).catch(fallback);
+ } else { fallback(); }
+ function fallback() {
+ var box = document.createElement('textarea');
+ box.value = code;
+ box.setAttribute('readonly', '');
+ box.style.cssText = 'position:fixed;top:-1000px;opacity:0;';
+ document.body.appendChild(box);
+ box.select();
+ try { document.execCommand('copy'); done(); }
+ catch (e) { showToast('Could not copy — select the code and copy it', 'error'); }
+ box.remove();
+ }
+ });
+ });
+
var spent = rows.filter(function(row) { return SPENT_STATUS.indexOf(row.status) !== -1; }).length;
var clear = document.getElementById('btn-clear-used-invites');
if (clear) {
diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js
index 0d4ae7d8..23ddfb0b 100644
--- a/src/routes/adminConfig.js
+++ b/src/routes/adminConfig.js
@@ -514,10 +514,40 @@ router.post('/config/models/clear-all', async function(req, res) {
});
// ── GET discover models from provider API ─────────────────────────────────
+// Modes that are definitely not a chat model. The filter excludes what the
+// gateway says is something else, rather than requiring it to say 'chat': a
+// model with no metadata is unknown, not disqualified, and requiring a positive
+// 'chat' would hide every model the gateway has no mode for.
+var NON_CHAT_MODES = ['image_generation', 'rerank', 'audio_speech', 'audio_transcription',
+ 'embedding', 'moderation'];
+
+async function nonChatModelIds() {
+ if (!process.env.LITELLM_API_BASE) return new Set();
+ try {
+ var axios = require('axios');
+ var resp = await axios.get(liteLLMBaseUrl() + '/model/info',
+ { headers: getLiteLLMAdminHeaders(), timeout: 10000 });
+ var out = new Set();
+ ((resp.data && resp.data.data) || []).forEach(function (m) {
+ var id = liteLLMModelId(m);
+ if (id && NON_CHAT_MODES.indexOf(liteLLMModelMode(m)) !== -1) out.add(id);
+ });
+ return out;
+ } catch (e) {
+ // No metadata means no filtering, which is what it did before.
+ return new Set();
+ }
+}
+
router.get('/config/models/discover', async function(req, res) {
try {
var { discoverModels } = require('../utils/ai');
var discovered = await discoverModels();
+ // /v1/models carries no mode, so rerankers, image and speech models all
+ // arrived in a list meant for choosing a chat model. /model/info does carry
+ // it, which is where every other discovery endpoint already looks.
+ var exclude = await nonChatModelIds();
+ discovered = discovered.filter(function (m) { return !exclude.has(m.id); });
var search = (req.query.q || '').toLowerCase().trim();
if (search) {
discovered = discovered.filter(function(m) {
@@ -775,7 +805,16 @@ 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(), lockdown: lockdown.state() });
+ // The code itself, decrypted for display, so an invitation can be copied
+ // again rather than only at the moment it was made. The cipher never leaves
+ // the server. A row created before codes were kept simply has no code, and
+ // its four-character hint is all there is to show.
+ var rows = (await invites.list()).map(function (row) {
+ var code = invites.decryptCode(row.code_cipher);
+ delete row.code_cipher;
+ return Object.assign(row, { code: code });
+ });
+ res.json({ success: true, invites: rows, inviteOnly: await invites.inviteOnly(), lockdown: lockdown.state() });
} catch (e) { return serverError(res, 'Invites list', e, 'Could not list invitations'); }
});
diff --git a/src/utils/registrationInvites.js b/src/utils/registrationInvites.js
index 9e7ab565..5fcdada6 100644
--- a/src/utils/registrationInvites.js
+++ b/src/utils/registrationInvites.js
@@ -10,6 +10,7 @@
// ============================================================
var crypto = require('crypto');
+var cryptoUtil = 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
@@ -43,30 +44,55 @@ function hash(code) {
return crypto.createHash('sha256').update(normalize(code)).digest('hex');
}
+// Encrypted with DATA_ENCRYPTION_KEY, like every other recoverable secret here.
+// Without a key configured the code is simply not kept — the invitation still
+// works, it just cannot be shown again, which is exactly the old behaviour.
+function encryptCode(code) {
+ try { return cryptoUtil.hasKey() ? cryptoUtil.encryptString(normalize(code)) : null; }
+ catch (e) { return null; }
+}
+
+function decryptCode(cipher) {
+ if (!cipher) return null;
+ try { return format(cryptoUtil.decryptString(cipher)); }
+ catch (e) { return null; }
+}
+
+// XXXX-XXXX-XXXX-XXXX from the stored, normalised form.
+function format(normalized) {
+ var plain = String(normalized || '');
+ if (plain.length !== 16) return plain || null;
+ return plain.replace(/(.{4})(?=.)/g, '$1-');
+}
+
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.
+// Returns the code, and keeps it. Three things are stored: the hash, which is
+// what a claim looks up; the last four characters, which are what an older row
+// has; and the code encrypted, which is what makes it copyable later.
+//
+// An invitation has to be given to somebody, usually later than the moment it
+// was made, and sometimes twice. Show-once was the wrong shape for that.
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)]
+ "INSERT INTO registration_invites (code_hash, code_hint, code_cipher, note, created_by, expires_at) " +
+ "VALUES ($1, $2, $3, $4, $5, NOW() + ($6 || ' days')::interval)",
+ [hash(code), normalize(code).slice(-4), encryptCode(code), 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, " +
+ "SELECT i.id, i.code_hint, i.code_cipher, 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' " +
@@ -148,6 +174,8 @@ async function inviteOnly() {
}
module.exports = {
+ decryptCode: decryptCode,
+ formatCode: format,
DEFAULT_TTL_DAYS,
MAX_TTL_DAYS,
generateCode,
diff --git a/test/chat-model-discovery-filter.test.js b/test/chat-model-discovery-filter.test.js
new file mode 100644
index 00000000..9334e532
--- /dev/null
+++ b/test/chat-model-discovery-filter.test.js
@@ -0,0 +1,39 @@
+// Choosing a chat model should not mean scrolling past rerankers, image models
+// and speech models. /v1/models carries no mode, so the list had everything the
+// gateway serves; /model/info does carry it, which is where every other
+// discovery endpoint already looks.
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const route = fs.readFileSync(path.join(__dirname, '..', 'src/routes/adminConfig.js'), 'utf8');
+
+test('every non-chat mode the gateway reports is excluded', () => {
+ assert.match(route, /var NON_CHAT_MODES = /);
+ for (const mode of ['image_generation', 'rerank', 'audio_speech', 'audio_transcription', 'embedding']) {
+ assert.ok(route.includes("'" + mode + "'"), mode + ' is not excluded');
+ }
+});
+
+test('a model with no mode is kept — unknown is not disqualified', () => {
+ // Requiring a positive 'chat' would hide every model the gateway has no
+ // metadata for, which on this roster is most of them.
+ assert.match(route, /excludes what the\n\/\/ gateway says is something else, rather than requiring it to say 'chat'/);
+ const fn = route.slice(route.indexOf('async function nonChatModelIds'));
+ assert.match(fn.slice(0, 900), /NON_CHAT_MODES\.indexOf\(liteLLMModelMode\(m\)\) !== -1/);
+ assert.doesNotMatch(fn.slice(0, 900), /mode === 'chat'/);
+});
+
+test('unreachable metadata filters nothing rather than emptying the list', () => {
+ const fn = route.slice(route.indexOf('async function nonChatModelIds'));
+ assert.match(fn.slice(0, 1100), /catch \(e\) \{[\s\S]{0,160}return new Set\(\)/);
+ assert.match(fn, /No metadata means no filtering, which is what it did before/);
+});
+
+test('the filter runs before the search, so a search cannot reveal a hidden model', () => {
+ const handler = route.slice(route.indexOf("router.get('/config/models/discover'"));
+ const filter = handler.indexOf('exclude.has(m.id)');
+ const search = handler.indexOf('req.query.q');
+ assert.ok(filter > -1 && filter < search, 'exclusion must precede the search filter');
+});