From 191b3b433b669e08672e5fa69e070de04ddabb47 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 21:00:22 +0200 Subject: [PATCH] wip: invite codes stay copyable --- .../1781000000000_invite-code-recoverable.js | 23 +++++++++++ public/js/admin.js | 37 ++++++++++++++++- src/routes/adminConfig.js | 41 ++++++++++++++++++- src/utils/registrationInvites.js | 40 +++++++++++++++--- test/chat-model-discovery-filter.test.js | 39 ++++++++++++++++++ 5 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 migrations/1781000000000_invite-code-recoverable.js create mode 100644 test/chat-model-discovery-filter.test.js 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'); +});