wip: invite codes stay copyable

This commit is contained in:
Daniel 2026-09-12 21:00:22 +02:00
parent 46112e1221
commit 191b3b433b
5 changed files with 172 additions and 8 deletions

View file

@ -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');
};

View file

@ -1815,7 +1815,14 @@ initImageSettings();
var who = row.used_by_email ? ' by ' + esc(row.used_by_email) : '';
return '<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:var(--g50);font-size:13px;flex-wrap:wrap;">' +
'<span style="font-size:10px;font-weight:700;text-transform:uppercase;padding:2px 7px;border-radius:10px;color:white;background:' + (colours[row.status] || 'var(--g400)') + ';">' + esc(row.status) + '</span>' +
'<code style="font-size:12px;">****-' + esc(row.code_hint) + '</code>' +
// 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
? '<code class="invite-code" style="font-size:12px;">' + esc(row.code) + '</code>' +
'<button type="button" class="btn-sm btn-ghost admin-invite-copy" data-code="' + esc(row.code) + '" title="Copy this code"><i class="fas fa-copy"></i></button>'
: '<code style="font-size:12px;" title="Made before codes were kept">****-' + esc(row.code_hint) + '</code>') +
'<span style="flex:1;min-width:0;overflow-wrap:anywhere;">' + esc(row.note || '') + '</span>' +
'<span style="font-size:11px;color:var(--g500);">' + esc(when) + who + '</span>' +
(row.status === 'active' ? '<button type="button" class="btn-sm btn-ghost admin-invite-revoke" data-id="' + esc(String(row.id)) + '">Revoke</button>' : '') +
@ -1830,6 +1837,34 @@ initImageSettings();
'</div>';
}).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) {

View file

@ -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'); }
});

View file

@ -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,

View file

@ -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');
});