openvibers/openvibe.live Commits

A streamer's AI-viewer daily budget is a cap in OpenVibe.AI (roadmap WS-O task 2; AI 960f14a ai.quota.attribution.manage, Network 728266b). server/ai/viewer-quota.js: on the site's AI with a budget, PUT /api/v1/attribution-quotas/live:user:<id> { window: day, max_cost_usd, workflow_prefix: live.viewers. }, so AI refuses a viewer run over the cap before any provider call; their own key or no budget removes the cap. Every config save that touches use_shared_key or daily_budget_cents syncs it (a failure is logged; Live's pre-check still applies); scripts/sync-viewer-quotas.js syncs every channel once. test/viewer-quota.test.js against a stub AI; the SSRF review lists the call. npm test 146/146.

OP OpenVibers committed 28 Sept 2026 · 1954db2834 · 5 files, +160 −0


Changes

ddiff --git a/scripts/sync-viewer-quotas.js b/scripts/sync-viewer-quotas.js
nnew file mode 100644
iindex 00000000..60ac5894
---- /dev/null
++++ b/scripts/sync-viewer-quotas.js
@@@ -0,0 +1,25 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Sets every streamer's AI-viewer daily budget as a cap in OpenVibe.AI (roadmap WS-O task 2,
++ * server/ai/viewer-quota.js): channels on the site's AI with a budget get PUT
++ * /api/v1/attribution-quotas/live:user:<id>, the others have any cap removed. Idempotent; prints one line per
++ * channel.
++ *
++ * sudo sh -c 'set -a; . /etc/openvibe/live.env; set +a; cd /opt/openvibe.live/current && \
++ * setpriv --reuid=<live user> --regid=<live user> --init-groups node scripts/sync-viewer-quotas.js'
++ */
++const db = require('../server/db/database');
++const quota = require('../server/ai/viewer-quota');
++
++(async () => {
++ const rows = db.getDb().prepare('SELECT user_id FROM channel_ai_config ORDER BY user_id').all();
++ let failed = 0;
++ for (const { user_id: userId } of rows) {
++ const out = await quota.sync(userId);
++ if (!['set', 'removed', 'none'].includes(out)) failed++;
++ console.log(`channel ${userId}: ${out}`);
++ }
++ console.log(`${rows.length} channel(s), ${failed} not synced`);
++ process.exit(failed ? 1 : 0);
++})().catch((e) => { console.error(e.message); process.exit(2); });
ddiff --git a/server/ai/viewer-quota.js b/server/ai/viewer-quota.js
nnew file mode 100644
iindex 00000000..d2c1e55a
---- /dev/null
++++ b/server/ai/viewer-quota.js
@@@ -0,0 +1,52 @@
++'use strict';
++/**
++ * A streamer's daily AI-viewer budget is an AI quota (roadmap WS-O task 2; Contracts 0.75.0
++ * ai.quota.attribution.manage). When the streamer uses the shared key with a daily budget
++ * (channel_ai_config.use_shared_key and daily_budget_cents), Live sets the cap on OpenVibe.AI for their attribution
++ * live:user:<id>, workflows live.viewers.*: PUT /api/v1/attribution-quotas/live:user:<id> { window: 'day',
++ * max_cost_usd, workflow_prefix: 'live.viewers.' }. AI then refuses a viewer run over the cap before any provider is
++ * called, whatever Live's own pre-check says. No budget, or their own key (which has its own budget in AI), removes
++ * the cap. Live's budget.js keeps reading today's spend for the degradation ladder.
++ *
++ * sync(userId) → 'set' | 'removed' | 'none' | '<code>'. scripts/sync-viewer-quotas.js runs it for every channel.
++ */
++const db = require('../db/database');
++
++const PREFIX = 'live.viewers.';
++
++function baseUrl() { return String(process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700').replace(/\/+$/, ''); }
++
++async function call(method, userId, body) {
++ const principal = require('../net/network-principal');
++ const url = `${baseUrl()}/api/v1/attribution-quotas/${encodeURIComponent(`live:user:${userId}`)}`;
++ for (let attempt = 0; attempt < 2; attempt++) {
++ let headers;
++ try { headers = await principal.serviceHeaders('openvibe.ai'); } catch { return { ok: false, status: 503, body: { code: 'quota.unavailable' } }; }
++ let res;
++ try {
++ res = await fetch(url, { method, headers: { ...headers, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(10000) });
++ } catch { return { ok: false, status: 503, body: { code: 'quota.unavailable' } }; }
++ 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: 'quota.unavailable' } };
++}
++
++/** Make AI's cap match the streamer's settings. */
++async function sync(userId) {
++ const id = parseInt(userId, 10);
++ if (!(id > 0)) return 'none';
++ const cfg = db.getChannelAiConfig(id);
++ const cents = Number(cfg.daily_budget_cents) || 0;
++ if (cfg.use_shared_key && cents > 0) {
++ const r = await call('PUT', id, { window: 'day', max_cost_usd: Math.round(cents) / 100, workflow_prefix: PREFIX });
++ return r.ok ? 'set' : ((r.body && (r.body.code || r.body.error)) || `http_${r.status}`);
++ }
++ const r = await call('DELETE', id);
++ if (r.status === 404) return 'none';
++ return r.ok ? 'removed' : ((r.body && (r.body.code || r.body.error)) || `http_${r.status}`);
++}
++
++module.exports = { sync, PREFIX };
ddiff --git a/server/ai/viewers/routes.js b/server/ai/viewers/routes.js
iindex f15248cc..35b5f105 100644
---- a/server/ai/viewers/routes.js
++++ b/server/ai/viewers/routes.js
@@@ -160,6 +160,12 @@ router.put('/config', requireAuth, async (req, res) => {
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);
++ // The daily budget on the site's AI is also a cap in OpenVibe.AI (viewer-quota.js): kept in step on every save
++ // that touches it; a failure leaves Live's own pre-check in charge until the next save or the sync script.
++ if (fields.use_shared_key !== undefined || fields.daily_budget_cents !== undefined) {
++ const q = await require('../viewer-quota').sync(req.user.id);
++ if (!['set', 'removed', 'none'].includes(q)) console.warn(`[AI viewers] budget cap not synced to OpenVibe.AI for ${req.user.id}: ${q}`);
++ }
// v3 settings blob (partial merge, validated + clamped in settings.js).
if (body.settings && typeof body.settings === 'object') settingsMod.updateSettings(req.user.id, body.settings);
try { engine.applyConfigForUser(req.user.id); } catch { /* */ }
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex c712b1a0..578bbccb 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/viewer-quota.js': 'OpenVibe.AI (configured OV_AI_INTERNAL_URL): a streamer\'s daily AI-viewer cap',
'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)',
ddiff --git a/test/viewer-quota.test.js b/test/viewer-quota.test.js
nnew file mode 100644
iindex 00000000..61ba1bc7
---- /dev/null
++++ b/test/viewer-quota.test.js
@@@ -0,0 +1,76 @@
++'use strict';
++// A streamer's AI-viewer daily budget is a cap in OpenVibe.AI (roadmap WS-O task 2, server/ai/viewer-quota.js),
++// against a stub Network (token endpoint) and a stub AI: the site's AI with a budget sets the cap for live:user:<id>
++// on live.viewers.* in dollars a day; their own key or no budget removes it; a missing cap is 'none'; AI down is
++// reported, never thrown.
++// node test/viewer-quota.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-vquota-'));
++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';
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++console.warn = () => {};
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++const calls = [];
++const caps = new Set();
++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.quota.attribution.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;
++ const attr = decodeURIComponent(req.url.split('/').pop());
++ calls.push({ method: req.method, attr, body });
++ res.setHeader('Content-Type', 'application/json');
++ if (req.method === 'PUT') { caps.add(attr); return res.end(JSON.stringify({ attribution: attr, window: body.window, max_cost_usd: body.max_cost_usd })); }
++ if (req.method === 'DELETE') { const had = caps.delete(attr); res.statusCode = had ? 204 : 404; return res.end(); }
++ 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 quota = require('../server/ai/viewer-quota');
++ db.getDb().prepare("INSERT INTO users (id, username, password_hash) VALUES (50, 'dana', '$sso$')").run();
++ try {
++ db.upsertChannelAiConfig(50, { use_shared_key: 1, daily_budget_cents: 35 });
++ assert.strictEqual(await quota.sync(50), 'set');
++ assert.deepStrictEqual(calls[0], { method: 'PUT', attr: 'live:user:50', body: { window: 'day', max_cost_usd: 0.35, workflow_prefix: 'live.viewers.' } });
++ db.upsertChannelAiConfig(50, { use_shared_key: 0 });
++ assert.strictEqual(await quota.sync(50), 'removed', 'their own key: the site cap goes');
++ assert.strictEqual(await quota.sync(50), 'none', 'nothing left to remove');
++ db.upsertChannelAiConfig(50, { use_shared_key: 1, daily_budget_cents: 0 });
++ assert.strictEqual(await quota.sync(50), 'none', 'no budget: no cap');
++ assert.strictEqual(await quota.sync('x'), 'none');
++ ai.close();
++ await new Promise((r) => setTimeout(r, 50));
++ db.upsertChannelAiConfig(50, { daily_budget_cents: 20 });
++ assert.strictEqual(await quota.sync(50), 'quota.unavailable', 'AI down is reported, never thrown');
++ } finally {
++ network.close(); ai.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++ console.log('viewer quota: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });