openvibers/openvibe.exchange Commits

Add personal AI credential settings to Exchange (#131)

OP OpenVibers committed 5 days ago · 2ce30cbfc0 · 11 files, +210 −10

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex e010523..d3276ae 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -18,7 +18,7 @@
"multer": "^2.5.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.103.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0",
"pg": "^8.23.0"
},
@@@ -1221,9 +1221,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.94.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
-- "integrity": "sha512-p3buZSOSFV81OdZgs5GoSIfMCQNJjW23eAIXIt0RVFIOvh3TByNNEMmuCanxRybFIbzh+9IDMbBwHJrBLl3JeA==",
++ "version": "0.103.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.103.0",
++ "integrity": "sha512-vFyBZs3NmBjeRkxe1Q8qAvsz/oE3l1HsLdsoTtI1JKaEIqV134VRcOEkXzkNZR+lnppKALJQoBRqr38RFb8BSQ==",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 0755e51..a2fd58d 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"multer": "^2.5.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.103.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0",
"pg": "^8.23.0"
},
ddiff --git a/public/css/exchange.css b/public/css/exchange.css
iindex c16f00c..0f81721 100644
---- a/public/css/exchange.css
++++ b/public/css/exchange.css
@@@ -672,3 +672,20 @@ body.ex-companion-shell > main.ex { flex: 1; min-height: 0; display: flex; }
.ex-console-action label, .ex-console-quota label { display: grid; gap: 5px; min-width: 0; }
.ex-console-action input, .ex-console-action select, .ex-console-quota input, .ex-console-quota select { min-width: 0; min-height: 44px; }
.ex-console-quota { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 160px), 1fr)); gap: 10px; margin: 8px 0 14px; }
++
++.ex-credentials { min-width: 0; }
++.ex-credentials-head, .ex-credential-section, .ex-credential-balance { max-width: 920px; margin: 0 auto 20px; min-width: 0; }
++.ex-credential-section, .ex-credential-balance { padding: 16px; border: 1px solid var(--border, #1f2d47); border-radius: 18px; background: var(--bg-secondary, #101828); }
++.ex-credential-section h2, .ex-credential-balance h2 { margin-top: 0; }
++.ex-credential-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 12px; }
++.ex-credential-card { min-width: 0; padding: 14px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; overflow-wrap: anywhere; }
++.ex-credential-card > div { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; }
++.ex-credential-kind { color: var(--text-muted, #7386a3); font-size: .82rem; }
++.ex-credential-card dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 5px 10px; margin: 12px 0; font-size: .88rem; }
++.ex-credential-card dt { color: var(--text-muted, #7386a3); }
++.ex-credential-card dd { margin: 0; overflow-wrap: anywhere; }
++.ex-credential-card form { display: inline-block; margin: 4px 8px 0 0; }
++.ex-credential-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr)); gap: 12px; }
++.ex-credential-form label { display: grid; gap: 6px; min-width: 0; }
++.ex-credential-form input, .ex-credential-form select { width: 100%; min-width: 0; min-height: 44px; }
++.ex-credential-balance pre { max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
ddiff --git a/server/ai.js b/server/ai.js
iindex 0581ce3..5d44f91 100644
---- a/server/ai.js
++++ b/server/ai.js
@@@ -14,6 +14,7 @@
* numbers are not available instead of showing stale ones as live.
*/
const { createServiceTokenClient } = require('openvibe-sdk/auth');
++const { createCredentialsClient } = require('openvibe-sdk/credentials');
const CATALOG_MS = 30_000;
const USAGE_MS = 60_000;
@@@ -294,8 +295,25 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
async function ownCreate(token, body) { return ownCall('POST', '/api/v1/own-endpoints', { token, body }); }
async function ownStop(token, id) { return ownCall('POST', `/api/v1/own-endpoints/${encodeURIComponent(id)}/stop`, { token }); }
++ /** The person's own AI credentials: pass only their signed Network session, never the Exchange service token. */
++ async function meRequest(token, path, { method = 'GET', body = undefined } = {}) {
++ if (!token) throw Object.assign(new Error('Sign in is required'), { status: 401 });
++ const headers = { Accept: 'application/json', Authorization: `Bearer ${token}`, Origin: config.baseUrl };
++ if (body !== undefined) headers['Content-Type'] = 'application/json';
++ const res = await fetchImpl(`${config.ai.url}${path}`, {
++ method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
++ signal: AbortSignal.timeout(config.ai.timeoutMs),
++ });
++ const out = await jsonOf(res);
++ if (!res.ok) throw Object.assign(new Error((out && (out.detail || out.title)) || `AI answered ${res.status}`), { status: res.status, code: out && out.code });
++ return out || {};
++ }
++ function credentialsFor(token) {
++ const client = { json: ({ method, path, body }) => meRequest(token, path, { method, body }) };
++ return createCredentialsClient(client, { baseUrl: config.ai.url });
++ }
-- return { catalog, usage, communityOffers, chat, presetsFor, setKeyPrivacy, ownedProjects, gpuQuote, ownStatus, ownList, ownCreate, ownStop, adminRequest, configured: Boolean(tokens) };
++ return { catalog, usage, communityOffers, chat, presetsFor, setKeyPrivacy, ownedProjects, gpuQuote, ownStatus, ownList, ownCreate, ownStop, adminRequest, meRequest, credentialsFor, configured: Boolean(tokens) };
}
/**
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 91cd015..12e3b62 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -128,6 +128,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Release metadata (JSON)', url: abs('/release.json') },
{ title: 'AI router API', url: 'https://ai.openvibe.services', note: 'the API and run receipts' },
{ title: 'AI operator console', url: abs('/console'), note: 'owner-only controls for providers, models, routes, quotas, usage and runs' },
++ { title: 'Your keys and accounts', url: abs('/privacy/keys'), note: 'manage your own provider keys and supported browser session cookies' },
{ title: 'OpenVibe Link', url: 'https://openvibe.link', note: 'offer a machine to the network' },
],
}));
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex c238dfa..afcc53a 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -27,6 +27,7 @@ const { esc, raw } = require('../render/html');
const marks = require('../views/marks');
const { send } = require('../render/layout');
const views = require('../views/exchange');
++const credentialViews = require('../views/credentials');
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 60 });
const RETIRED_PATHS = ['/listings', '/listings/*', '/submit', '/sandbox', '/sandbox/*', '/installs', '/offers', '/safety', '/developers'];
@@@ -41,6 +42,21 @@ function createPageRoutes(ctx) {
const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
const owner = (req) => req.viewer.subject;
const notFromHere = (req, res) => page(req, res, { title: 'Not from this site', index: false, body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'shield', title: 'That came from somewhere else', text: 'Use the form on OpenVibe.Exchange.' })}</div>`) }, 403);
++ const credentialPage = async (req, res, { error = null, balance = null } = {}, status = 200) => {
++ let credentials = [];
++ try {
++ const out = ai.credentialsFor
++ ? await ai.credentialsFor(req.viewer.token).list()
++ : await ai.meRequest(req.viewer.token, '/api/v1/me/credentials');
++ credentials = Array.isArray(out.credentials) ? out.credentials : [];
++ } catch (err) { error = error || 'OpenVibe.AI could not read your credential list.'; status = err.status === 401 ? 401 : 502; }
++ return page(req, res, { title: 'Your keys and accounts', index: false, body: raw(credentialViews.credentialsPage({ credentials, error, balance })) }, status);
++ };
++ const credentialFailure = (err, secrets = []) => {
++ let message = err && err.status === 422 ? (err.message || 'The credential was not accepted.') : 'OpenVibe.AI could not complete that credential request.';
++ for (const secret of secrets) if (secret) message = message.split(String(secret)).join('[hidden]');
++ return message;
++ };
const signInPage = (req, res, { title, text, next, status = 401 }) => page(req, res, {
title, index: false,
body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'person', title, text, action: { href: `/auth/login?next=${encodeURIComponent(next || req.originalUrl)}`, label: 'Sign in with OpenVibe' } })}</div>`),
@@@ -432,6 +448,48 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''
return page(req, res, { title: 'The API', index: false, body: raw(views.apiPage({ catalog, usage, viewer: req.viewer, keyPrivacy })) });
});
++ // A person's AI credentials live on Exchange. The user's signed Network token travels server-to-server to AI;
++ // the page and its responses contain metadata only, never a saved API key or cookie value.
++ r.get('/privacy/keys', async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/privacy/keys')}`);
++ return credentialPage(req, res);
++ });
++ r.post('/privacy/credentials', async (req, res) => {
++ if (!signedIn(req)) return res.sendStatus(404);
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const body = req.body || {};
++ const secrets = [body.key, body.cookie];
++ const payload = body.kind === 'session_cookie'
++ ? { kind: 'session_cookie', provider: String(body.provider || ''), cookie: String(body.cookie || ''), ...(body.org_id ? { org_id: String(body.org_id) } : {}) }
++ : { kind: 'api_key', provider: String(body.provider || ''), key: String(body.key || ''), ...(body.label ? { label: String(body.label).slice(0, 80) } : {}), ...(body.base_url ? { base_url: String(body.base_url) } : {}) };
++ try {
++ if (ai.credentialsFor) await ai.credentialsFor(req.viewer.token).add(payload);
++ else await ai.meRequest(req.viewer.token, '/api/v1/me/credentials', { method: 'POST', body: payload });
++ return res.redirect(303, '/privacy/keys');
++ } catch (err) {
++ return credentialPage(req, res, { error: credentialFailure(err, secrets) }, 200);
++ }
++ });
++ r.post('/privacy/credentials/:id/remove', async (req, res) => {
++ if (!signedIn(req)) return res.sendStatus(404);
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ try {
++ if (ai.credentialsFor) await ai.credentialsFor(req.viewer.token).remove(req.params.id);
++ else await ai.meRequest(req.viewer.token, `/api/v1/me/credentials/${encodeURIComponent(req.params.id)}`, { method: 'DELETE' });
++ return res.redirect(303, '/privacy/keys');
++ } catch (err) { return credentialPage(req, res, { error: credentialFailure(err) }, 200); }
++ });
++ r.post('/privacy/credentials/:id/balance', async (req, res) => {
++ if (!signedIn(req)) return res.sendStatus(404);
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ try {
++ const result = ai.credentialsFor
++ ? await ai.credentialsFor(req.viewer.token).balance(req.params.id)
++ : await ai.meRequest(req.viewer.token, `/api/v1/me/credentials/${encodeURIComponent(req.params.id)}/balance`);
++ return credentialPage(req, res, { balance: result.balance });
++ } catch (err) { return credentialPage(req, res, { error: credentialFailure(err) }, 200); }
++ });
++
r.get('/chat', async (req, res) => {
const list = await loadChats(req);
if (!list.length) return signInIfGuest(req, res, async () => {
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 4c395da..c904ee5 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -27,6 +27,7 @@ const NAV = [
{ label: 'Rankings', href: '/rankings', icon: 'fa-chart-line' },
{ label: 'Start building', href: '/start', icon: 'fa-bolt' },
{ label: 'API', href: '/api', icon: 'fa-code' },
++ { label: 'Your keys and accounts', href: '/privacy/keys', icon: 'fa-key', more: true },
{ label: 'Why this model', href: '/why', icon: 'fa-diagram-project', more: true },
{ label: 'Your own endpoint', href: '/own', icon: 'fa-server', more: true },
{ label: 'Sell compute', href: '/sell', icon: 'fa-microchip', more: true },
ddiff --git a/server/views/credentials.js b/server/views/credentials.js
nnew file mode 100644
iindex 0000000..6f385ad
---- /dev/null
++++ b/server/views/credentials.js
@@@ -0,0 +1,34 @@
++'use strict';
++
++const { esc } = require('../render/html');
++const PROVIDERS = ['anthropic', 'openai', 'gemini', 'deepseek', 'mistral', 'xai', 'groq', 'cerebras', 'together', 'fireworks', 'openrouter', 'minimax', 'custom'];
++const option = (value) => PROVIDERS.map((p) => `<option value="${p}"${p === value ? ' selected' : ''}>${p}</option>`).join('');
++
++function credentialsPage({ credentials = [], error = null, balance = null }) {
++ const cards = credentials.length ? credentials.map((c) => `<article class="ex-credential-card">
++<div><strong>${esc(c.label || c.provider || c.kind)}</strong><span class="ex-credential-kind">${esc(c.kind === 'session_cookie' ? 'Browser session cookie' : 'API key')}</span></div>
++<dl><dt>Provider</dt><dd>${esc(c.provider || '—')}</dd><dt>Status</dt><dd>${esc(c.status || (c.expired ? 'expired' : 'saved'))}</dd><dt>Added</dt><dd>${esc(c.created || '—')}</dd>${c.hint ? `<dt>Key hint</dt><dd>••••${esc(c.hint)}</dd>` : ''}${c.last_used ? `<dt>Last used</dt><dd>${esc(c.last_used)}</dd>` : ''}</dl>
++${c.kind === 'session_cookie' || ['openrouter', 'deepseek'].includes(c.provider) ? `<form method="post" action="/privacy/credentials/${encodeURIComponent(c.id)}/balance"><button class="ovk-button ovk-button-quiet" type="submit">Check balance</button></form>` : ''}
++<form method="post" action="/privacy/credentials/${encodeURIComponent(c.id)}/remove"><button class="ovk-button ovk-button-quiet" type="submit">Remove</button></form>
++</article>`).join('') : '<p class="ex-prose">No provider credentials are saved to your AI account yet.</p>';
++ return `<div class="ex-wrap ex-page ex-credentials">
++<header class="ex-credentials-head"><p class="ex-eyebrow">Your account</p><h1>Your keys and accounts</h1><p class="ex-prose">Provider keys and browser session cookies are stored encrypted by OpenVibe.AI. This page shows metadata and never displays a saved value.</p></header>
++${error ? `<p class="ex-console-error" role="alert">${esc(error)}</p>` : ''}
++${balance ? `<section class="ex-credential-balance" role="status"><h2>Balance from ${esc(balance.credential && balance.credential.provider || balance.provider || 'provider')}</h2><pre>${esc(JSON.stringify(balance.values || balance.balance || balance, null, 2))}</pre></section>` : ''}
++<section class="ex-credential-section"><h2>Saved credentials</h2><div class="ex-credential-list">${cards}</div></section>
++<section class="ex-credential-section"><h2>Add a provider API key</h2><form class="ovk-form ex-credential-form" method="post" action="/privacy/credentials">
++<input type="hidden" name="kind" value="api_key"><label>Provider<select name="provider">${option('openai')}</select></label>
++<label>API key<input name="key" type="password" autocomplete="new-password" required minlength="8" maxlength="400"></label>
++<label>Label (optional)<input name="label" maxlength="80" autocomplete="off"></label>
++<label>Custom base URL (custom provider only)<input name="base_url" type="url" placeholder="https://api.example.com/v1"></label>
++<button class="ovk-button" type="submit">Save and check key</button></form></section>
++<section class="ex-credential-section"><h2>Add a browser session cookie</h2><p class="ex-prose">A session cookie can grant access to your provider account until it expires or you remove it. Use a dedicated browser session and remove it here when you no longer need it. OpenVibe.AI stores it encrypted and checks only the provider balance endpoints it supports.</p>
++<form class="ovk-form ex-credential-form" method="post" action="/privacy/credentials"><input type="hidden" name="kind" value="session_cookie">
++<label>Provider<select name="provider"><option value="anthropic">Anthropic</option><option value="openai">OpenAI</option></select></label>
++<label>Cookie value<input name="cookie" type="password" autocomplete="new-password" required minlength="16" maxlength="4096"></label>
++<label>Anthropic organization ID (required for Anthropic)<input name="org_id" autocomplete="off" placeholder="UUID from provider console"></label>
++<button class="ovk-button ovk-button-quiet" type="submit">Save session cookie</button></form></section>
++</div>`;
++}
++
++module.exports = { credentialsPage, PROVIDERS };
ddiff --git a/server/views/exchange.js b/server/views/exchange.js
iindex ac44058..a13b5c4 100644
---- a/server/views/exchange.js
++++ b/server/views/exchange.js
@@@ -475,8 +475,8 @@ ${panel({ title: 'One endpoint', body: `
<p class="ex-prose"><code>${ENDPOINT}</code> speaks OpenAI's Chat Completions; <code>https://api.openvibe.exchange/v1/messages</code> speaks Anthropic's Messages API, so Claude Code works by pointing <code>ANTHROPIC_BASE_URL</code> at it.</p>
${kit.snippet({ code: `curl ${ENDPOINT} \\\n -H "Authorization: Bearer $OPENVIBE_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"openvibe/auto","messages":[{"role":"user","content":"Hello"}]}'`, id: 'api-curl' })}
<h3 class="ex-h3">Keys</h3>
--<p class="ex-prose">Keys are OpenVibe.Network developer keys: make one, name its scope, and revoke it any time. They are shown once and never stored here. Your own provider keys (OpenAI, Anthropic and the rest) are different: they stay sealed on <a href="https://ai.openvibe.services/you">OpenVibe.AI’s own provider-keys page</a>, which needs your sign-in, and are never copied here.</p>
--<p>${kit.button({ href: 'https://openvibe.network/developers', label: 'Make a key on Network' })} ${kit.button({ href: 'https://ai.openvibe.services/you', label: 'Your own provider keys', kind: 'quiet' })}</p>
++<p class="ex-prose">Keys are OpenVibe.Network developer keys: make one, name its scope, and revoke it any time. They are shown once and never stored here. Manage your own provider API keys and browser session cookies on <a href="/privacy/keys">Your keys and accounts</a>; saved values stay sealed on OpenVibe.AI and are never shown again.</p>
++<p>${kit.button({ href: 'https://openvibe.network/developers', label: 'Make a key on Network' })} ${kit.button({ href: '/privacy/keys', label: 'Your keys and accounts', kind: 'quiet' })}</p>
<h3 class="ex-h3" id="strict">Strict privacy</h3>
${switchBox}
<h3 class="ex-h3">Model ids</h3>
@@@ -1061,7 +1061,7 @@ ${kit.table({ columns: [{ key: 'limit', label: 'Limit', primary: true }, { key:
{ limit: 'A person’s own provider key', sandbox: 'Never used', production: 'Never used' },
], label: 'Sandbox and production', stack: true })}` })}
${panel({ title: 'Keys, endpoints and questions', id: 'more', body: `<p class="ex-prose">Part of <a href="https://openvibe.services/">OpenVibe Services</a>: the building blocks every OpenVibe product runs on, with one console, one account and one SDK.${models.length ? ` ${models.length} model${models.length === 1 ? '' : 's'} are on sale here right now.` : ''}</p>
--<p>${kit.button({ href: '/api', label: 'Keys and the API' })} ${kit.button({ href: '/own', label: 'Your own endpoint', kind: 'quiet' })} ${kit.button({ href: 'https://ai.openvibe.services/you', label: 'Your own provider keys', kind: 'quiet' })} ${kit.button({ href: '/faq', label: 'Questions', kind: 'quiet' })}</p>` })}
++<p>${kit.button({ href: '/api', label: 'Keys and the API' })} ${kit.button({ href: '/own', label: 'Your own endpoint', kind: 'quiet' })} ${kit.button({ href: '/privacy/keys', label: 'Your keys and accounts', kind: 'quiet' })} ${kit.button({ href: '/faq', label: 'Questions', kind: 'quiet' })}</p>` })}
</div>`;
}
ddiff --git a/test/credentials-settings.test.js b/test/credentials-settings.test.js
nnew file mode 100644
iindex 0000000..a592bd1
---- /dev/null
++++ b/test/credentials-settings.test.js
@@@ -0,0 +1,71 @@
++'use strict';
++
++const assert = require('assert');
++const express = require('express');
++const { createServer } = require('http');
++const { createPageRoutes } = require('../server/http/pages');
++
++const secret = 'sk-cookie-secret-never-rendered';
++const calls = [];
++const app = express();
++app.use((req, _res, next) => {
++ req.viewer = req.get('x-person') === 'alice'
++ ? { kind: 'user', subject: 'usr_alice', token: 'alice-session' }
++ : { kind: 'anonymous' };
++ next();
++});
++app.use(createPageRoutes({
++ config: { baseUrl: 'http://localhost' },
++ ai: {
++ credentialsFor(token) {
++ calls.push(token);
++ return {
++ async list() { return { credentials: [
++ { id: 'cred_alice', kind: 'session_cookie', provider: 'anthropic', created: '2026-11-08', cookie: secret },
++ { id: 'key_alice', kind: 'api_key', provider: 'openai', hint: '1234', key: secret },
++ ] }; },
++ async add(body) { calls.push(body); return {}; },
++ async remove(id) { calls.push(id); return {}; },
++ async balance(id) { calls.push(id); return { balance: { available_usd: 12 } }; },
++ };
++ },
++ },
++ network: {}, history: { async capture() {} }, openapi: async () => ({ doc: {} }),
++ chats: {}, adult: {}, sharing: {}, log: console,
++}));
++
++(async () => {
++ const server = createServer(app);
++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ try {
++ const guest = await fetch(`${base}/privacy/keys`, { redirect: 'manual' });
++ assert.strictEqual(guest.status, 303, 'signed-out people are sent to Exchange sign-in');
++ const page = await fetch(`${base}/privacy/keys`, { headers: { 'x-person': 'alice' } });
++ const html = await page.text();
++ assert.strictEqual(page.status, 200);
++ assert.ok(html.includes('Your keys and accounts') && html.includes('Browser session cookie') && html.includes('openai'));
++ assert.ok(!html.includes(secret), 'only metadata renders; saved key and cookie values stay private');
++
++ const add = await fetch(`${base}/privacy/credentials`, {
++ method: 'POST', headers: { 'x-person': 'alice', Origin: 'http://localhost', 'content-type': 'application/x-www-form-urlencoded' },
++ body: new URLSearchParams({ kind: 'session_cookie', provider: 'anthropic', cookie: secret, org_id: 'org-123' }), redirect: 'manual',
++ });
++ assert.strictEqual(add.status, 303);
++ assert.ok(!(await add.text()).includes(secret), 'save never echoes the cookie');
++ assert.ok(calls.some((value) => value && value.kind === 'session_cookie' && value.cookie === secret));
++
++ const remove = await fetch(`${base}/privacy/credentials/cred_alice/remove`, {
++ method: 'POST', headers: { 'x-person': 'alice', Origin: 'http://localhost', 'content-type': 'application/x-www-form-urlencoded' }, body: '', redirect: 'manual',
++ });
++ assert.strictEqual(remove.status, 303);
++ assert.ok(calls.includes('cred_alice'));
++ const balance = await fetch(`${base}/privacy/credentials/cred_alice/balance`, {
++ method: 'POST', headers: { 'x-person': 'alice', Origin: 'http://localhost', 'content-type': 'application/x-www-form-urlencoded' }, body: '',
++ });
++ assert.strictEqual(balance.status, 200);
++ assert.ok((await balance.text()).includes('12'));
++ assert.ok(calls.filter((value) => value === 'alice-session').length >= 4, 'each operation uses the signed-in person token');
++ } finally { await new Promise((resolve) => server.close(resolve)); }
++ process.stdout.write('credentials-settings: owner metadata, cookie CRUD and balance passed\n');
++})().catch((err) => { process.stderr.write(`${err.stack || err}\n`); process.exitCode = 1; });
ddiff --git a/test/start-why-faq.test.js b/test/start-why-faq.test.js
iindex b9be56e..1f137f5 100644
---- a/test/start-why-faq.test.js
++++ b/test/start-why-faq.test.js
@@@ -21,7 +21,7 @@ const { createFakeAi } = require('./helpers/ai');
assert.ok(r.text.includes('chat, generate, summarize, classify, extract and embed'), 'AI’s six operations stay described');
assert.ok(r.text.includes('Sandbox') && r.text.includes('Production') && r.text.includes('Never used'), 'the sandbox and production limits');
assert.ok(r.text.includes('openvibe.network/developers') && r.text.includes('/own') && r.text.includes('/faq'), 'keys, own endpoints and the questions');
-- assert.ok(r.text.includes('https://ai.openvibe.services/you'), 'own provider keys stay on AI, with the person session');
++ assert.ok(r.text.includes('href="/privacy/keys"'), 'own provider keys stay on Exchange');
});
await check('/why signed out: the rule, the live offers and a sign-in gate, never a fake answer', async () => {