openvibers/openvibe.live Commits
Ways to pay come from Billing (a rail shows when its switch is on and Billing holds its keys); the 15 unused provider credential settings and the VPN queue routes go; CORS refusals and the registry line log once (#303)
Changes
nnew file mode 100644@@@ -0,0 +1,15 @@++-- phase: migrate++-- OpenVibe.Billing holds every payment provider's credentials since the cutover of 2026-10-17 (its environment file;++-- openvibe.network/admin → Payments points there), and Live offers a way to pay when Billing reports that provider++-- enabled (server/monetization/payments.js, GET /api/v1/rates). Nothing in Live reads these settings any more++-- (test/billing-authority.test.js), so they leave site_settings and the admin's Payments form. Production on++-- 2026-10-22: every credential here was empty; crypto_provider and paypal_mode held their seed defaults. The++-- per-provider switches (paypal_enabled, stripe_enabled, ccbill_enabled, crypto_enabled) stay. The N-1 release reads++-- only stripe_secret_key, paypal_client_id, ccbill_flexform_id, crypto_api_key and stripe_publishable_key, as empty++-- strings when absent, which is what production holds today.++DELETE FROM site_settings WHERE key IN (++ 'ccbill_client_account', 'ccbill_flexform_id', 'ccbill_salt', 'ccbill_subaccount', 'ccbill_webhook_secret',++ 'crypto_api_key', 'crypto_ipn_secret', 'crypto_provider',++ 'paypal_client_id', 'paypal_client_secret', 'paypal_mode', 'paypal_webhook_id',++ 'stripe_publishable_key', 'stripe_secret_key', 'stripe_webhook_secret'++);@@@ -11,8 +11,6 @@* GET /api/admin/streams - All active streams* DELETE /api/admin/streams/:id - Force end a stream* GET /api/admin/bans - List all bans-- * GET /api/admin/vpn-queue - VPN approval queue-- * PUT /api/admin/vpn-queue/:id - Approve/deny VPN* GET /api/admin/money - Money authority (OpenVibe.Billing), freeze state, Billing health* POST /api/admin/money/freeze - Owner: freeze/unfreeze Live money writes { on, reason }* POST /api/admin/money/actions/:id/resolve - Owner: re-send a Billing action whose outcome is unknown@@@ -442,38 +440,9 @@ router.get('/bans', async (req, res) => {}});--// ── VPN Approval Queue ───────────────────────────────────────--router.get('/vpn-queue', async (req, res) => {-- try {-- const queue = await db.all(`-- SELECT v.*, u.username-- FROM vpn_approvals v-- LEFT JOIN users u ON v.user_id = u.id-- WHERE v.status = 'pending'-- ORDER BY v.created_at ASC-- `);-- res.json({ queue });-- } catch (err) {-- res.status(500).json({ error: 'Failed to get VPN queue' });-- }--});----// ── Approve/Deny VPN ─────────────────────────────────────────--router.put('/vpn-queue/:id', async (req, res) => {-- try {-- const { status } = req.body; // 'approved' or 'denied'-- if (!['approved', 'denied'].includes(status)) {-- return res.status(400).json({ error: 'Status must be approved or denied' });-- }---- await db.run('UPDATE vpn_approvals SET status = ?, reviewed_by = ? WHERE id = ?',-- [status, req.user.id, req.params.id]);---- res.json({ message: `VPN request ${status}` });-- } catch (err) {-- res.status(500).json({ error: 'Failed to update VPN request' });-- }--});++// The VPN approval queue left 2026-10-22: IP approval is OpenVibe.Chat's (pending_ip_messages), nothing has written++// vpn_approvals since the chat cutover (0 rows in production), and Network's admin dropped its tab (Network#299). The++// table goes with a later contract migration.// ═══════════════════════════════════════════════════════════════// Site Settings@@@ -66,6 +66,7 @@ function normalizeRtmpHost(host) {}let _lastUrlLines = ''; // the Effective URLs block last printed++let _registryLogged = false;function buildConfig(registryValues) {registryValues = registryValues || {};@@@ -324,7 +325,9 @@ async function refreshRegistry() {);const updated = buildConfig(registry);Object.assign(config, updated);-- console.log('[Config] URL registry loaded from openvibe.network resolved registry');++ // The refresh runs every few minutes; the Effective URLs block (buildConfig) says when anything changed.++ if (!_registryLogged) console.log('[Config] URL registry loaded from openvibe.network resolved registry');++ _registryLogged = true;} catch (err) {console.warn('[Config] Unable to load URL registry from openvibe.network:', err.message);}@@@ -429,11 +429,19 @@ app.use('/api/views', require('./media-proxy/view-beacon').createViewBeacon({ raconst rum = require('./web/rum').createRum();app.use('/api/rum', rum.router);++// A refused origin is logged once an hour each: "null" (sandboxed frames, file:// pages, privacy redirects) arrived++// ~400 times a day and said nothing new after the first.++const _corsLogged = new Map(); // origin -> last logged atconst allowlistedCors = cors({origin(origin, callback) {if (!origin) return callback(null, true);if (isAllowedOrigin(origin)) return callback(null, true);-- console.warn(`[CORS] Rejected origin: "${origin}" | allowed: ${[...allowedOrigins].join(', ')} (+ *.openvibe.tools)`);++ const now = Date.now();++ if (!(now - (_corsLogged.get(origin) || 0) < 3600_000)) {++ if (_corsLogged.size > 500) _corsLogged.clear();++ _corsLogged.set(origin, now);++ console.warn(`[CORS] Rejected origin: "${origin}" | allowed: ${[...allowedOrigins].join(', ')} (+ *.openvibe.tools); repeats are not logged for an hour`);++ }return callback(new Error('Origin not allowed by CORS'));},credentials: true,@@@ -36,6 +36,7 @@ router.post('/bucks/checkout', requireAuth, money.guardWrite, async (req, res) =if (bucks < minBucks) return res.status(400).json({ error: `Minimum purchase is ${minBucks.toLocaleString()} Vibes` });if (bucks > 1_000_000) return res.status(400).json({ error: 'Amount too large' });if (!['stripe', 'paypal', 'ccbill', 'crypto', 'powerchat'].includes(provider)) return res.status(400).json({ error: 'Unknown payment provider' });++ if (provider !== 'powerchat' && !(await pay.offered())[provider]) return res.status(403).json({ error: 'That way to pay is not available right now' });try {const r = await billingActions.checkout(req, { provider, bucks });return res.status(r.status).json(r.body);@@@ -60,6 +61,7 @@ router.post('/subscribe', requireAuth, money.guardWrite, async (req, res) => {// gates the card/PayPal/Vibes-purchase rails.const viaPowerchat = provider === 'powerchat' || provider === 'powerchat_site';if (!await pay.isEnabled() && !viaPowerchat && provider !== 'bucks') return res.status(403).json({ error: 'Payments are not enabled' });++ if (provider === 'stripe' && !(await pay.offered()).stripe) return res.status(403).json({ error: 'Card subscriptions are not available right now' });const streamer = await db.getUserByUsername(String(req.body.streamer || ''));if (!streamer) return res.status(404).json({ error: 'Streamer not found' });if (streamer.id === req.user.id) return res.status(400).json({ error: 'You cannot subscribe to yourself' });@@@ -2,12 +2,14 @@* OpenVibe.Live — payment settings for the client: which ways to pay are shown, and the prices.** OpenVibe.Billing runs every checkout, webhook, subscription and renewal since the cutover of 2026-10-17-- * (billing-actions.js); the provider adapters live there. The master switch `payments_enabled` (site_settings,-- * openvibe.network/admin → Payments) still decides whether Live offers card/PayPal/crypto at all; PowerChat has its-- * own enablement.++ * (billing-actions.js); the provider adapters and their credentials live there. Live offers a way to pay when the++ * master switch `payments_enabled` and the provider's own switch (`stripe_enabled`, …; site_settings,++ * openvibe.network/admin → Payments) are on AND Billing reports the provider enabled (`GET /api/v1/rates`, which is++ * true only when Billing holds that provider's keys). Live keeps no provider keys. PowerChat has its own enablement.*/const db = require('../db/database');const money = require('./money-authority');++const billing = require('./billing-client');async function s(key) { return (await db.getSetting(key) || '').toString().trim(); }async function b(key) { const v = await db.getSetting(key); return v === true || v === 'true' || v === 1 || v === '1'; }@@@ -15,6 +17,33 @@ async function n(key, dflt) { const v = parseFloat(await db.getSetting(key)); reasync function isEnabled() { return await b('payments_enabled'); }++// Billing's provider availability, read at most once a minute. Unreachable Billing offers no card rail (a checkout++// would fail there anyway); the last answer is kept for up to 10 minutes so a short Billing restart hides nothing.++const PROVIDERS_FRESH_MS = 60_000;++const PROVIDERS_MAX_STALE_MS = 10 * 60_000;++// Live's provider names → Billing's adapters.++const BILLING_NAME = { paypal: 'paypal', stripe: 'stripe', ccbill: 'ccbill', crypto: 'nowpayments' };++let _providers = { at: 0, value: null, pending: null };++async function billingProviders() {++ const age = Date.now() - _providers.at;++ if (_providers.value && age < PROVIDERS_FRESH_MS) return _providers.value;++ _providers.pending ||= billing.api.rates()++ .then((r) => { _providers = { at: Date.now(), value: (r && r.providers) || {}, pending: null }; })++ .catch(() => { _providers.pending = null; });++ await _providers.pending;++ if (_providers.value && Date.now() - _providers.at < PROVIDERS_MAX_STALE_MS) return _providers.value;++ return {};++}++++/** Card, PayPal and crypto rails Live offers: master switch, the provider's switch, and Billing holds its keys. */++async function offered() {++ const out = Object.fromEntries(Object.keys(BILLING_NAME).map((p) => [p, false]));++ if (!await isEnabled()) return out;++ const on = await billingProviders();++ for (const p of Object.keys(out)) out[p] = await b(`${p}_enabled`) && on[BILLING_NAME[p]] === true;++ return out;++}++/** Public provider availability + pricing for the client. */async function publicConfig() {const hb = require('./vibes');@@@ -32,17 +61,13 @@ async function publicConfig() {minPurchaseBucks: await n('bucks_min_purchase_bucks', 100),subPriceUsd: await n('sub_price_usd', 4.99),providers: {-- paypal: await isEnabled() && await b('paypal_enabled') && !!await s('paypal_client_id'),-- stripe: await isEnabled() && await b('stripe_enabled') && !!await s('stripe_secret_key'),-- ccbill: await isEnabled() && await b('ccbill_enabled') && !!await s('ccbill_flexform_id'),-- crypto: await isEnabled() && await b('crypto_enabled') && !!await s('crypto_api_key'),++ ...await offered(),// PowerChat tips as a money entry point. Deliberately INDEPENDENT of the// payments master switch — it has its own enablement (powerchat_enabled +// the site tips account), so Vibes can be bought via PowerChat even while// the card/PayPal rails are off. Lazy require: circular with this module.powerchat: powerchatAvailable,},-- stripePublishableKey: await s('stripe_publishable_key'),// Who holds the money (OpenVibe.Billing) and whether money actions are paused.authority: money.authority(),moneyFrozen: await money.isFrozen(),@@@ -50,7 +75,8 @@ async function publicConfig() {}module.exports = {-- isEnabled, publicConfig,++ isEnabled, publicConfig, offered,++ _resetProviders() { _providers = { at: 0, value: null, pending: null }; },// settings accessors (for routes)_get: s, _bool: b, _num: n,};@@@ -329,6 +329,11 @@ async function check(name, fn) {let r = await call('POST', '/api/payments/bucks/checkout', 2, { provider: 'stripe', bucks: 500 });assert.strictEqual(r.status, 403); assert.strictEqual(callsSince(n).length, 0);await db.setSetting('payments_enabled', 'true');++ // The provider's own switch is off: not offered, and Billing is not asked.++ r = await call('POST', '/api/payments/bucks/checkout', 2, { provider: 'stripe', bucks: 500 });++ assert.strictEqual(r.status, 403, r.text);++ assert.ok(!callsSince(n).some((c) => c.path === '/intents'), 'no intent for a rail that is off');++ await db.setSetting('stripe_enabled', 'true'); await db.setSetting('crypto_enabled', 'true');r = await call('POST', '/api/payments/bucks/checkout', 2, { provider: 'stripe', bucks: 500 });assert.strictEqual(r.status, 200, r.text);assert.match(r.json.url, /^https:\/\/checkout\.test\/pi_/);@@@ -337,6 +342,48 @@ async function check(name, fn) {await db.setSetting('payments_enabled', 'false');});++ await check('Live offers a card rail only when Billing reports the provider enabled (Billing holds the keys, Live none)', async () => {++ const pay = require('../server/monetization/payments');++ await db.setSetting('payments_enabled', 'true');++ pay._resetProviders();++ let cfg = (await call('GET', '/api/payments/config')).json;++ assert.deepStrictEqual(cfg.providers, { paypal: false, stripe: true, ccbill: false, crypto: true, powerchat: cfg.providers.powerchat });++ assert.ok(!('stripePublishableKey' in cfg), 'no provider key leaves Live');++ // Billing has no Stripe keys: the button goes, and a checkout or card subscription is refused before Billing.++ billing.state.providers = { powerchat: true, nowpayments: true };++ pay._resetProviders();++ cfg = (await call('GET', '/api/payments/config')).json;++ assert.strictEqual(cfg.providers.stripe, false);++ assert.strictEqual(cfg.providers.crypto, true);++ const n = billing.calls.length;++ let r = await call('POST', '/api/payments/bucks/checkout', 2, { provider: 'stripe', bucks: 500 });++ assert.strictEqual(r.status, 403, r.text);++ r = await call('POST', '/api/payments/subscribe', 2, { streamer: 'bob', provider: 'stripe' });++ assert.strictEqual(r.status, 403, r.text);++ assert.ok(!callsSince(n).some((c) => c.path === '/intents' || c.path === '/subscriptions'), 'Billing is not asked to charge');++ // Billing unreachable with nothing cached: no card rail is offered.++ billing.state.fail500 = true;++ pay._resetProviders();++ cfg = (await call('GET', '/api/payments/config')).json;++ assert.deepStrictEqual([cfg.providers.stripe, cfg.providers.crypto], [false, false]);++ billing.state.fail500 = false;++ delete billing.state.providers;++ pay._resetProviders();++ await db.setSetting('payments_enabled', 'false');++ });++++ await check('no provider credential is read by Live, and migration 0013 removes them from site_settings', async () => {++ const fs = require('fs'); const path = require('path');++ const sql = fs.readFileSync(path.join(__dirname, '..', 'migrations', '0013_provider_keys_are_billings.sql'), 'utf8');++ const keys = [...sql.matchAll(/'((?:paypal|stripe|ccbill|crypto)_[a-z_]+)'/g)].map((m) => m[1]);++ assert.strictEqual(keys.length, 15);++ const files = [];++ (function walk(d) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (p.endsWith('.js')) files.push(p); } })(path.join(__dirname, '..', 'server'));++ const readers = files.flatMap((f) => { const src = fs.readFileSync(f, 'utf8'); return keys.filter((k) => src.includes(`'${k}'`)).map((k) => `${path.basename(f)}: ${k}`); });++ assert.deepStrictEqual(readers, []);++ for (const k of keys) assert.strictEqual(await db.getSetting(k), null, `${k} is gone`);++ });++await check('a Vibes media request is a paid interaction keyed by its request; its refund reverses that exact transfer', async () => {const mediaQueue = require('../server/media/media-queue');const reqId = Number((await db.run("INSERT INTO media_requests (streamer_id, stream_id, user_id, username, input, canonical_url, provider, title, duration_seconds, cost, queue_position, currency) VALUES (3, ?, 2, 'ann', 'x', 'https://x.test/a', 'youtube', 'song', 60, 40, 1, 'vibes') RETURNING id", [streamB])).lastInsertRowid);@@@ -75,7 +75,7 @@ async function startBilling({ network }) {function route(method, path, q, b, res) {let m;if (method === 'GET' && path === '/rates') {-- return send(res, 200, { bits_per_usd: 100, min_purchase_bits: 100, subscription: { price_cents: 499, streamer_share_pct: 70, site_route_fee_pct: 10 }, cashout: { min_bits: 500, escrow_days: 14 }, providers: { powerchat: true, stripe: true } });++ return send(res, 200, { bits_per_usd: 100, min_purchase_bits: 100, subscription: { price_cents: 499, streamer_share_pct: 70, site_route_fee_pct: 10 }, cashout: { min_bits: 500, escrow_days: 14 }, providers: state.providers || { powerchat: true, stripe: true, nowpayments: true } });}if (method === 'POST' && path === '/intents') {const kind = b.kind;
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+-- phase: migrate
+-- OpenVibe.Billing holds every payment provider's credentials since the cutover of 2026-10-17 (its environment file;
+-- openvibe.network/admin → Payments points there), and Live offers a way to pay when Billing reports that provider
+-- enabled (server/monetization/payments.js, GET /api/v1/rates). Nothing in Live reads these settings any more
+-- (test/billing-authority.test.js), so they leave site_settings and the admin's Payments form. Production on
+-- 2026-10-22: every credential here was empty; crypto_provider and paypal_mode held their seed defaults. The
+-- per-provider switches (paypal_enabled, stripe_enabled, ccbill_enabled, crypto_enabled) stay. The N-1 release reads
+-- only stripe_secret_key, paypal_client_id, ccbill_flexform_id, crypto_api_key and stripe_publishable_key, as empty
+-- strings when absent, which is what production holds today.
+DELETE FROM site_settings WHERE key IN (
+ 'ccbill_client_account', 'ccbill_flexform_id', 'ccbill_salt', 'ccbill_subaccount', 'ccbill_webhook_secret',
+ 'crypto_api_key', 'crypto_ipn_secret', 'crypto_provider',
+ 'paypal_client_id', 'paypal_client_secret', 'paypal_mode', 'paypal_webhook_id',
+ 'stripe_publishable_key', 'stripe_secret_key', 'stripe_webhook_secret'
+);
@@ -11,8 +11,6 @@
@@ -11,8 +11,6 @@
* GET /api/admin/streams - All active streams
* GET /api/admin/streams - All active streams
* DELETE /api/admin/streams/:id - Force end a stream
* DELETE /api/admin/streams/:id - Force end a stream
* GET /api/admin/bans - List all bans
* GET /api/admin/bans - List all bans
- * GET /api/admin/vpn-queue - VPN approval queue
- * PUT /api/admin/vpn-queue/:id - Approve/deny VPN
* GET /api/admin/money - Money authority (OpenVibe.Billing), freeze state, Billing health
* GET /api/admin/money - Money authority (OpenVibe.Billing), freeze state, Billing health
* POST /api/admin/money/freeze - Owner: freeze/unfreeze Live money writes { on, reason }
* POST /api/admin/money/freeze - Owner: freeze/unfreeze Live money writes { on, reason }
* POST /api/admin/money/actions/:id/resolve - Owner: re-send a Billing action whose outcome is unknown
* POST /api/admin/money/actions/:id/resolve - Owner: re-send a Billing action whose outcome is unknown
@@ -442,38 +440,9 @@ router.get('/bans', async (req, res) => {
@@ -442,38 +440,9 @@ router.get('/bans', async (req, res) => {
}
}
});
});
-// ── VPN Approval Queue ───────────────────────────────────────
-router.get('/vpn-queue', async (req, res) => {
- try {
- const queue = await db.all(`
- SELECT v.*, u.username
- FROM vpn_approvals v
- LEFT JOIN users u ON v.user_id = u.id
- WHERE v.status = 'pending'
- ORDER BY v.created_at ASC
- `);
- res.json({ queue });
- } catch (err) {
- res.status(500).json({ error: 'Failed to get VPN queue' });
- }
-});
-
-// ── Approve/Deny VPN ─────────────────────────────────────────
-router.put('/vpn-queue/:id', async (req, res) => {
- try {
- const { status } = req.body; // 'approved' or 'denied'
- if (!['approved', 'denied'].includes(status)) {
- return res.status(400).json({ error: 'Status must be approved or denied' });
- }
-
- await db.run('UPDATE vpn_approvals SET status = ?, reviewed_by = ? WHERE id = ?',
- [status, req.user.id, req.params.id]);
-
- res.json({ message: `VPN request ${status}` });
- } catch (err) {
- res.status(500).json({ error: 'Failed to update VPN request' });
- }
-});
+// The VPN approval queue left 2026-10-22: IP approval is OpenVibe.Chat's (pending_ip_messages), nothing has written
+// vpn_approvals since the chat cutover (0 rows in production), and Network's admin dropped its tab (Network#299). The
+// table goes with a later contract migration.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// Site Settings
// Site Settings
@@ -66,6 +66,7 @@ function normalizeRtmpHost(host) {
@@ -66,6 +66,7 @@ function normalizeRtmpHost(host) {
}
}
let _lastUrlLines = ''; // the Effective URLs block last printed
let _lastUrlLines = ''; // the Effective URLs block last printed
+let _registryLogged = false;
function buildConfig(registryValues) {
function buildConfig(registryValues) {
registryValues = registryValues || {};
registryValues = registryValues || {};
@@ -324,7 +325,9 @@ async function refreshRegistry() {
@@ -324,7 +325,9 @@ async function refreshRegistry() {
);
);
const updated = buildConfig(registry);
const updated = buildConfig(registry);
Object.assign(config, updated);
Object.assign(config, updated);
- console.log('[Config] URL registry loaded from openvibe.network resolved registry');
+ // The refresh runs every few minutes; the Effective URLs block (buildConfig) says when anything changed.
+ if (!_registryLogged) console.log('[Config] URL registry loaded from openvibe.network resolved registry');
+ _registryLogged = true;
} catch (err) {
} catch (err) {
console.warn('[Config] Unable to load URL registry from openvibe.network:', err.message);
console.warn('[Config] Unable to load URL registry from openvibe.network:', err.message);
}
}
@@ -429,11 +429,19 @@ app.use('/api/views', require('./media-proxy/view-beacon').createViewBeacon({ ra
@@ -429,11 +429,19 @@ app.use('/api/views', require('./media-proxy/view-beacon').createViewBeacon({ ra
const rum = require('./web/rum').createRum();
const rum = require('./web/rum').createRum();
app.use('/api/rum', rum.router);
app.use('/api/rum', rum.router);
+// A refused origin is logged once an hour each: "null" (sandboxed frames, file:// pages, privacy redirects) arrived
+// ~400 times a day and said nothing new after the first.
+const _corsLogged = new Map(); // origin -> last logged at
const allowlistedCors = cors({
const allowlistedCors = cors({
origin(origin, callback) {
origin(origin, callback) {
if (!origin) return callback(null, true);
if (!origin) return callback(null, true);
if (isAllowedOrigin(origin)) return callback(null, true);
if (isAllowedOrigin(origin)) return callback(null, true);
- console.warn(`[CORS] Rejected origin: "${origin}" | allowed: ${[...allowedOrigins].join(', ')} (+ *.openvibe.tools)`);
+ const now = Date.now();
+ if (!(now - (_corsLogged.get(origin) || 0) < 3600_000)) {
+ if (_corsLogged.size > 500) _corsLogged.clear();
+ _corsLogged.set(origin, now);
+ console.warn(`[CORS] Rejected origin: "${origin}" | allowed: ${[...allowedOrigins].join(', ')} (+ *.openvibe.tools); repeats are not logged for an hour`);
+ }
return callback(new Error('Origin not allowed by CORS'));
return callback(new Error('Origin not allowed by CORS'));
},
},
credentials: true,
credentials: true,
@@ -36,6 +36,7 @@ router.post('/bucks/checkout', requireAuth, money.guardWrite, async (req, res) =
@@ -36,6 +36,7 @@ router.post('/bucks/checkout', requireAuth, money.guardWrite, async (req, res) =
if (bucks < minBucks) return res.status(400).json({ error: `Minimum purchase is ${minBucks.toLocaleString()} Vibes` });
if (bucks < minBucks) return res.status(400).json({ error: `Minimum purchase is ${minBucks.toLocaleString()} Vibes` });
if (bucks > 1_000_000) return res.status(400).json({ error: 'Amount too large' });
if (bucks > 1_000_000) return res.status(400).json({ error: 'Amount too large' });
if (!['stripe', 'paypal', 'ccbill', 'crypto', 'powerchat'].includes(provider)) return res.status(400).json({ error: 'Unknown payment provider' });
if (!['stripe', 'paypal', 'ccbill', 'crypto', 'powerchat'].includes(provider)) return res.status(400).json({ error: 'Unknown payment provider' });
+ if (provider !== 'powerchat' && !(await pay.offered())[provider]) return res.status(403).json({ error: 'That way to pay is not available right now' });
try {
try {
const r = await billingActions.checkout(req, { provider, bucks });
const r = await billingActions.checkout(req, { provider, bucks });
return res.status(r.status).json(r.body);
return res.status(r.status).json(r.body);
@@ -60,6 +61,7 @@ router.post('/subscribe', requireAuth, money.guardWrite, async (req, res) => {
@@ -60,6 +61,7 @@ router.post('/subscribe', requireAuth, money.guardWrite, async (req, res) => {
// gates the card/PayPal/Vibes-purchase rails.
// gates the card/PayPal/Vibes-purchase rails.
const viaPowerchat = provider === 'powerchat' || provider === 'powerchat_site';
const viaPowerchat = provider === 'powerchat' || provider === 'powerchat_site';
if (!await pay.isEnabled() && !viaPowerchat && provider !== 'bucks') return res.status(403).json({ error: 'Payments are not enabled' });
if (!await pay.isEnabled() && !viaPowerchat && provider !== 'bucks') return res.status(403).json({ error: 'Payments are not enabled' });
+ if (provider === 'stripe' && !(await pay.offered()).stripe) return res.status(403).json({ error: 'Card subscriptions are not available right now' });
const streamer = await db.getUserByUsername(String(req.body.streamer || ''));
const streamer = await db.getUserByUsername(String(req.body.streamer || ''));
if (!streamer) return res.status(404).json({ error: 'Streamer not found' });
if (!streamer) return res.status(404).json({ error: 'Streamer not found' });
if (streamer.id === req.user.id) return res.status(400).json({ error: 'You cannot subscribe to yourself' });
if (streamer.id === req.user.id) return res.status(400).json({ error: 'You cannot subscribe to yourself' });
@@ -2,12 +2,14 @@
@@ -2,12 +2,14 @@
* OpenVibe.Live — payment settings for the client: which ways to pay are shown, and the prices.
* OpenVibe.Live — payment settings for the client: which ways to pay are shown, and the prices.
*
*
* OpenVibe.Billing runs every checkout, webhook, subscription and renewal since the cutover of 2026-10-17
* OpenVibe.Billing runs every checkout, webhook, subscription and renewal since the cutover of 2026-10-17
- * (billing-actions.js); the provider adapters live there. The master switch `payments_enabled` (site_settings,
- * openvibe.network/admin → Payments) still decides whether Live offers card/PayPal/crypto at all; PowerChat has its
- * own enablement.
+ * (billing-actions.js); the provider adapters and their credentials live there. Live offers a way to pay when the
+ * master switch `payments_enabled` and the provider's own switch (`stripe_enabled`, …; site_settings,
+ * openvibe.network/admin → Payments) are on AND Billing reports the provider enabled (`GET /api/v1/rates`, which is
+ * true only when Billing holds that provider's keys). Live keeps no provider keys. PowerChat has its own enablement.
*/
*/
const db = require('../db/database');
const db = require('../db/database');
const money = require('./money-authority');
const money = require('./money-authority');
+const billing = require('./billing-client');
async function s(key) { return (await db.getSetting(key) || '').toString().trim(); }
async function s(key) { return (await db.getSetting(key) || '').toString().trim(); }
async function b(key) { const v = await db.getSetting(key); return v === true || v === 'true' || v === 1 || v === '1'; }
async function b(key) { const v = await db.getSetting(key); return v === true || v === 'true' || v === 1 || v === '1'; }
@@ -15,6 +17,33 @@ async function n(key, dflt) { const v = parseFloat(await db.getSetting(key)); re
@@ -15,6 +17,33 @@ async function n(key, dflt) { const v = parseFloat(await db.getSetting(key)); re
async function isEnabled() { return await b('payments_enabled'); }
async function isEnabled() { return await b('payments_enabled'); }
+// Billing's provider availability, read at most once a minute. Unreachable Billing offers no card rail (a checkout
+// would fail there anyway); the last answer is kept for up to 10 minutes so a short Billing restart hides nothing.
+const PROVIDERS_FRESH_MS = 60_000;
+const PROVIDERS_MAX_STALE_MS = 10 * 60_000;
+// Live's provider names → Billing's adapters.
+const BILLING_NAME = { paypal: 'paypal', stripe: 'stripe', ccbill: 'ccbill', crypto: 'nowpayments' };
+let _providers = { at: 0, value: null, pending: null };
+async function billingProviders() {
+ const age = Date.now() - _providers.at;
+ if (_providers.value && age < PROVIDERS_FRESH_MS) return _providers.value;
+ _providers.pending ||= billing.api.rates()
+ .then((r) => { _providers = { at: Date.now(), value: (r && r.providers) || {}, pending: null }; })
+ .catch(() => { _providers.pending = null; });
+ await _providers.pending;
+ if (_providers.value && Date.now() - _providers.at < PROVIDERS_MAX_STALE_MS) return _providers.value;
+ return {};
+}
+
+/** Card, PayPal and crypto rails Live offers: master switch, the provider's switch, and Billing holds its keys. */
+async function offered() {
+ const out = Object.fromEntries(Object.keys(BILLING_NAME).map((p) => [p, false]));
+ if (!await isEnabled()) return out;
+ const on = await billingProviders();
+ for (const p of Object.keys(out)) out[p] = await b(`${p}_enabled`) && on[BILLING_NAME[p]] === true;
+ return out;
+}
+
/** Public provider availability + pricing for the client. */
/** Public provider availability + pricing for the client. */
async function publicConfig() {
async function publicConfig() {
const hb = require('./vibes');
const hb = require('./vibes');
@@ -32,17 +61,13 @@ async function publicConfig() {
@@ -32,17 +61,13 @@ async function publicConfig() {
minPurchaseBucks: await n('bucks_min_purchase_bucks', 100),
minPurchaseBucks: await n('bucks_min_purchase_bucks', 100),
subPriceUsd: await n('sub_price_usd', 4.99),
subPriceUsd: await n('sub_price_usd', 4.99),
providers: {
providers: {
- paypal: await isEnabled() && await b('paypal_enabled') && !!await s('paypal_client_id'),
- stripe: await isEnabled() && await b('stripe_enabled') && !!await s('stripe_secret_key'),
- ccbill: await isEnabled() && await b('ccbill_enabled') && !!await s('ccbill_flexform_id'),
- crypto: await isEnabled() && await b('crypto_enabled') && !!await s('crypto_api_key'),
+ ...await offered(),
// PowerChat tips as a money entry point. Deliberately INDEPENDENT of the
// PowerChat tips as a money entry point. Deliberately INDEPENDENT of the
// payments master switch — it has its own enablement (powerchat_enabled +
// payments master switch — it has its own enablement (powerchat_enabled +
// the site tips account), so Vibes can be bought via PowerChat even while
// the site tips account), so Vibes can be bought via PowerChat even while
// the card/PayPal rails are off. Lazy require: circular with this module.
// the card/PayPal rails are off. Lazy require: circular with this module.
powerchat: powerchatAvailable,
powerchat: powerchatAvailable,
},
},
- stripePublishableKey: await s('stripe_publishable_key'),
// Who holds the money (OpenVibe.Billing) and whether money actions are paused.
// Who holds the money (OpenVibe.Billing) and whether money actions are paused.
authority: money.authority(),
authority: money.authority(),
moneyFrozen: await money.isFrozen(),
moneyFrozen: await money.isFrozen(),
@@ -50,7 +75,8 @@ async function publicConfig() {
@@ -50,7 +75,8 @@ async function publicConfig() {
}
}
module.exports = {
module.exports = {
- isEnabled, publicConfig,
+ isEnabled, publicConfig, offered,
+ _resetProviders() { _providers = { at: 0, value: null, pending: null }; },
// settings accessors (for routes)
// settings accessors (for routes)
_get: s, _bool: b, _num: n,
_get: s, _bool: b, _num: n,
};
};
@@ -329,6 +329,11 @@ async function check(name, fn) {
@@ -329,6 +329,11 @@ async function check(name, fn) {
let r = await call('POST', '/api/payments/bucks/checkout', 2, { provider: 'stripe', bucks: 500 });
let r = await call('POST', '/api/payments/bucks/checkout', 2, { provider: 'stripe', bucks: 500 });
assert.strictEqual(r.status, 403); assert.strictEqual(callsSince(n).length, 0);
assert.strictEqual(r.status, 403); assert.strictEqual(callsSince(n).length, 0);
await db.setSetting('payments_enabled', 'true');
await db.setSetting('payments_enabled', 'true');
+ // The provider's own switch is off: not offered, and Billing is not asked.
+ r = await call('POST', '/api/payments/bucks/checkout', 2, { provider: 'stripe', bucks: 500 });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.ok(!callsSince(n).some((c) => c.path === '/intents'), 'no intent for a rail that is off');
+ await db.setSetting('stripe_enabled', 'true'); await db.setSetting('crypto_enabled', 'true');
r = await call('POST', '/api/payments/bucks/checkout', 2, { provider: 'stripe', bucks: 500 });
r = await call('POST', '/api/payments/bucks/checkout', 2, { provider: 'stripe', bucks: 500 });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
assert.match(r.json.url, /^https:\/\/checkout\.test\/pi_/);
assert.match(r.json.url, /^https:\/\/checkout\.test\/pi_/);
@@ -337,6 +342,48 @@ async function check(name, fn) {
@@ -337,6 +342,48 @@ async function check(name, fn) {
await db.setSetting('payments_enabled', 'false');
await db.setSetting('payments_enabled', 'false');
});
});
+ await check('Live offers a card rail only when Billing reports the provider enabled (Billing holds the keys, Live none)', async () => {
+ const pay = require('../server/monetization/payments');
+ await db.setSetting('payments_enabled', 'true');
+ pay._resetProviders();
+ let cfg = (await call('GET', '/api/payments/config')).json;
+ assert.deepStrictEqual(cfg.providers, { paypal: false, stripe: true, ccbill: false, crypto: true, powerchat: cfg.providers.powerchat });
+ assert.ok(!('stripePublishableKey' in cfg), 'no provider key leaves Live');
+ // Billing has no Stripe keys: the button goes, and a checkout or card subscription is refused before Billing.
+ billing.state.providers = { powerchat: true, nowpayments: true };
+ pay._resetProviders();
+ cfg = (await call('GET', '/api/payments/config')).json;
+ assert.strictEqual(cfg.providers.stripe, false);
+ assert.strictEqual(cfg.providers.crypto, true);
+ const n = billing.calls.length;
+ let r = await call('POST', '/api/payments/bucks/checkout', 2, { provider: 'stripe', bucks: 500 });
+ assert.strictEqual(r.status, 403, r.text);
+ r = await call('POST', '/api/payments/subscribe', 2, { streamer: 'bob', provider: 'stripe' });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.ok(!callsSince(n).some((c) => c.path === '/intents' || c.path === '/subscriptions'), 'Billing is not asked to charge');
+ // Billing unreachable with nothing cached: no card rail is offered.
+ billing.state.fail500 = true;
+ pay._resetProviders();
+ cfg = (await call('GET', '/api/payments/config')).json;
+ assert.deepStrictEqual([cfg.providers.stripe, cfg.providers.crypto], [false, false]);
+ billing.state.fail500 = false;
+ delete billing.state.providers;
+ pay._resetProviders();
+ await db.setSetting('payments_enabled', 'false');
+ });
+
+ await check('no provider credential is read by Live, and migration 0013 removes them from site_settings', async () => {
+ const fs = require('fs'); const path = require('path');
+ const sql = fs.readFileSync(path.join(__dirname, '..', 'migrations', '0013_provider_keys_are_billings.sql'), 'utf8');
+ const keys = [...sql.matchAll(/'((?:paypal|stripe|ccbill|crypto)_[a-z_]+)'/g)].map((m) => m[1]);
+ assert.strictEqual(keys.length, 15);
+ const files = [];
+ (function walk(d) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (p.endsWith('.js')) files.push(p); } })(path.join(__dirname, '..', 'server'));
+ const readers = files.flatMap((f) => { const src = fs.readFileSync(f, 'utf8'); return keys.filter((k) => src.includes(`'${k}'`)).map((k) => `${path.basename(f)}: ${k}`); });
+ assert.deepStrictEqual(readers, []);
+ for (const k of keys) assert.strictEqual(await db.getSetting(k), null, `${k} is gone`);
+ });
+
await check('a Vibes media request is a paid interaction keyed by its request; its refund reverses that exact transfer', async () => {
await check('a Vibes media request is a paid interaction keyed by its request; its refund reverses that exact transfer', async () => {
const mediaQueue = require('../server/media/media-queue');
const mediaQueue = require('../server/media/media-queue');
const reqId = Number((await db.run("INSERT INTO media_requests (streamer_id, stream_id, user_id, username, input, canonical_url, provider, title, duration_seconds, cost, queue_position, currency) VALUES (3, ?, 2, 'ann', 'x', 'https://x.test/a', 'youtube', 'song', 60, 40, 1, 'vibes') RETURNING id", [streamB])).lastInsertRowid);
const reqId = Number((await db.run("INSERT INTO media_requests (streamer_id, stream_id, user_id, username, input, canonical_url, provider, title, duration_seconds, cost, queue_position, currency) VALUES (3, ?, 2, 'ann', 'x', 'https://x.test/a', 'youtube', 'song', 60, 40, 1, 'vibes') RETURNING id", [streamB])).lastInsertRowid);
@@ -75,7 +75,7 @@ async function startBilling({ network }) {
@@ -75,7 +75,7 @@ async function startBilling({ network }) {
function route(method, path, q, b, res) {
function route(method, path, q, b, res) {
let m;
let m;
if (method === 'GET' && path === '/rates') {
if (method === 'GET' && path === '/rates') {
- return send(res, 200, { bits_per_usd: 100, min_purchase_bits: 100, subscription: { price_cents: 499, streamer_share_pct: 70, site_route_fee_pct: 10 }, cashout: { min_bits: 500, escrow_days: 14 }, providers: { powerchat: true, stripe: true } });
+ return send(res, 200, { bits_per_usd: 100, min_purchase_bits: 100, subscription: { price_cents: 499, streamer_share_pct: 70, site_route_fee_pct: 10 }, cashout: { min_bits: 500, escrow_days: 14 }, providers: state.providers || { powerchat: true, stripe: true, nowpayments: true } });
}
}
if (method === 'POST' && path === '/intents') {
if (method === 'POST' && path === '/intents') {
const kind = b.kind;
const kind = b.kind;