openvibers/openvibe.exchange Commits

Add masked provider key status, check and removal controls (#172)

OP OpenVibers committed 3 days ago · f5d00cb035 · 5 files, +47 −7

* Add owner provider key checks and removal to the console

* Keep stored provider keys removable when unavailable

Changes

ddiff --git a/server/http/console.js b/server/http/console.js
iindex d8a4db0..e5b428f 100644
---- a/server/http/console.js
++++ b/server/http/console.js
@@@ -27,7 +27,7 @@ function createConsoleRoutes({ config, ai }) {
head: ['providers', 'models', 'pricebook'].includes(section) ? DESIGNED_HEAD : undefined,
body: raw(consolePage({ section, data, error })),
});
-- r.use((req, res, next) => isOwner(req) ? next() : res.sendStatus(404));
++ r.use((req, res, next) => isOwner(req) ? next() : res.sendStatus(req.viewer?.kind === 'user' ? 403 : 404));
// The owner's AI defaults: one row per task, the model inline, and the fallback order. Posting one task's form
// saves only that task, and AI resolves it within a minute.
const DEFAULTS_HEAD = raw('<link rel="stylesheet" href="/css/task-defaults.css"><script src="/js/task-defaults.js" defer></script>');
@@@ -145,13 +145,15 @@ function createConsoleRoutes({ config, ai }) {
await ai.adminRequest(`/api/v1/providers/${key}/${endpoint}`, { method: 'POST', subject: req.viewer.subject });
} else if (action === 'reset') await ai.adminRequest(`/api/v1/providers/${key}/reset`, { method: 'POST', subject: req.viewer.subject });
else if (action === 'key') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/key`, { method: 'POST', subject: req.viewer.subject, body: { api_key: String(req.body.api_key || '') } });
++ else if (action === 'remove-key') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/key`, { method: 'DELETE', subject: req.viewer.subject });
++ else if (action === 'check') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/check`, { method: 'POST', subject: req.viewer.subject });
else if (action === 'settings') await ai.adminRequest(`/api/v1/providers/${key}`, { method: 'PATCH', subject: req.viewer.subject, body: {
status: req.body.status, provider_access: req.body.provider_access, priority: Number(req.body.preference_order),
markup_percent: Number(req.body.markup_percent), uncensored: req.body.uncensored === '1',
} });
else return res.sendStatus(404);
return res.redirect(303, '/console/providers');
-- } catch (err) { return page(req, res, 'providers', {}, err.message || 'The change could not be saved.', 502); }
++ } catch { return page(req, res, 'providers', {}, 'The change could not be saved.', 502); }
});
r.post('/console/provider-credits', async (req, res) => {
if (!sameOrigin(req)) return res.sendStatus(403);
ddiff --git a/server/views/console-ai.js b/server/views/console-ai.js
iindex d46410c..12504c1 100644
---- a/server/views/console-ai.js
++++ b/server/views/console-ai.js
@@@ -62,6 +62,14 @@ function providerCard(p, models) {
<label class="exc-check"><input type="checkbox" name="uncensored" value="1"${p.uncensored ? ' checked' : ''}><span>Uncensored models (18+ only)</span></label>
<p class="exc-actions">${btn('Save settings', 'primary')}</p>
</form>`;
++ const keyState = p.key_state || {};
++ const check = p.last_check;
++ const keyStatus = keyState.source === 'not_required' ? '<p>No key required.</p>'
++ : keyState.hint ? `<p>Key ends in ${esc(keyState.hint)}${keyState.set_at ? ` · set on ${esc(keyState.set_at)}` : ''}.</p>` : '';
++ const keyActions = `${keyStatus}
++ ${check ? `<p>Check: ${check.ok ? 'OK' : esc(check.error || 'Failed')} · ${esc(check.latency_ms)} ms · ${esc(check.checked_at)}</p>` : ''}
++ <form method="post" action="/console/providers/${key}/check"><button class="ovk-btn ovk-btn--quiet" type="submit">Check provider</button></form>
++ ${p.key_present || keyState.source === 'stored' ? `<form method="post" action="/console/providers/${key}/remove-key"><button class="ovk-btn ovk-btn--quiet" type="submit">Remove key and disable</button></form>` : ''}`;
const keyForm = `<form class="exc-form exc-form--key" method="post" action="/console/providers/${key}/key">
<label><span>${p.key_present ? 'Replace the key' : 'Add the key'}</span><input name="api_key" type="password" autocomplete="off" minlength="8" maxlength="400" required placeholder="Paste the provider's API key"><small>Stored sealed and bound to ${esc(host(p.base_url) || 'this provider')}; never shown again.</small></label>
<p class="exc-actions">${btn(p.key_present ? 'Replace key' : 'Save key')}</p>
@@@ -71,7 +79,7 @@ function providerCard(p, models) {
<form method="post" action="/console/providers/${key}/status" class="exc-quick"><input type="hidden" name="status" value="${on ? 'disabled' : 'active'}">${btn(on ? 'Turn off' : 'Turn on')}</form></div>
<p class="exc-tags">${tags}</p>
${accessNote ? `<p class="exc-note">${esc(accessNote)}</p>` : ''}
-- <details class="exc-fold"><summary>Settings and key</summary><div class="exc-fold-body">${settings}${keyForm}
++ <details class="exc-fold"><summary>Settings and key</summary><div class="exc-fold-body">${settings}${keyState.source === 'not_required' ? '' : keyForm}${keyActions}
<form method="post" action="/console/providers/${key}/reset" class="exc-inline"><p class="exc-note">Reset clears its failure count and opens it to traffic again.</p>${btn('Reset health')}</form></div></details>
</li>`;
}
ddiff --git a/test/console-ai.test.js b/test/console-ai.test.js
iindex 3b0187c..6a754f0 100644
---- a/test/console-ai.test.js
++++ b/test/console-ai.test.js
@@@ -5,7 +5,9 @@ const { providersSection, modelsSection, pricebookSection } = require('../server
const secret = 'sk-live-never-on-a-page';
const providers = [
-- { key: 'openai', display_name: 'OpenAI', base_url: 'https://api.openai.com/v1', status: 'active', key_present: true, preference_order: 10, markup_percent: 0, provider_access: 'AUTHORIZED_WHOLESALE', health: { state: 'up' }, api_key: secret },
++ { key: 'openai', display_name: 'OpenAI', base_url: 'https://api.openai.com/v1', status: 'active', key_present: true, preference_order: 10, markup_percent: 0, provider_access: 'AUTHORIZED_WHOLESALE', health: { state: 'up' }, api_key: secret,
++ key_state: { source: 'stored', hint: '…abcd', set_at: '2026-11-10T00:00:00.000Z' },
++ last_check: { ok: false, error: 'http_401', latency_ms: 42, checked_at: '2026-11-10T00:01:00.000Z' } },
{ key: 'venice', display_name: 'Venice AI', base_url: 'https://api.venice.ai/api/v1', status: 'disabled', key_present: false, preference_order: 60, uncensored: true, provider_access: 'UNVERIFIED' },
];
const presets = [
@@@ -20,6 +22,11 @@ assert.ok(!html.includes(secret), 'a key value never reaches the page');
assert.ok(html.includes('Key stored') && html.includes('No key'), 'a key is only present or not');
assert.ok(html.includes('18+'), 'the uncensored tier is marked 18+');
assert.ok(html.includes('type="password"'), 'keys are typed into password fields');
++assert.ok(html.includes('Key ends in …abcd') && html.includes('set on 2026-11-10'), 'only the key marker and date are rendered');
++assert.ok(html.includes('http_401') && html.includes('42 ms'), 'the last check and latency are visible');
++assert.ok(html.includes('action="/console/providers/openai/check"') && html.includes('action="/console/providers/openai/remove-key"'), 'explicit check and removal controls');
++const locked = providersSection({ providers: [{ ...providers[0], key_present: false }], models: [], presets: [] });
++assert.ok(locked.includes('action="/console/providers/openai/remove-key"'), 'a stored key remains removable when it cannot be decrypted');
const order = ['Cheap', 'Pricey', 'Unpriced'].map((n) => html.indexOf(`<b>${n}</b>`));
assert.ok(order.every((i) => i > 0) && order[0] < order[1] && order[1] < order[2], 'presets: cheapest first, unpriced last');
assert.ok(!html.includes('<b>OpenAI</b><small>api.openai.com · 0'), 'an added provider is not offered again as a preset');
ddiff --git a/test/console-merge.test.js b/test/console-merge.test.js
iindex 575860f..d3611e4 100644
---- a/test/console-merge.test.js
++++ b/test/console-merge.test.js
@@@ -27,7 +27,7 @@ app.use(createConsoleRoutes({
const consolePaths = ['/console', ...Object.keys(READS).map((section) => `/console/${section}`), '/console/providers/new', '/console/providers/openai', '/console/gpus/model-a/quote', '/console/data/providers', '/console/data/models', '/console/data/pricebook'];
for (const path of consolePaths) for (const headers of [{}, { 'x-test-role': 'user' }, { 'x-test-role': 'admin' }]) {
const response = await fetch(`${base}${path}`, { headers });
-- assert.strictEqual(response.status, 404, `${path}: signed-out people and non-owners see no console route`);
++ assert.strictEqual(response.status, headers['x-test-role'] ? 403 : 404, `${path}: signed-in non-owners are forbidden; signed-out people see no console route`);
}
const response = await fetch(`${base}/console/providers`, { headers: { 'x-test-role': 'owner' } });
const html = await response.text();
@@@ -53,6 +53,29 @@ app.use(createConsoleRoutes({
assert.strictEqual(keyWrite.status, 303);
assert.ok(!keyAnswer.includes(fakeSecret), 'a provider key POST never echoes its value');
assert.deepStrictEqual(writes[0], { path: '/api/v1/admin/console/providers/openai/key', method: 'POST', subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX', body: { api_key: fakeSecret } });
++ for (const action of ['key', 'remove-key', 'check']) {
++ for (const role of ['user', 'admin']) {
++ const count = writes.length;
++ const denied = await fetch(`${base}/console/providers/openai/${action}`, {
++ method: 'POST', headers: { 'x-test-role': role, Origin: 'https://openvibe.exchange' },
++ });
++ assert.strictEqual(denied.status, 403);
++ assert.strictEqual(writes.length, count, 'non-owners cannot reach the AI service');
++ }
++ const csrf = await fetch(`${base}/console/providers/openai/${action}`, {
++ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://evil.example' },
++ });
++ assert.strictEqual(csrf.status, 403);
++ }
++ for (const [action, method, endpoint] of [['check', 'POST', 'check'], ['remove-key', 'DELETE', 'key']]) {
++ const count = writes.length;
++ const result = await fetch(`${base}/console/providers/openai/${action}`, {
++ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange' }, redirect: 'manual',
++ });
++ assert.strictEqual(result.status, 303);
++ assert.strictEqual(writes.length, count + 1);
++ assert.deepStrictEqual(writes.at(-1), { path: `/api/v1/admin/console/providers/openai/${endpoint}`, method, subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX' });
++ }
const createProvider = await fetch(`${base}/console/providers`, {
method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },
ddiff --git a/test/task-defaults.test.js b/test/task-defaults.test.js
iindex fa9cf03..d6ec537 100644
---- a/test/task-defaults.test.js
++++ b/test/task-defaults.test.js
@@@ -37,9 +37,9 @@ app.use(createConsoleRoutes({
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = 'http://127.0.0.1:' + server.address().port;
try {
-- // Owner rank only: no viewer, a person and an admin all get 404.
++ // Owner rank only: signed-in non-owners are forbidden; signed-out people see no route.
for (const role of ['', 'user', 'admin']) {
-- assert.strictEqual((await fetch(base + '/console/defaults', { headers: role ? { 'x-role': role } : {} })).status, 404, `${role || 'signed out'} is not the owner`);
++ assert.strictEqual((await fetch(base + '/console/defaults', { headers: role ? { 'x-role': role } : {} })).status, role ? 403 : 404, `${role || 'signed out'} is not the owner`);
}
const page = await fetch(base + '/console/defaults', { headers: { 'x-role': 'owner' } });
const html = await page.text();