openvibers/openvibe.live Commits

A streamer's own provider key lives in OpenVibe.AI, not in Live's database (roadmap WS-O task 2; AI fca04c7, 0b9f7aa; Network d37ee87 grants live ai.credential.manage).

OP OpenVibers committed 28 Sept 2026 · 4d758fac91 · 9 files, +331 −14

server/ai/byo-credentials.js sends the key a streamer saves for their AI viewers once to AI (PUT /api/v1/credentials/:subject: provider, https address, models per role). Live keeps only channel_ai_config.byo_in_ai (a new column); byo_key is emptied.
- A model change keeps AI's stored key.
- A new provider address needs the key again (400 credential.key_required, shown to the streamer).
- byo_key: null removes the key.
The route logic is applyConfig, so it is tested without a session.

Runs: budget.byoProvider names the streamer's subject instead of carrying a key. llm.complete sends that as a run with credential { subject } through ai-service.complete, metered here as 'byo'. A streamer whose key is in AI but who has no Network subject stays quiet and never falls back to the shared key. /byo/test tests a stored key through AI.

scripts/move-byo-keys-to-ai.js moves every key still in Live the same way (idempotent; one line per channel, never a key).

test/byo-credentials.test.js runs against a stub Network and AI: the move, the credentialed run and its metering, no shared fallback, a model change, a refused new address, a new key, removal. The SSRF review lists the new call. npm test 144/144.

Changes

ddiff --git a/scripts/move-byo-keys-to-ai.js b/scripts/move-byo-keys-to-ai.js
nnew file mode 100644
iindex 00000000..bf5933fe
---- /dev/null
++++ b/scripts/move-byo-keys-to-ai.js
@@@ -0,0 +1,25 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Moves every streamer's own provider key still stored in Live's database to OpenVibe.AI (roadmap WS-O task 2,
++ * server/ai/byo-credentials.js). Each key is sent once (PUT /api/v1/credentials/:subject with Live's service token,
++ * grant live ai.credential.manage on openvibe.ai); on success Live's copy is erased and byo_in_ai set. Idempotent:
++ * a moved channel has no key left to move. Prints one line per channel, never a key.
++ *
++ * sudo -u <live service user> sh -c 'set -a; . /etc/openvibe/live.env; set +a; \
++ * cd /opt/openvibe.live/current && node scripts/move-byo-keys-to-ai.js'
++ */
++const db = require('../server/db/database');
++const byo = require('../server/ai/byo-credentials');
++
++(async () => {
++ const rows = db.getDb().prepare("SELECT user_id FROM channel_ai_config WHERE byo_key IS NOT NULL AND TRIM(byo_key) != '' ORDER BY user_id").all();
++ let moved = 0; let failed = 0;
++ for (const { user_id: userId } of rows) {
++ const out = await byo.moveLocal(userId);
++ if (out === 'moved') moved++; else if (out !== 'none') failed++;
++ console.log(`channel ${userId}: ${out}`);
++ }
++ console.log(`${rows.length} key(s) in Live: ${moved} moved to OpenVibe.AI, ${failed} not moved`);
++ process.exit(failed ? 1 : 0);
++})().catch((e) => { console.error(e.message); process.exit(2); });
ddiff --git a/server/ai/ai-service.js b/server/ai/ai-service.js
iindex 9b0ef43f..a2322ec9 100644
---- a/server/ai/ai-service.js
++++ b/server/ai/ai-service.js
@@@ -18,7 +18,9 @@
* null means "no AI answer" exactly like llm.complete() returning null today (AI off, over
* budget, provider down): quota refusals (429), failed runs, unreachable service, and SYNTHETIC
* answers (the AI service's stub provider) all come back as null — synthetic text never reaches
-- * a Live page. Streamer BYO providers are not routed here (llm.js keeps calling them directly).
++ * a Live page. A streamer's own key stored in OpenVibe.AI (byo-credentials.js) runs here too, as
++ * complete({ credentialSubject }): the run carries credential { subject }; an older key still in
++ * Live's database is called directly by llm.js until it is moved.
*/
'use strict';
const principal = require('../net/network-principal');
@@@ -73,11 +75,12 @@ async function _fetchJson(method, path, body, timeoutMs, retried = false) {
* Run a workflow and wait for it. Returns the run object (any terminal status) or null.
* opts: { target, attribution, idempotencyKey, waitMs (default 60s), timeoutMs }
*/
--async function run(workflow, input, { target, attribution, idempotencyKey, waitMs = 60000 } = {}) {
++async function run(workflow, input, { target, attribution, idempotencyKey, waitMs = 60000, credentialSubject = null } = {}) {
if (!enabled()) return null;
const wait = Math.max(0, Math.min(60000, waitMs));
const created = await _fetchJson('POST', `/api/v1/runs?wait=${wait}`, {
workflow, input, target: target || undefined, attribution: attribution || undefined, idempotency_key: idempotencyKey || undefined,
++ credential: credentialSubject ? { subject: credentialSubject } : undefined,
}, wait + 15000);
let r = created && created.run;
if (!r) return null;
@@@ -160,7 +163,7 @@ async function complete(o = {}, { toVisionJpeg } = {}) {
};
if (!input.user && !input.messages) input.user = '';
const started = Date.now();
-- const r = await run(workflowFor(o.kind), input, { attribution: ownerRef(o.ownerUserId), waitMs: Math.max(30000, (o.timeoutMs || 30000) * 2) });
++ const r = await run(workflowFor(o.kind), input, { attribution: ownerRef(o.ownerUserId), waitMs: Math.max(30000, (o.timeoutMs || 30000) * 2), credentialSubject: o.credentialSubject || null });
const out = usable(r);
if (!out) return null;
const usage = { input: (r.usage && r.usage.tokens_in) || 0, output: (r.usage && r.usage.tokens_out) || 0, cached: 0 };
@@@ -169,8 +172,8 @@ async function complete(o = {}, { toVisionJpeg } = {}) {
json: out.json || null,
usage,
model: (r.provenance && r.provenance.model) || null,
-- provider: 'openvibe-ai',
-- shared: true,
++ provider: o.credentialSubject ? 'byo' : 'openvibe-ai',
++ shared: !o.credentialSubject,
latencyMs: Date.now() - started,
cost: (r.usage && r.usage.cost_usd) || 0,
runId: r.id,
ddiff --git a/server/ai/byo-credentials.js b/server/ai/byo-credentials.js
nnew file mode 100644
iindex 00000000..2850006f
---- /dev/null
++++ b/server/ai/byo-credentials.js
@@@ -0,0 +1,125 @@
++'use strict';
++/**
++ * A streamer's own provider key lives in OpenVibe.AI (roadmap WS-O task 2; Contracts 0.74.0 ai.credential.manage).
++ *
++ * When a streamer saves a key for their AI viewers, Live forwards it once to OpenVibe.AI
++ * (PUT /api/v1/credentials/:subject, with Live's service token for audience openvibe.ai) and keeps only
++ * channel_ai_config.byo_in_ai = 1; the key column is emptied. Their viewers then run as workflow runs on AI with
++ * credential { subject } (llm.complete → ai-service.complete), so the key is used only by AI, only for them, and
++ * only within the daily budget they set. A change of model keeps the stored key; a new provider address needs the
++ * key again (AI answers 400 credential.key_required).
++ *
++ * moveLocal(userId) moves a key still stored in Live the same way (scripts/move-byo-keys-to-ai.js runs it for every
++ * channel once). The key is never logged.
++ */
++const db = require('../db/database');
++
++function baseUrl() { return String(process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700').replace(/\/+$/, ''); }
++
++function subjectOf(userId) {
++ const r = db.getDb().prepare("SELECT subject_id FROM linked_accounts WHERE user_id = ? AND service = 'network' AND subject_id IS NOT NULL ORDER BY id LIMIT 1").get(userId);
++ return r ? r.subject_id : null;
++}
++
++/** openai | anthropic, from the streamer's settings (settings_json.byo.provider) or the address. */
++function providerOf(cfg) {
++ let extra = {};
++ try { extra = (JSON.parse(cfg.settings_json || '{}') || {}).byo || {}; } catch { extra = {}; }
++ if (extra.provider === 'anthropic' || /anthropic\.com/i.test(String(cfg.byo_base_url || extra.base_url || ''))) return 'anthropic';
++ return 'openai';
++}
++
++/** Models per role from the streamer's settings, the chosen model as the default. */
++function modelsOf(cfg) {
++ let extra = {};
++ try { extra = (JSON.parse(cfg.settings_json || '{}') || {}).byo || {}; } catch { extra = {}; }
++ const models = { chat: String(cfg.byo_model || extra.model || 'gpt-4o-mini').slice(0, 120) };
++ for (const role of ['vision', 'director', 'summary']) {
++ const m = extra[`model_${role}`] || (extra.models && extra.models[role]);
++ if (m) models[role] = String(m).slice(0, 120);
++ }
++ return models;
++}
++
++/** The https address to send with the key (the provider's own when none was set). */
++function addressOf(cfg, provider) {
++ const raw = String(cfg.byo_base_url || '').trim().replace(/\/+$/, '');
++ if (raw) return raw;
++ return provider === 'anthropic' ? 'https://api.anthropic.com/v1' : 'https://api.openai.com/v1';
++}
++
++async function call(method, subject, body) {
++ const principal = require('../net/network-principal');
++ for (let attempt = 0; attempt < 2; attempt++) {
++ let headers;
++ try { headers = await principal.serviceHeaders('openvibe.ai'); } catch (e) { return { ok: false, status: 503, body: { code: 'credentials.unavailable', detail: `no service token for openvibe.ai: ${e.message}` } }; }
++ let res;
++ try {
++ res = await fetch(`${baseUrl()}/api/v1/credentials/${encodeURIComponent(subject)}`, {
++ method, headers: { ...headers, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
++ body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(15000),
++ });
++ } catch (e) { return { ok: false, status: 503, body: { code: 'credentials.unavailable', detail: 'OpenVibe.AI did not answer' } }; }
++ if (res.status === 401 && attempt === 0) { principal.invalidate('openvibe.ai'); continue; }
++ let data = {};
++ try { data = res.status === 204 ? {} : await res.json(); } catch { data = {}; }
++ return { ok: res.ok, status: res.status, body: data };
++ }
++ return { ok: false, status: 503, body: { code: 'credentials.unavailable' } };
++}
++
++/**
++ * Store (or update) the streamer's credential in AI. apiKey may be omitted to keep the stored key (a model or
++ * budget change). → { ok, status, body } (body: ai.credential@1, or a problem with `code`).
++ */
++async function store(userId, cfg, { apiKey = null } = {}) {
++ const subject = subjectOf(userId);
++ if (!subject) return { ok: false, status: 409, body: { code: 'credentials.no_subject', detail: 'this channel has no Network account linked' } };
++ const provider = providerOf(cfg);
++ const put = { provider, base_url: addressOf(cfg, provider), models: modelsOf(cfg) };
++ if (apiKey) put.api_key = String(apiKey);
++ return call('PUT', subject, put);
++}
++
++async function remove(userId) {
++ const subject = subjectOf(userId);
++ if (!subject) return { ok: true, status: 204, body: {} };
++ return call('DELETE', subject);
++}
++
++/**
++ * A saved AI-viewer config, before Live writes its own columns. byoKey: a new key (string), null to remove the stored
++ * one, undefined to keep it. A new key, or a new address or model while the key is in AI, goes to AI; `fields` then
++ * gets byo_key '' and byo_in_ai. → null when Live may save, else { status, error, code } to answer with.
++ */
++async function applyConfig(userId, before, byoKey, fields) {
++ const newKey = typeof byoKey === 'string' && byoKey.trim() !== '' ? byoKey.trim() : null;
++ if (byoKey === null && (before.byo_in_ai || before.byo_key)) {
++ const r = await remove(userId);
++ if (!r.ok && r.status !== 404) return { status: 502, error: 'Could not remove your key from OpenVibe.AI; try again', code: 'credentials.unavailable' };
++ fields.byo_key = ''; fields.byo_in_ai = 0;
++ return null;
++ }
++ if (!newKey && !(before.byo_in_ai && (fields.byo_base_url !== undefined || fields.byo_model !== undefined))) return null;
++ const r = await store(userId, { ...before, ...fields }, { apiKey: newKey });
++ if (!r.ok) {
++ const code = (r.body && (r.body.code || r.body.error)) || null;
++ if (code === 'credential.key_required') return { status: 400, error: 'Enter your key again to change the provider address', code };
++ return { status: r.status >= 500 ? 502 : 400, error: (r.body && r.body.detail) || 'Could not store your key in OpenVibe.AI', code };
++ }
++ fields.byo_key = ''; fields.byo_in_ai = 1;
++ return null;
++}
++
++/** Move a key still stored in Live to AI; on success the local key is erased. → 'moved' | 'none' | '<code>' */
++async function moveLocal(userId) {
++ const cfg = db.getChannelAiConfig(userId);
++ const key = String(cfg.byo_key || '').trim();
++ if (!key) return 'none';
++ const r = await store(userId, cfg, { apiKey: key });
++ if (!r.ok) return (r.body && (r.body.code || r.body.error)) || `http_${r.status}`;
++ db.upsertChannelAiConfig(userId, { byo_key: '', byo_in_ai: 1 });
++ return 'moved';
++}
++
++module.exports = { store, remove, moveLocal, applyConfig, subjectOf, providerOf, modelsOf, addressOf };
ddiff --git a/server/ai/llm.js b/server/ai/llm.js
iindex 08669f69..8fc7e4fc 100644
---- a/server/ai/llm.js
++++ b/server/ai/llm.js
@@@ -311,6 +311,21 @@ async function complete(o = {}) {
// AI_SERVICE=remote: shared-key calls become workflow runs on OpenVibe.AI (a streamer's BYO
// provider override still goes straight to that provider, below). Usage is still recorded here
// so per-streamer budgets and the admin cost views keep working unchanged.
++ // A streamer's own key stored in OpenVibe.AI (WS-O task 2, byo-credentials.js): a run with credential { subject };
++ // AI calls their provider with it, within their daily budget. Metered here as 'byo', like the direct path below.
++ if (o.provider && o.provider.credentialSubject) {
++ if (!aiService.enabled()) return null;
++ const r = await aiService.complete({ ...o, role, credentialSubject: o.provider.credentialSubject }, { toVisionJpeg });
++ if (!r) return null;
++ if (o.json && !r.json) r.json = parseJsonLoose(r.text);
++ try {
++ db.recordAiUsage({
++ kind: o.kind || role, model: r.model, input_tokens: r.usage.input, output_tokens: r.usage.output, cached_tokens: r.usage.cached || 0,
++ cost_usd: r.cost || 0, owner_user_id: o.ownerUserId || null, source: o.source || null, role, provider: 'byo', latency_ms: r.latencyMs,
++ });
++ } catch { /* metering is best-effort */ }
++ return r;
++ }
if (aiService.enabled() && !(o.provider && (o.provider.apiKey || o.provider.baseUrl))) {
if (!isEnabled() || !withinBudget()) return null;
const r = await aiService.complete({ ...o, role }, { toVisionJpeg });
ddiff --git a/server/ai/viewers/budget.js b/server/ai/viewers/budget.js
iindex 5bc92086..990c04e1 100644
---- a/server/ai/viewers/budget.js
++++ b/server/ai/viewers/budget.js
@@@ -22,6 +22,7 @@ const SOURCE = 'ai_viewers';
function approxTokens(str) { return Math.ceil((str || '').length / 4); }
function byoUsable(cfg) {
++ if (cfg.byo_in_ai) return true;
if (cfg.byo_key && String(cfg.byo_key).trim()) return true;
try { return aiProvider.isSelfHostedBaseUrl(cfg.byo_base_url); } catch { return false; }
}
@@@ -66,16 +67,26 @@ async function generate(userId, { system = '', user = '', image = null, maxToken
// BYO key — same llm.js path as the shared key (real system role, caching, timeouts),
// metered from the provider's usage report (estimated only when the server omits it).
++ // Never fall through to the shared key: no usable own key means the streamer's bots stay quiet.
++ const provider = byoProvider(st.cfg);
++ if (!provider) return null;
const r = await ai.llm.complete({
role: 'chat', system, user, image, imageMaxWidth: 768, maxTokens, temperature,
kind: 'ai_viewers', source: SOURCE, ownerUserId: userId,
-- provider: byoProvider(st.cfg),
++ provider,
});
return r && r.text ? r.text.trim() || null : null;
}
--/** llm.js provider override for a streamer's BYO settings (column fields + settings_json.byo). */
++/**
++ * llm.js provider override for a streamer's BYO settings (column fields + settings_json.byo). A key stored in
++ * OpenVibe.AI (byo_in_ai, WS-O task 2) is named by the streamer's subject; the key itself never comes back here.
++ */
function byoProvider(cfg) {
++ if (cfg.byo_in_ai) {
++ const subject = require('../byo-credentials').subjectOf(cfg.user_id);
++ return subject ? { credentialSubject: subject } : null;
++ }
let extra = {};
try { extra = (JSON.parse(cfg.settings_json || '{}') || {}).byo || {}; } catch { extra = {}; }
const models = {};
ddiff --git a/server/ai/viewers/routes.js b/server/ai/viewers/routes.js
iindex 40b8fb71..492489fb 100644
---- a/server/ai/viewers/routes.js
++++ b/server/ai/viewers/routes.js
@@@ -39,8 +39,9 @@ function sanitizeConfig(cfg) {
daily_budget_cents: cfg.daily_budget_cents ?? 20,
byo_base_url: cfg.byo_base_url || '',
byo_model: cfg.byo_model || 'gpt-4o-mini',
-- has_byo_key: !!key,
-- byo_key_masked: key ? KEY_SENTINEL : '',
++ has_byo_key: !!key || !!cfg.byo_in_ai,
++ byo_key_masked: key || cfg.byo_in_ai ? KEY_SENTINEL : '',
++ byo_key_in_ai: !!cfg.byo_in_ai,
};
}
@@@ -125,13 +126,20 @@ router.post('/byo/test', requireAuth, async (req, res) => {
try {
const cfg = db.getChannelAiConfig(req.user.id);
const body = req.body || {};
++ // A stored key lives in OpenVibe.AI: test it there (a one-word run with credential { subject }).
++ const typedKey = body.byo_key && body.byo_key !== KEY_SENTINEL;
++ if (cfg.byo_in_ai && !typedKey) {
++ const started = Date.now();
++ const r = await ai.llm.complete({ role: 'chat', user: 'Reply with the single word: ok', maxTokens: 5, temperature: 0, timeoutMs: 15000, retries: 0, kind: 'status_check', ownerUserId: req.user.id, provider: budget.byoProvider(cfg) });
++ return res.json(r && r.text ? { ok: true, model: r.model, latency_ms: Date.now() - started, via: 'openvibe-ai' } : { ok: false, error: 'your provider did not answer through OpenVibe.AI' });
++ }
const override = budget.byoProvider({ ...cfg, byo_key: (body.byo_key && body.byo_key !== KEY_SENTINEL) ? body.byo_key : cfg.byo_key, byo_base_url: body.byo_base_url ?? cfg.byo_base_url, byo_model: body.byo_model ?? cfg.byo_model });
const r = await ai.llm.testProvider(override);
res.json(r);
} catch (e) { res.status(500).json({ ok: false, error: e.message }); }
});
--router.put('/config', requireAuth, (req, res) => {
++router.put('/config', requireAuth, async (req, res) => {
try {
const body = req.body || {};
const fields = {};
@@@ -143,10 +151,11 @@ router.put('/config', requireAuth, (req, res) => {
if (body.daily_budget_cents !== undefined) fields.daily_budget_cents = body.daily_budget_cents;
if (body.byo_base_url !== undefined) fields.byo_base_url = body.byo_base_url;
if (body.byo_model !== undefined) fields.byo_model = body.byo_model;
-- // Preserve the stored key unless a real new one is supplied (not blank / not the mask).
-- if (body.byo_key !== undefined && body.byo_key !== '' && body.byo_key !== KEY_SENTINEL) {
-- fields.byo_key = body.byo_key;
-- }
++ // The key goes to OpenVibe.AI, never into Live's database (WS-O task 2, byo-credentials.js). A real new key
++ // (not blank, not the mask) is sent with the address and models; a change of model alone keeps AI's stored key;
++ // byo_key: null removes it.
++ const refused = await require('../byo-credentials').applyConfig(req.user.id, db.getChannelAiConfig(req.user.id), body.byo_key === KEY_SENTINEL ? undefined : body.byo_key, fields);
++ if (refused) return res.status(refused.status).json({ error: refused.error, code: refused.code });
db.upsertChannelAiConfig(req.user.id, fields);
// v3 settings blob (partial merge, validated + clamped in settings.js).
if (body.settings && typeof body.settings === 'object') settingsMod.updateSettings(req.user.id, body.settings);
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 07fcca53..204477ba 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -1874,6 +1874,8 @@ function initDb() {
try {
const cols = database.prepare('PRAGMA table_info(channel_ai_config)').all().map(c => c.name);
if (!cols.includes('settings_json')) database.exec("ALTER TABLE channel_ai_config ADD COLUMN settings_json TEXT DEFAULT '{}'");
++ // The streamer's own key lives in OpenVibe.AI (WS-O task 2, server/ai/byo-credentials.js); Live keeps only this flag.
++ if (!cols.includes('byo_in_ai')) database.exec('ALTER TABLE channel_ai_config ADD COLUMN byo_in_ai INTEGER DEFAULT 0');
database.exec(`CREATE TABLE IF NOT EXISTS ai_viewer_threads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_user_id INTEGER NOT NULL,
@@@ -5974,6 +5976,7 @@ function upsertChannelAiConfig(userId, fields) {
byo_key: (v) => String(v || '').trim().slice(0, 400),
byo_base_url: (v) => String(v || '').trim().slice(0, 500),
byo_model: (v) => String(v || '').trim().slice(0, 120) || 'gpt-4o-mini',
++ byo_in_ai: (v) => (v ? 1 : 0),
// Validated/clamped by ai/viewers/settings.js before it gets here; just bound the size.
settings_json: (v) => { const t = typeof v === 'string' ? v : JSON.stringify(v || {}); return t.length > 40000 ? '{}' : t; },
};
ddiff --git a/test/byo-credentials.test.js b/test/byo-credentials.test.js
nnew file mode 100644
iindex 00000000..361f3711
---- /dev/null
++++ b/test/byo-credentials.test.js
@@@ -0,0 +1,125 @@
++'use strict';
++// A streamer's own provider key lives in OpenVibe.AI (roadmap WS-O task 2), against a stub Network (token
++// endpoint) and a stub AI service:
++// - moveLocal sends a key still in Live's database to AI once (provider, address, models) and erases Live's copy;
++// - the streamer's AI viewers then run on AI with credential { subject }, metered here as 'byo', and never fall
++// back to the shared key (no subject → quiet);
++// - a model change keeps AI's stored key, a new address without the key is refused, byo_key null removes it.
++// node test/byo-credentials.test.js
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const crypto = require('crypto');
++const { serviceAuth } = require('openvibe-contracts');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-byo-'));
++process.env.DB_PATH = path.join(tmp, 'live.db');
++process.env.NODE_ENV = 'test';
++process.env.OV_OAUTH_CLIENT_ID = 'live';
++process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
++process.env.AI_SERVICE = 'remote';
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++console.warn = () => {};
++
++const LOCAL_KEY = 'sk-local-EXAMPLE-0000';
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++const calls = [];
++const stored = new Map();
++const network = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ const f = new URLSearchParams(raw);
++ const now = Math.floor(Date.now() / 1000);
++ res.setHeader('Content-Type', 'application/json');
++ res.end(JSON.stringify({ access_token: serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:live', actor_type: 'service', aud: [f.get('audience')], cap: ['ai.run.create', 'ai.credential.manage'], iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey), token_type: 'Bearer', expires_in: 300 }));
++ });
++});
++const ai = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ const body = raw ? JSON.parse(raw) : null;
++ calls.push({ method: req.method, url: req.url, body });
++ res.setHeader('Content-Type', 'application/json');
++ const m = /^\/api\/v1\/credentials\/(usr_[0-9A-Z]+)$/.exec(req.url);
++ if (m && req.method === 'PUT') {
++ const prev = stored.get(m[1]);
++ if (!body.api_key && (!prev || prev.base_url !== body.base_url)) { res.statusCode = 400; return res.end(JSON.stringify({ code: 'credential.key_required', detail: 'enter the key again' })); }
++ stored.set(m[1], { ...body, api_key: body.api_key || prev.api_key });
++ return res.end(JSON.stringify({ subject: m[1], owner: 'live', provider: body.provider, key_hint: '…0000' }));
++ }
++ if (m && req.method === 'DELETE') { stored.delete(m[1]); res.statusCode = 204; return res.end(); }
++ if (req.url.startsWith('/api/v1/runs')) {
++ res.statusCode = 201;
++ return res.end(JSON.stringify({ run: { id: 'run_01JAB2C3D4E5F6G7H8J9K0MNPA', status: 'succeeded', synthetic: false, workflow: { key: 'live.viewers.line' }, output: { text: 'hi from the streamer key', json: null }, usage: { tokens_in: 10, tokens_out: 5, cost_usd: 0.0001 }, provenance: { model: 'gpt-4o-mini' } } }));
++ }
++ res.statusCode = 404; res.end('{}');
++ });
++});
++
++(async () => {
++ await new Promise((r) => network.listen(0, '127.0.0.1', r));
++ await new Promise((r) => ai.listen(0, '127.0.0.1', r));
++ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${network.address().port}`;
++ process.env.OV_AI_INTERNAL_URL = `http://127.0.0.1:${ai.address().port}`;
++ const db = require('../server/db/database');
++ db.initDb();
++ const byo = require('../server/ai/byo-credentials');
++ const budget = require('../server/ai/viewers/budget');
++ const d = db.getDb();
++ const SUBJECT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1';
++ d.prepare("INSERT INTO users (id, username, password_hash) VALUES (30, 'dana', '$sso$'), (31, 'nolink', '$sso$')").run();
++ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, subject_id) VALUES (30, 'network', '300', 'dana', ?)").run(SUBJECT);
++ db.setSetting('ai_enabled', 'true');
++ db.upsertChannelAiConfig(30, { enabled: 1, use_shared_key: 0, byo_key: LOCAL_KEY, byo_base_url: 'https://openrouter.ai/api/v1', byo_model: 'gpt-4o-mini' });
++ db.upsertChannelAiConfig(31, { enabled: 1, use_shared_key: 0, byo_key: LOCAL_KEY });
++ try {
++ // ── The move ──
++ assert.strictEqual(await byo.moveLocal(30), 'moved');
++ const put = calls.find((c) => c.method === 'PUT');
++ assert.deepStrictEqual([put.url, put.body.provider, put.body.base_url, put.body.api_key, put.body.models], [`/api/v1/credentials/${SUBJECT}`, 'openai', 'https://openrouter.ai/api/v1', LOCAL_KEY, { chat: 'gpt-4o-mini' }]);
++ const cfg = db.getChannelAiConfig(30);
++ assert.deepStrictEqual([cfg.byo_key, cfg.byo_in_ai], ['', 1], "Live's copy is erased");
++ assert.strictEqual(await byo.moveLocal(30), 'none', 'moving again changes nothing');
++ assert.strictEqual(await byo.moveLocal(31), 'credentials.no_subject', 'no Network account: the key stays until one is linked');
++
++ // ── The viewers run on AI with the credential ──
++ const n = calls.length;
++ const text = await budget.generate(30, { system: 'be a viewer', user: 'say hi', maxTokens: 20 });
++ assert.strictEqual(text, 'hi from the streamer key');
++ const runCall = calls.slice(n).find((c) => c.url.startsWith('/api/v1/runs'));
++ assert.deepStrictEqual([runCall.body.workflow, runCall.body.credential], ['live.viewers.line', { subject: SUBJECT }]);
++ assert.ok(!JSON.stringify(runCall.body).includes(LOCAL_KEY), 'the key is not in the run');
++ const usage = d.prepare("SELECT provider, owner_user_id FROM ai_usage WHERE source = 'ai_viewers' ORDER BY id DESC LIMIT 1").get();
++ assert.deepStrictEqual([usage.provider, usage.owner_user_id], ['byo', 30], 'metered as the streamer\'s own');
++ // A key in AI without a subject never falls back to the shared key.
++ db.upsertChannelAiConfig(31, { byo_key: '', byo_in_ai: 1 });
++ const m = calls.length;
++ assert.strictEqual(await budget.generate(31, { user: 'x' }), null);
++ assert.strictEqual(calls.length, m, 'nothing was called');
++
++ // ── Saving the config ──
++ let fields = { byo_model: 'gpt-4o' };
++ assert.strictEqual(await byo.applyConfig(30, db.getChannelAiConfig(30), undefined, fields), null);
++ const modelPut = calls[calls.length - 1];
++ assert.deepStrictEqual([modelPut.method, modelPut.body.api_key, modelPut.body.models.chat], ['PUT', undefined, 'gpt-4o'], 'a model change keeps the stored key');
++ fields = { byo_base_url: 'https://attacker.example/v1' };
++ const refused = await byo.applyConfig(30, db.getChannelAiConfig(30), undefined, fields);
++ assert.deepStrictEqual([refused.status, refused.code], [400, 'credential.key_required'], 'a new address needs the key again');
++ fields = {};
++ assert.strictEqual(await byo.applyConfig(30, db.getChannelAiConfig(30), 'sk-new-EXAMPLE-1111', fields), null);
++ assert.deepStrictEqual([fields.byo_key, fields.byo_in_ai], ['', 1], 'a new key is never saved in Live');
++ fields = {};
++ assert.strictEqual(await byo.applyConfig(30, db.getChannelAiConfig(30), null, fields), null);
++ assert.deepStrictEqual([calls[calls.length - 1].method, fields.byo_in_ai], ['DELETE', 0]);
++ assert.ok(!stored.has(SUBJECT));
++ } finally {
++ network.close(); ai.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++ console.log('byo credentials: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 3fa9ccb7..2675dd2f 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -354,6 +354,7 @@ async function check(name, fn) {
'server/utils/notify.js': 'OpenVibe.Network (configured)',
'server/auth/identity-sync.js': 'OpenVibe.Network (configured)',
'server/auth/account-data.js': 'OpenVibe.Network (configured): export parts and deletion confirmations',
++ 'server/ai/byo-credentials.js': 'OpenVibe.AI (configured OV_AI_INTERNAL_URL): a streamer\'s own key stored there',
'server/monetization/billing-client.js': 'OpenVibe.Billing (configured)',
'server/monetization/wallet-client.js': 'OpenVibe.Network wallet (configured)',
'server/monetization/payments.js': 'PayPal (fixed host)',