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.
Changes
nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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 };@@@ -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 { /* */ }@@@ -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)',nnew file mode 100644@@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -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 };
@@ -160,6 +160,12 @@ router.put('/config', requireAuth, async (req, res) => {
@@ -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);
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 });
if (refused) return res.status(refused.status).json({ error: refused.error, code: refused.code });
db.upsertChannelAiConfig(req.user.id, fields);
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).
// v3 settings blob (partial merge, validated + clamped in settings.js).
if (body.settings && typeof body.settings === 'object') settingsMod.updateSettings(req.user.id, body.settings);
if (body.settings && typeof body.settings === 'object') settingsMod.updateSettings(req.user.id, body.settings);
try { engine.applyConfigForUser(req.user.id); } catch { /* */ }
try { engine.applyConfigForUser(req.user.id); } catch { /* */ }
@@ -354,6 +354,7 @@ async function check(name, fn) {
@@ -354,6 +354,7 @@ async function check(name, fn) {
'server/utils/notify.js': 'OpenVibe.Network (configured)',
'server/utils/notify.js': 'OpenVibe.Network (configured)',
'server/auth/identity-sync.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/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/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/billing-client.js': 'OpenVibe.Billing (configured)',
'server/monetization/wallet-client.js': 'OpenVibe.Network wallet (configured)',
'server/monetization/wallet-client.js': 'OpenVibe.Network wallet (configured)',
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -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); });