openvibers/openvibe.exchange Commits
Add masked provider key status, check and removal controls (#172)
Changes
@@@ -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);@@@ -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>`;}@@@ -5,7 +5,9 @@ const { providersSection, modelsSection, pricebookSection } = require('../serverconst 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');@@@ -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' },@@@ -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();
@@ -27,7 +27,7 @@ function createConsoleRoutes({ config, ai }) {
@@ -27,7 +27,7 @@ function createConsoleRoutes({ config, ai }) {
head: ['providers', 'models', 'pricebook'].includes(section) ? DESIGNED_HEAD : undefined,
head: ['providers', 'models', 'pricebook'].includes(section) ? DESIGNED_HEAD : undefined,
body: raw(consolePage({ section, data, error })),
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
// 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.
// 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>');
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 }) {
@@ -145,13 +145,15 @@ function createConsoleRoutes({ config, ai }) {
await ai.adminRequest(`/api/v1/providers/${key}/${endpoint}`, { method: 'POST', subject: req.viewer.subject });
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 === '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 === '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: {
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),
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',
markup_percent: Number(req.body.markup_percent), uncensored: req.body.uncensored === '1',
} });
} });
else return res.sendStatus(404);
else return res.sendStatus(404);
return res.redirect(303, '/console/providers');
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) => {
r.post('/console/provider-credits', async (req, res) => {
if (!sameOrigin(req)) return res.sendStatus(403);
if (!sameOrigin(req)) return res.sendStatus(403);
@@ -62,6 +62,14 @@ function providerCard(p, models) {
@@ -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>
<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>
<p class="exc-actions">${btn('Save settings', 'primary')}</p>
</form>`;
</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">
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>
<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>
<p class="exc-actions">${btn(p.key_present ? 'Replace key' : 'Save key')}</p>
@@ -71,7 +79,7 @@ function providerCard(p, models) {
@@ -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>
<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>
<p class="exc-tags">${tags}</p>
${accessNote ? `<p class="exc-note">${esc(accessNote)}</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>
<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>`;
</li>`;
}
}
@@ -5,7 +5,9 @@ const { providersSection, modelsSection, pricebookSection } = require('../server
@@ -5,7 +5,9 @@ const { providersSection, modelsSection, pricebookSection } = require('../server
const secret = 'sk-live-never-on-a-page';
const secret = 'sk-live-never-on-a-page';
const providers = [
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' },
{ 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 = [
const presets = [
@@ -20,6 +22,11 @@ assert.ok(!html.includes(secret), 'a key value never reaches the page');
@@ -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('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('18+'), 'the uncensored tier is marked 18+');
assert.ok(html.includes('type="password"'), 'keys are typed into password fields');
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>`));
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(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');
assert.ok(!html.includes('<b>OpenAI</b><small>api.openai.com · 0'), 'an added provider is not offered again as a preset');
@@ -27,7 +27,7 @@ app.use(createConsoleRoutes({
@@ -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'];
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' }]) {
for (const path of consolePaths) for (const headers of [{}, { 'x-test-role': 'user' }, { 'x-test-role': 'admin' }]) {
const response = await fetch(`${base}${path}`, { headers });
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 response = await fetch(`${base}/console/providers`, { headers: { 'x-test-role': 'owner' } });
const html = await response.text();
const html = await response.text();
@@ -53,6 +53,29 @@ app.use(createConsoleRoutes({
@@ -53,6 +53,29 @@ app.use(createConsoleRoutes({
assert.strictEqual(keyWrite.status, 303);
assert.strictEqual(keyWrite.status, 303);
assert.ok(!keyAnswer.includes(fakeSecret), 'a provider key POST never echoes its value');
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 } });
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`, {
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' },
method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },
@@ -37,9 +37,9 @@ app.use(createConsoleRoutes({
@@ -37,9 +37,9 @@ app.use(createConsoleRoutes({
await new Promise((r) => server.listen(0, '127.0.0.1', r));
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = 'http://127.0.0.1:' + server.address().port;
const base = 'http://127.0.0.1:' + server.address().port;
try {
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']) {
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 page = await fetch(base + '/console/defaults', { headers: { 'x-role': 'owner' } });
const html = await page.text();
const html = await page.text();