openvibers/openvibe.exchange Commits
Add personal AI credential settings to Exchange (#131)
Changes
@@@ -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"@@@ -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"},@@@ -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; }@@@ -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) };}/**@@@ -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' },],}));@@@ -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 () => {@@@ -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 },nnew file mode 100644@@@ -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 };@@@ -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>`;}nnew file mode 100644@@@ -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; });@@@ -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 () => {
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"multer": "^2.5.0",
"multer": "^2.5.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1221,9 +1221,9 @@
@@ -1221,9 +1221,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"multer": "^2.5.0",
"multer": "^2.5.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -672,3 +672,20 @@ body.ex-companion-shell > main.ex { flex: 1; min-height: 0; display: flex; }
@@ -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 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-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-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; }
@@ -14,6 +14,7 @@
@@ -14,6 +14,7 @@
* numbers are not available instead of showing stale ones as live.
* numbers are not available instead of showing stale ones as live.
*/
*/
const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
+const { createCredentialsClient } = require('openvibe-sdk/credentials');
const CATALOG_MS = 30_000;
const CATALOG_MS = 30_000;
const USAGE_MS = 60_000;
const USAGE_MS = 60_000;
@@ -294,8 +295,25 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
@@ -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 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 }); }
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) };
}
}
/**
/**
@@ -128,6 +128,7 @@ function createDiscoveryRoutes(ctx) {
@@ -128,6 +128,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Release metadata (JSON)', url: abs('/release.json') },
{ 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 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: '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' },
{ title: 'OpenVibe Link', url: 'https://openvibe.link', note: 'offer a machine to the network' },
],
],
}));
}));
@@ -27,6 +27,7 @@ const { esc, raw } = require('../render/html');
@@ -27,6 +27,7 @@ const { esc, raw } = require('../render/html');
const marks = require('../views/marks');
const marks = require('../views/marks');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const views = require('../views/exchange');
const views = require('../views/exchange');
+const credentialViews = require('../views/credentials');
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 60 });
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 60 });
const RETIRED_PATHS = ['/listings', '/listings/*', '/submit', '/sandbox', '/sandbox/*', '/installs', '/offers', '/safety', '/developers'];
const RETIRED_PATHS = ['/listings', '/listings/*', '/submit', '/sandbox', '/sandbox/*', '/installs', '/offers', '/safety', '/developers'];
@@ -41,6 +42,21 @@ function createPageRoutes(ctx) {
@@ -41,6 +42,21 @@ function createPageRoutes(ctx) {
const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
const owner = (req) => 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 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, {
const signInPage = (req, res, { title, text, next, status = 401 }) => page(req, res, {
title, index: false,
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>`),
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>` : ''
@@ -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 })) });
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) => {
r.get('/chat', async (req, res) => {
const list = await loadChats(req);
const list = await loadChats(req);
if (!list.length) return signInIfGuest(req, res, async () => {
if (!list.length) return signInIfGuest(req, res, async () => {
@@ -27,6 +27,7 @@ const NAV = [
@@ -27,6 +27,7 @@ const NAV = [
{ label: 'Rankings', href: '/rankings', icon: 'fa-chart-line' },
{ label: 'Rankings', href: '/rankings', icon: 'fa-chart-line' },
{ label: 'Start building', href: '/start', icon: 'fa-bolt' },
{ label: 'Start building', href: '/start', icon: 'fa-bolt' },
{ label: 'API', href: '/api', icon: 'fa-code' },
{ 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: 'Why this model', href: '/why', icon: 'fa-diagram-project', more: true },
{ label: 'Your own endpoint', href: '/own', icon: 'fa-server', more: true },
{ label: 'Your own endpoint', href: '/own', icon: 'fa-server', more: true },
{ label: 'Sell compute', href: '/sell', icon: 'fa-microchip', more: true },
{ label: 'Sell compute', href: '/sell', icon: 'fa-microchip', more: true },
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -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 };
@@ -475,8 +475,8 @@ ${panel({ title: 'One endpoint', body: `
@@ -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>
<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' })}
${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>
<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>
<h3 class="ex-h3" id="strict">Strict privacy</h3>
${switchBox}
${switchBox}
<h3 class="ex-h3">Model ids</h3>
<h3 class="ex-h3">Model ids</h3>
@@ -1061,7 +1061,7 @@ ${kit.table({ columns: [{ key: 'limit', label: 'Limit', primary: true }, { key:
@@ -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' },
{ limit: 'A person’s own provider key', sandbox: 'Never used', production: 'Never used' },
], label: 'Sandbox and production', stack: true })}` })}
], 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>
${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>`;
</div>`;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -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; });
@@ -21,7 +21,7 @@ const { createFakeAi } = require('./helpers/ai');
@@ -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('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('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('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 () => {
await check('/why signed out: the rule, the live offers and a sign-in gate, never a fake answer', async () => {