openvibers/openvibe.network Commits
Admin panel: TTS reaches OpenVibe.Chat again, Cashouts points to Billing's console, Payments shows Billing's webhooks without the secrets nothing read, paste limits are read-only from Community, and the dead VPN queue tab is gone (#299)
Changes
@@@ -86,6 +86,7 @@ OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000OV_TOOLS_INTERNAL_URL=http://127.0.0.1:4001OV_GAMES_INTERNAL_URL=http://127.0.0.1:8000OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100++OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400# Optional: SSH admin-tab context (non-secret display values)#SSH_SERVER_HOST=@@@ -48,7 +48,6 @@<button class="admin-tab-btn owner-only" onclick="showTab('cashouts',this)"><i class="fa-solid fa-money-bill-transfer"></i> Cashouts</button><button class="admin-tab-btn owner-only" onclick="showTab('payments',this)"><i class="fa-solid fa-credit-card"></i> Payments</button><button class="admin-tab-btn" onclick="showTab('ai',this)"><i class="fa-solid fa-robot"></i> AI</button>-- <button class="admin-tab-btn" onclick="showTab('vpn',this)"><i class="fa-solid fa-user-shield"></i> VPN Queue</button><button class="admin-tab-btn" onclick="showTab('pastes',this)"><i class="fa-solid fa-paste"></i> Pastes</button><button class="admin-tab-btn owner-only" onclick="showTab('storage',this)"><i class="fa-solid fa-hard-drive"></i> Storage</button><button class="admin-tab-btn" onclick="showTab('notifications',this)"><i class="fa-solid fa-bell"></i> Notifications</button>@@@ -150,12 +149,20 @@<div id="tts-content"><div class="loading">Loading...</div></div></div>-- <!-- Cashouts -->++ <!-- Cashouts: decided in OpenVibe.Billing's staff console (Billing is the only money ledger since 2026-10-17) --><div class="section" id="sec-cashouts">-- <div id="cashouts-content"><div class="loading">Loading...</div></div>++ <div class="card" style="max-width:760px">++ <h3><i class="fa-solid fa-money-bill-transfer" aria-hidden="true"></i> Cashouts are decided in OpenVibe.Billing</h3>++ <p class="muted" style="margin-bottom:8px">Billing holds every creator's balance. Its staff console lists cashouts still in escrow, ready to pay, paid and denied. Approving one records the payout reference; denying one puts the amount back in the creator's balance. The money freeze is there too.</p>++ <p class="muted">Sign in with your OpenVibe account. Billing lets in the admins listed in its <code>BILLING_STAFF_SUBJECTS</code> setting.</p>++ <div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:16px">++ <a class="btn btn-primary" href="https://billing.openvibe.network/cashouts" target="_blank" rel="noopener"><i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i> Open cashouts in Billing</a>++ <a class="btn btn-outline" href="https://billing.openvibe.network/freeze" target="_blank" rel="noopener"><i class="fa-solid fa-snowflake" aria-hidden="true"></i> Money freeze</a>++ </div>++ </div></div>-- <!-- Payments: configure PayPal / Stripe / CCBill / crypto + subscription pricing -->++ <!-- Payments: the switches and prices Live reads; provider webhooks and credentials are OpenVibe.Billing's --><div class="section" id="sec-payments"><div id="payments-content"><div class="loading">Loading...</div></div></div>@@@ -165,11 +172,6 @@<div id="ai-content"><div class="loading">Loading...</div></div></div>-- <!-- VPN Queue -->-- <div class="section" id="sec-vpn">-- <div id="vpn-content"><div class="loading">Loading...</div></div>-- </div>--<!-- Pastes --><div class="section" id="sec-pastes"><div id="pastes-content"><div class="loading">Loading...</div></div>@@@ -74,6 +74,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa.btn-success{background:rgba(46,204,113,0.1);color:var(--success);border:1px solid rgba(46,204,113,0.2)}.btn-success:hover{background:rgba(46,204,113,0.2)}.btn-sm{padding:5px 10px;font-size:11px}++a.btn{text-decoration:none}.btn:disabled{opacity:.4;cursor:not-allowed}/* Form fields */@@@ -84,6 +85,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMaborder-radius:8px;color:var(--text);font-size:13px;outline:none;transition:border-color .2s;}.form-field input:focus,.form-field select:focus,.form-field textarea:focus{border-color:var(--accent)}++.form-field input:disabled,.form-field select:disabled,.form-field textarea:disabled{opacity:.55;cursor:not-allowed}/* Input inline */.input-row{display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap}@@@ -153,6 +155,6 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa.tabs-scroll-btn{width:30px;height:30px;flex-basis:30px;border-radius:8px}}.ssh-prompt{background:var(--bg-elev,#111);border:1px solid var(--border,#2a2a2a);border-radius:8px;padding:14px;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.8rem;line-height:1.55;color:var(--text,#ddd);max-height:520px;overflow:auto;margin:0}--#sec-ssh code{background:var(--bg-elev,#111);border:1px solid var(--border,#2a2a2a);border-radius:5px;padding:2px 7px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.8rem;display:inline-block;margin-top:3px;word-break:break-all}++#sec-ssh code,#sec-payments code,#sec-cashouts code{background:var(--bg-elev,#111);border:1px solid var(--border,#2a2a2a);border-radius:5px;padding:2px 7px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.8rem;display:inline-block;margin-top:3px;word-break:break-all}#sec-ssh .staff-pill{font-size:0.62rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:3px 8px;border-radius:999px;vertical-align:middle;margin-left:8px;color:var(--warning,#f0b74a);background:color-mix(in srgb,var(--warning,#f0b74a) 18%,transparent);border:1px solid color-mix(in srgb,var(--warning,#f0b74a) 40%,transparent)}#sec-ssh .staff-pill.user-pill{color:var(--accent,#6ea8ff);background:color-mix(in srgb,var(--accent,#6ea8ff) 18%,transparent);border-color:color-mix(in srgb,var(--accent,#6ea8ff) 40%,transparent)}@@@ -1,5 +1,5 @@/* ═══════════════════════════════════════════════════════════════-- OpenVibe.Network — admin panel (/admin): AI, AI viewers, streamer media tools and TTS.++ OpenVibe.Network — admin panel (/admin): AI, AI viewers, streamer media tools and TTS (OpenVibe.Chat).Split out of admin.html. Classic scripts, global scope: they rely onadmin/core.js's helpers (api, esc, toast, API, token) and on thetabLoaders map in admin/boot.js (loaded last). No ES modules.@@@ -404,7 +404,9 @@ async function testStreamerMediaExtraction() {}// ═══════════════════════════════════════════════════════════════--// TTS (via openvibelive TTS proxy)++// TTS: OpenVibe.Chat's /api/tts admin routes, through the admin proxy (/api/admin/streamer-tts → Chat). Chat checks++// the admin role again and keeps the provider credentials owner-only (it masks them for other admins and ignores++// them in their saves); the settings themselves are stored with Live's site settings.// ═══════════════════════════════════════════════════════════════async function loadTTS() {const c = document.getElementById('tts-content');@@@ -415,87 +417,103 @@ async function loadTTS() {api('/api/admin/streamer-tts/voices'),]);const s = settingsRes.settings || {};++ const locked = !!s._secretsRedacted; // not the owner: credentials are shown masked and cannot be changedconst voices = voicesRes.voices || [];const byEngine = (engine) => voices.filter(v => v.engine === engine);const voiceOptions = (list) => list.map(v => `<option value="${esc(v.id)}" ${v.id === s.defaultVoice ? 'selected' : ''}>${esc(v.name)} (${esc(v.rarity || 'standard')})${v.available ? '' : ' ⚠ unavailable'}</option>`).join('');++ const secret = (id, label, value) => `<div class="form-field"><label for="${id}">${label}</label><input type="password" id="${id}" value="${esc(value || '')}" autocomplete="off"${locked ? ' disabled' : ''}></div>`;++ const ownerNote = locked ? '<p class="muted" style="margin-bottom:12px"><i class="fa-solid fa-lock" aria-hidden="true"></i> Only the owner can see or change provider credentials.</p>' : '';c.innerHTML = `<form id="tts-form" style="display:grid;gap:16px;max-width:760px">++ <p class="muted">Text-to-speech in chat runs in OpenVibe.Chat. These settings apply to every channel.</p><div class="card">-- <h3><i class="fa-solid fa-sliders"></i> TTS Configuration</h3>-- <div class="setting-row"><label><strong>Enabled</strong><br><small>Allow TTS requests on OpenVibe.Live.</small></label><input type="checkbox" id="tts-enabled" ${s.enabled ? 'checked' : ''} style="width:18px;height:18px"></div>-- <div class="form-field"><label>Provider</label><select id="tts-provider"><option value="espeak-ng" ${s.provider === 'espeak-ng' ? 'selected' : ''}>espeak-ng</option><option value="google-cloud" ${s.provider === 'google-cloud' ? 'selected' : ''}>Google Cloud</option><option value="amazon-polly" ${s.provider === 'amazon-polly' ? 'selected' : ''}>Amazon Polly</option></select></div>-- <div class="form-field"><label>Default Voice</label><select id="tts-default-voice"><optgroup label="espeak-ng">${voiceOptions(byEngine('espeak-ng'))}</optgroup><optgroup label="Google Cloud">${voiceOptions(byEngine('google-cloud'))}</optgroup><optgroup label="Amazon Polly">${voiceOptions(byEngine('amazon-polly'))}</optgroup></select></div>-- <div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px">-- <div class="form-field"><label>Max Message Length</label><input type="number" id="tts-max-length" value="${esc(s.maxLength || 200)}"></div>-- <div class="form-field"><label>Max Queue / User</label><input type="number" id="tts-max-per-user" value="${esc(s.maxQueuePerUser || 3)}"></div>-- <div class="form-field"><label>Max Global Queue</label><input type="number" id="tts-max-global" value="${esc(s.maxQueueGlobal || 20)}"></div>++ <h3><i class="fa-solid fa-sliders" aria-hidden="true"></i> TTS Configuration</h3>++ <div class="setting-row"><label for="tts-enabled"><strong>Enabled</strong><br><small>Allow text-to-speech messages in chat.</small></label><input type="checkbox" id="tts-enabled" ${s.enabled ? 'checked' : ''} style="width:18px;height:18px"></div>++ <div class="form-field"><label for="tts-provider">Provider</label><select id="tts-provider"><option value="espeak-ng" ${s.provider === 'espeak-ng' ? 'selected' : ''}>espeak-ng</option><option value="google-cloud" ${s.provider === 'google-cloud' ? 'selected' : ''}>Google Cloud</option><option value="amazon-polly" ${s.provider === 'amazon-polly' ? 'selected' : ''}>Amazon Polly</option></select></div>++ <div class="form-field"><label for="tts-default-voice">Default Voice</label><select id="tts-default-voice"><optgroup label="espeak-ng">${voiceOptions(byEngine('espeak-ng'))}</optgroup><optgroup label="Google Cloud">${voiceOptions(byEngine('google-cloud'))}</optgroup><optgroup label="Amazon Polly">${voiceOptions(byEngine('amazon-polly'))}</optgroup></select></div>++ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px">++ <div class="form-field"><label for="tts-max-length">Max Message Length</label><input type="number" id="tts-max-length" value="${esc(s.maxLength || 200)}"></div>++ <div class="form-field"><label for="tts-max-per-user">Max Queue / User</label><input type="number" id="tts-max-per-user" value="${esc(s.maxQueuePerUser || 3)}"></div>++ <div class="form-field"><label for="tts-max-global">Max Global Queue</label><input type="number" id="tts-max-global" value="${esc(s.maxQueueGlobal || 20)}"></div></div></div><div class="card">-- <h3><i class="fa-brands fa-google"></i> Google Cloud</h3>-- <div class="form-field"><label>API Key</label><input type="password" id="tts-google-api-key" value="${esc(s.googleApiKey || '')}" autocomplete="off"></div>-- <div class="form-field"><label>Service Account JSON</label><textarea id="tts-google-sa" rows="4" style="resize:vertical">${esc(s.googleServiceAccount || '')}</textarea></div>++ <h3><i class="fa-brands fa-google" aria-hidden="true"></i> Google Cloud</h3>++ ${ownerNote}++ ${secret('tts-google-api-key', 'API Key', s.googleApiKey)}++ <div class="form-field"><label for="tts-google-sa">Service Account JSON</label><textarea id="tts-google-sa" rows="4" style="resize:vertical"${locked ? ' disabled' : ''}>${esc(s.googleServiceAccount || '')}</textarea></div></div><div class="card">-- <h3><i class="fa-brands fa-aws"></i> Amazon Polly</h3>-- <div class="form-field"><label>AWS Access Key ID</label><input type="password" id="tts-aws-key" value="${esc(s.awsAccessKeyId || '')}" autocomplete="off"></div>-- <div class="form-field"><label>AWS Secret Access Key</label><input type="password" id="tts-aws-secret" value="${esc(s.awsSecretAccessKey || '')}" autocomplete="off"></div>-- <div class="form-field"><label>AWS Region</label><input type="text" id="tts-aws-region" value="${esc(s.awsRegion || 'us-east-1')}"></div>++ <h3><i class="fa-brands fa-aws" aria-hidden="true"></i> Amazon Polly</h3>++ ${ownerNote}++ ${secret('tts-aws-key', 'AWS Access Key ID', s.awsAccessKeyId)}++ ${secret('tts-aws-secret', 'AWS Secret Access Key', s.awsSecretAccessKey)}++ <div class="form-field"><label for="tts-aws-region">AWS Region</label><input type="text" id="tts-aws-region" value="${esc(s.awsRegion || 'us-east-1')}"></div></div><div style="display:flex;gap:10px;flex-wrap:wrap">-- <button type="button" class="btn btn-primary" onclick="saveTTS()"><i class="fa-solid fa-floppy-disk"></i> Save TTS Settings</button>-- <button type="button" class="btn btn-outline" onclick="testTTSVoice()"><i class="fa-solid fa-volume-high"></i> Test Voice</button>++ <button type="button" class="btn btn-primary" onclick="saveTTS()"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Save TTS Settings</button>++ <button type="button" class="btn btn-outline" onclick="testTTSVoice()"><i class="fa-solid fa-volume-high" aria-hidden="true"></i> Test Voice</button></div></form>`;} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }}async function saveTTS() {-- try {-- await api('/api/admin/streamer-tts/admin/settings', {-- method: 'PUT',-- body: {-- settings: {-- tts_enabled: document.getElementById('tts-enabled').checked,-- tts_provider: document.getElementById('tts-provider').value,-- tts_default_voice: document.getElementById('tts-default-voice').value,-- tts_max_length: parseInt(document.getElementById('tts-max-length').value, 10) || 200,-- tts_max_queue_per_user: parseInt(document.getElementById('tts-max-per-user').value, 10) || 3,-- tts_max_queue_global: parseInt(document.getElementById('tts-max-global').value, 10) || 20,-- tts_google_api_key: document.getElementById('tts-google-api-key').value,-- tts_google_service_account: document.getElementById('tts-google-sa').value,-- tts_aws_access_key_id: document.getElementById('tts-aws-key').value,-- tts_aws_secret_access_key: document.getElementById('tts-aws-secret').value,-- tts_aws_region: document.getElementById('tts-aws-region').value || 'us-east-1',-- },-- },++ const val = (id) => document.getElementById(id).value;++ const settings = {++ tts_enabled: document.getElementById('tts-enabled').checked,++ tts_provider: val('tts-provider'),++ tts_default_voice: val('tts-default-voice'),++ tts_max_length: parseInt(val('tts-max-length'), 10) || 200,++ tts_max_queue_per_user: parseInt(val('tts-max-per-user'), 10) || 3,++ tts_max_queue_global: parseInt(val('tts-max-global'), 10) || 20,++ tts_aws_region: val('tts-aws-region') || 'us-east-1',++ };++ // Credentials go only from the owner's form (Chat ignores them from anyone else, and never stores a masked value).++ if (!document.getElementById('tts-aws-secret').disabled) {++ Object.assign(settings, {++ tts_google_api_key: val('tts-google-api-key'),++ tts_google_service_account: val('tts-google-sa'),++ tts_aws_access_key_id: val('tts-aws-key'),++ tts_aws_secret_access_key: val('tts-aws-secret'),});++ }++ try {++ await api('/api/admin/streamer-tts/admin/settings', { method: 'PUT', body: { settings } });toast('TTS settings saved', 'success');} catch (e) { toast(e.message, 'error'); }}++// The test clip plays through Web Audio: this page's Content-Security-Policy (media-src 'self') refuses blob:++// and data: audio, and the clip URL Chat returns is on Live's origin.++let _ttsAudioCtx = null;async function testTTSVoice() {try {const voiceId = document.getElementById('tts-default-voice').value;const result = await api('/api/admin/streamer-tts/admin/test', {method: 'POST',-- body: { voiceId, text: 'Hello, this is a OpenVibe.Live voice test from the unified admin panel.' },++ body: { voiceId, text: 'Hello, this is a voice test from the OpenVibe admin panel.' },});-- if (!result.audio || !result.mimeType) {++ if (!result.audio) {toast('No audio returned from the provider', 'warning');return;}const binary = atob(result.audio);const bytes = new Uint8Array(binary.length);for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);-- const url = URL.createObjectURL(new Blob([bytes], { type: result.mimeType }));-- const audio = new Audio(url);-- audio.onended = () => URL.revokeObjectURL(url);-- await audio.play();++ const Ctx = window.AudioContext || window.webkitAudioContext;++ if (!Ctx) { toast('This browser cannot play the test clip', 'warning'); return; }++ _ttsAudioCtx = _ttsAudioCtx || new Ctx();++ if (_ttsAudioCtx.state === 'suspended') await _ttsAudioCtx.resume();++ const buffer = await _ttsAudioCtx.decodeAudioData(bytes.buffer);++ const src = _ttsAudioCtx.createBufferSource();++ src.buffer = buffer;++ src.connect(_ttsAudioCtx.destination);++ src.start();toast(`Testing ${result.voiceName || voiceId}`, 'info');-- } catch (e) { toast(e.message, 'error'); }++ } catch (e) { toast(e.message || 'Could not play the test clip', 'error'); }}@@@ -14,10 +14,9 @@ const tabLoaders = {moderators: loadModerators,settings: loadSettings,tts: loadTTS,-- cashouts: loadCashouts,++ cashouts: () => {}, // a pointer to OpenVibe.Billing's consolepayments: loadPayments,ai: loadAi,-- vpn: loadVPNQueue,pastes: loadPastesAdmin,storage: loadStorage,notifications: () => {},@@@ -1,50 +1,15 @@/* ═══════════════════════════════════════════════════════════════-- OpenVibe.Network — admin panel (/admin): VPN queue, pastes, storage, VODs, notifications broadcast and email.++ OpenVibe.Network — admin panel (/admin): pastes, storage, VODs, notifications broadcast and email.Split out of admin.html. Classic scripts, global scope: they rely onadmin/core.js's helpers (api, esc, toast, API, token) and on thetabLoaders map in admin/boot.js (loaded last). No ES modules.═══════════════════════════════════════════════════════════════ */--// ═══════════════════════════════════════════════════════════════--// VPN queue (via admin proxy)--// ═══════════════════════════════════════════════════════════════--async function loadVPNQueue() {-- const c = document.getElementById('vpn-content');-- c.innerHTML = '<div class="loading">Loading...</div>';-- try {-- const data = await api('/api/admin/streamer/vpn-queue');-- const queue = data.queue || [];-- if (!queue.length) { c.innerHTML = '<p class="muted">VPN approval queue empty</p>'; return; }-- c.innerHTML = `-- <table class="admin-table">-- <thead><tr><th>User</th><th>IP</th><th>Reason</th><th>Status</th><th>Actions</th></tr></thead>-- <tbody>${queue.map(item => `-- <tr>-- <td><strong>${esc(item.username || item.user_id || '-')}</strong></td>-- <td>${esc(item.ip_address || '-')}</td>-- <td>${esc(item.reason || '-')}</td>-- <td><span class="badge badge-${item.status === 'approved' ? 'success' : item.status === 'denied' ? 'danger' : 'warning'}">${esc(item.status || 'pending')}</span></td>-- <td>${item.status === 'pending' ? `-- <div style="display:flex;gap:6px">-- <button class="btn btn-sm btn-success" onclick="updateVPNStatus('${item.id}','approved')"><i class="fa-solid fa-check"></i> Approve</button>-- <button class="btn btn-sm btn-danger" onclick="updateVPNStatus('${item.id}','denied')"><i class="fa-solid fa-xmark"></i> Deny</button>-- </div>` : '<span class="muted">Handled</span>'}-- </td>-- </tr>-- `).join('')}</tbody>-- </table>`;-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }--}----async function updateVPNStatus(id, status) {-- try {-- await api(`/api/admin/streamer/vpn-queue/${id}`, { method: 'PUT', body: { status } });-- toast(`VPN request ${status}`, status === 'approved' ? 'success' : 'info');-- loadVPNQueue();-- } catch (e) { toast(e.message, 'error'); }--}++// The VPN queue tab is gone: nothing writes Live's vpn_approvals since IP approval moved to OpenVibe.Chat. Held++// messages are reviewed per channel by the streamer and channel mods (openvibe.live/dashboard/moderation); the++// operator checklist points there.// ═══════════════════════════════════════════════════════════════--// Pastes (via pastes proxy + streamer settings)++// Pastes (OpenVibe.Community, through the pastes proxy: Network → Live → Community)// ═══════════════════════════════════════════════════════════════async function loadPastesAdmin() {const c = document.getElementById('pastes-content');@@@ -56,50 +21,35 @@ async function loadPastesAdmin() {]);const s = statsData.stats || {};const cfg = configData || {};++ const n = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—');++ const limit = (name, value) => `++ <div class="setting-row">++ <span style="flex:1"><strong style="font-size:13px">${esc(name)}</strong></span>++ <span style="font-size:13px">${esc(value)}</span>++ </div>`;c.innerHTML = `<div class="stats-grid">-- <div class="stat-card"><div class="value">${Number(s.total || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-paste"></i> Total Pastes</div></div>-- <div class="stat-card"><div class="value">${Number(s.textPastes || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-code"></i> Text Pastes</div></div>-- <div class="stat-card"><div class="value">${Number(s.screenshots || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-image"></i> Screenshots</div></div>-- <div class="stat-card"><div class="value">${Number(s.forks || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-code-fork"></i> Forks</div></div>++ <div class="stat-card"><div class="value">${n(s.total || 0)}</div><div class="label"><i class="fa-solid fa-paste" aria-hidden="true"></i> Total Pastes</div></div>++ <div class="stat-card"><div class="value">${n(s.textPastes || 0)}</div><div class="label"><i class="fa-solid fa-code" aria-hidden="true"></i> Text Pastes</div></div>++ <div class="stat-card"><div class="value">${n(s.screenshots || 0)}</div><div class="label"><i class="fa-solid fa-image" aria-hidden="true"></i> Screenshots</div></div>++ <div class="stat-card"><div class="value">${n(s.forks || 0)}</div><div class="label"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Forks</div></div></div><div class="card">-- <h3><i class="fa-solid fa-sliders"></i> Paste Limits</h3>-- <div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px">-- <div class="form-field"><label>Max Paste Size (KB)</label><input type="number" id="paste-max-size-kb" value="${esc(cfg.maxSizeKb || 512)}"></div>-- <div class="form-field"><label>Max Image Size (MB)</label><input type="number" id="paste-screenshot-max-mb" value="${esc(cfg.screenshotMaxSizeMb || 8)}"></div>-- <div class="form-field"><label>Cooldown (seconds)</label><input type="number" id="paste-cooldown" value="${esc(cfg.cooldownSeconds || 30)}"></div>-- <div class="form-field"><label>Max / User / Day</label><input type="number" id="paste-max-per-day" value="${esc(cfg.maxPerUserPerDay || 50)}"></div>-- </div>-- <div class="setting-row"><label><strong>Allow anonymous pastes</strong></label><input type="checkbox" id="paste-anon" ${cfg.anonAllowed !== false ? 'checked' : ''} style="width:18px;height:18px"></div>-- <div class="setting-row"><label><strong>Allow image uploads</strong></label><input type="checkbox" id="paste-image-upload" ${cfg.imageUploadEnabled !== false ? 'checked' : ''} style="width:18px;height:18px"></div>-- <div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:10px">-- <button class="btn btn-primary" onclick="savePasteAdminConfig()"><i class="fa-solid fa-floppy-disk"></i> Save Paste Settings</button>-- <button class="btn btn-danger" onclick="deleteAllPasteForks()"><i class="fa-solid fa-trash-can"></i> Delete All Forks (${Number(s.forks || 0).toLocaleString()})</button>-- </div>++ <h3><i class="fa-solid fa-sliders" aria-hidden="true"></i> Paste limits</h3>++ <p class="muted" style="margin-bottom:8px">OpenVibe.Community owns pastes and sets these limits itself; they cannot be changed from here.</p>++ ${limit('Largest text paste', `${n(cfg.maxSizeKb)} KB`)}++ ${limit('Largest image', `${n(cfg.screenshotMaxSizeMb)} MB`)}++ ${limit('Wait between pastes', `${n(cfg.cooldownSeconds)} seconds`)}++ ${limit('Pastes per person per day', Number(cfg.maxPerUserPerDay) === 0 ? 'No limit' : n(cfg.maxPerUserPerDay))}++ </div>++ <div class="card">++ <h3><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Forks</h3>++ <p class="muted" style="margin-bottom:12px">Copies people made of other pastes. Deleting them cannot be undone.</p>++ <button class="btn btn-danger" onclick="deleteAllPasteForks()"${Number(s.forks || 0) ? '' : ' disabled'}><i class="fa-solid fa-trash-can" aria-hidden="true"></i> Delete all forks (${n(s.forks || 0)})</button></div>`;} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }}--async function savePasteAdminConfig() {-- try {-- await api('/api/admin/streamer/settings', {-- method: 'PUT',-- body: {-- settings: {-- paste_max_size_kb: document.getElementById('paste-max-size-kb').value,-- paste_screenshot_max_size_mb: document.getElementById('paste-screenshot-max-mb').value,-- paste_cooldown_seconds: document.getElementById('paste-cooldown').value,-- paste_max_per_user_per_day: document.getElementById('paste-max-per-day').value,-- paste_anon_allowed: document.getElementById('paste-anon').checked ? 'true' : 'false',-- paste_image_upload_enabled: document.getElementById('paste-image-upload').checked ? 'true' : 'false',-- },-- },-- });-- toast('Paste settings saved', 'success');-- } catch (e) { toast(e.message, 'error'); }--}--async function deleteAllPasteForks() {if (!confirm('Delete all forked pastes? This cannot be undone.')) return;try {@@@ -1,23 +1,49 @@/* ═══════════════════════════════════════════════════════════════-- OpenVibe.Network — admin panel (/admin): payments, cashouts and verification keys.++ OpenVibe.Network — admin panel (/admin): payment settings, the Billing pointer and verification keys.Split out of admin.html. Classic scripts, global scope: they rely onadmin/core.js's helpers (api, esc, toast, API, token) and on thetabLoaders map in admin/boot.js (loaded last). No ES modules.═══════════════════════════════════════════════════════════════ */--// ── Payments: configure PayPal / Stripe / CCBill / crypto + subscriptions ──++// ── Payments: the switches and prices OpenVibe.Live reads ──++// OpenVibe.Billing runs every checkout, webhook, subscription and cashout since the cutover of 2026-10-17. Live++// still reads these site settings to decide which ways to pay it offers and at what price (Live's++// server/monetization/payments.js). Provider credentials are Billing's environment variables, never settings here.const PAYMENT_GROUPS = [-- { title: 'General', keys: ['payments_enabled', 'bucks_per_usd', 'bucks_min_purchase_usd', 'sub_price_usd', 'sub_streamer_share_pct'] },-- { title: 'PayPal', keys: ['paypal_enabled', 'paypal_mode', 'paypal_client_id', 'paypal_client_secret', 'paypal_webhook_id'] },-- { title: 'Stripe', keys: ['stripe_enabled', 'stripe_secret_key', 'stripe_publishable_key', 'stripe_webhook_secret'] },-- { title: 'CCBill', keys: ['ccbill_enabled', 'ccbill_client_account', 'ccbill_subaccount', 'ccbill_flexform_id', 'ccbill_salt', 'ccbill_webhook_secret'] },-- { title: 'Crypto (NOWPayments)', keys: ['crypto_enabled', 'crypto_provider', 'crypto_api_key', 'crypto_ipn_secret'] },++ { title: 'General', keys: ['payments_enabled', 'bucks_per_usd', 'bucks_min_purchase_bucks', 'sub_price_usd', 'sub_streamer_share_pct', 'sub_site_route_fee_pct'] },++ { title: 'PayPal', keys: ['paypal_enabled', 'paypal_client_id'] },++ { title: 'Stripe', keys: ['stripe_enabled', 'stripe_secret_key', 'stripe_publishable_key'] },++ { title: 'CCBill', keys: ['ccbill_enabled', 'ccbill_flexform_id'] },++ { title: 'Crypto (NOWPayments)', keys: ['crypto_enabled', 'crypto_api_key'] },];--const PAYMENT_WEBHOOKS = {-- Stripe: 'https://openvibe.live/api/payments/webhook/stripe',-- PayPal: 'https://openvibe.live/api/payments/webhook/paypal',-- CCBill: 'https://openvibe.live/api/payments/webhook/ccbill?secret=YOUR_ccbill_webhook_secret',-- 'Crypto (NOWPayments)': 'https://openvibe.live/api/payments/webhook/crypto',--};++// Where providers send payments (OpenVibe.Billing server/api/webhooks.js) and the variables Billing reads++// (its server/config.js), set in /etc/openvibe/billing.env on the host.++const BILLING_URL = 'https://billing.openvibe.network';++const BILLING_PROVIDERS = [++ { name: 'Stripe', webhook: '/webhooks/stripe', env: ['STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'] },++ { name: 'PayPal', webhook: '/webhooks/paypal', env: ['PAYPAL_CLIENT_ID', 'PAYPAL_CLIENT_SECRET', 'PAYPAL_WEBHOOK_ID', 'PAYPAL_MODE'] },++ { name: 'CCBill', webhook: '/webhooks/ccbill?secret=<CCBILL_WEBHOOK_SECRET>', env: ['CCBILL_WEBHOOK_SECRET', 'CCBILL_CLIENT_ACCOUNT', 'CCBILL_SUBACCOUNT', 'CCBILL_FLEXFORM_ID', 'CCBILL_SALT'] },++ { name: 'Crypto (NOWPayments)', webhook: '/webhooks/nowpayments', env: ['NOWPAYMENTS_API_KEY', 'NOWPAYMENTS_IPN_SECRET'] },++ { name: 'PowerChat', webhook: '/webhooks/powerchat', env: ['POWERCHAT_WEBHOOK_SECRET', 'POWERCHAT_SITE_USERNAME'] },++];++++function billingProvidersCard() {++ const label = (t) => `<span class="muted" style="font-size:11px;display:block">${esc(t)}</span>`;++ const rows = BILLING_PROVIDERS.map(p => `++ <div class="setting-row" style="flex-wrap:wrap;align-items:flex-start;gap:6px 16px">++ <strong style="font-size:13px;flex:0 0 150px">${esc(p.name)}</strong>++ <div style="flex:1 1 300px;min-width:0;display:grid;gap:8px">++ <div>${label('Webhook URL')}<code>${esc(BILLING_URL + p.webhook)}</code></div>++ <div>${label('Billing environment variables')}${p.env.map(v => `<code>${esc(v)}</code>`).join(' ')}</div>++ </div>++ </div>`).join('');++ return `++ <div class="card">++ <h3><i class="fa-solid fa-link" aria-hidden="true"></i> Provider webhooks and credentials</h3>++ <p class="muted" style="margin-bottom:4px">Providers send payments to OpenVibe.Billing: set each provider's webhook to the URL below. Their keys and secrets go in Billing's environment file on the host, <code>/etc/openvibe/billing.env</code>, under these variable names; nothing on this page holds them.</p>++ ${rows}++ <p style="margin-top:14px"><a class="btn btn-outline" href="${BILLING_URL}/" target="_blank" rel="noopener"><i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i> Open the Billing console</a></p>++ </div>`;++}async function loadPayments() {const c = document.getElementById('payments-content');@@@ -27,17 +53,18 @@ async function loadPayments() {const byKey = {}; (data.settings || []).forEach(s => { byKey[s.key] = s; });const field = (s) => {if (!s) return '';-- const sensitive = /(secret|salt|api[_-]?key|client_secret|ipn)/i.test(s.key);++ const sensitive = /(secret|api[_-]?key)/i.test(s.key);++ const id = `pay-${s.key}`;if (s.type === 'boolean') return `<div class="setting-row">-- <label><strong>${esc(s.key)}</strong><br><small>${esc(s.description||'')}</small></label>-- <input type="checkbox" data-key="${esc(s.key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer">++ <label for="${esc(id)}"><strong>${esc(s.key)}</strong><br><small>${esc(s.description||'')}</small></label>++ <input type="checkbox" id="${esc(id)}" data-key="${esc(s.key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer"></div>`;const type = s.type === 'number' ? 'number' : (sensitive ? 'password' : 'text');return `<div class="setting-row" style="flex-direction:column;align-items:stretch">-- <label><strong>${esc(s.key)}</strong><br><small>${esc(s.description||'')}</small></label>-- <input type="${type}" data-key="${esc(s.key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}"++ <label for="${esc(id)}"><strong>${esc(s.key)}</strong><br><small>${esc(s.description||'')}</small></label>++ <input type="${type}" id="${esc(id)}" data-key="${esc(s.key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}" ${sensitive ? 'autocomplete="off"' : ''}style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px"></div>`;};@@@ -45,14 +72,14 @@ async function loadPayments() {<fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px"><legend style="padding:0 8px;font-weight:700">${esc(g.title)}</legend>${g.keys.map(k => field(byKey[k])).join('')}-- ${PAYMENT_WEBHOOKS[g.title] ? `<p class="muted" style="font-size:12px;margin-top:8px"><i class="fa-solid fa-link"></i> Webhook URL: <code>${esc(PAYMENT_WEBHOOKS[g.title])}</code></p>` : ''}</fieldset>`).join('');c.innerHTML = `-- <div style="max-width:760px">-- <p class="muted" style="margin-bottom:12px">Configure real-money payments for Vibes & channel subscriptions. Nothing goes live until <strong>payments_enabled</strong> is on and the provider is enabled with valid keys. Set each provider's webhook to the URL shown, and add the redirect/return URLs from OpenVibe.Live.</p>++ <div style="max-width:860px">++ <p class="muted" style="margin-bottom:12px">OpenVibe.Billing runs every checkout, subscription and cashout. These are the switches and prices OpenVibe.Live reads to decide which ways to pay it offers: nothing is offered until <strong>payments_enabled</strong> and the provider are on. Live also checks that <strong>stripe_secret_key</strong> and <strong>crypto_api_key</strong> are filled in before it offers Stripe or crypto; the keys Billing charges with are in its environment (below).</p><form id="payments-form">${groups}-- <button type="button" class="btn btn-primary" onclick="savePayments()"><i class="fa-solid fa-floppy-disk"></i> Save Payment Settings</button>++ <button type="button" class="btn btn-primary" onclick="savePayments()"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Save payment settings</button></form>++ <div style="margin-top:20px">${billingProvidersCard()}</div></div>`;} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }}@@@ -69,53 +96,8 @@ async function savePayments() {} catch (e) { toast(e.message, 'error'); }}--// ═══════════════════════════════════════════════════════════════--// Cashouts (via funds proxy)--// ═══════════════════════════════════════════════════════════════--async function loadCashouts() {-- const c = document.getElementById('cashouts-content');-- c.innerHTML = '<div class="loading">Loading...</div>';-- try {-- const data = await api('/api/admin/streamer-funds/cashouts/pending');-- const cashouts = data.cashouts || [];-- if (!cashouts.length) { c.innerHTML = '<p class="muted">No pending cashouts</p>'; return; }-- c.innerHTML = `-- <table class="admin-table">-- <thead><tr><th>User</th><th>Amount</th><th>USD</th><th>PayPal</th><th>Requested</th><th>Actions</th></tr></thead>-- <tbody>${cashouts.map(co => `-- <tr>-- <td><strong>${esc(co.username || '-')}</strong></td>-- <td>${Number(co.amount || 0).toLocaleString()} CF</td>-- <td>$${((Number(co.amount) || 0) * 0.01).toFixed(2)}</td>-- <td>${esc(co.paypal_email || '-')}</td>-- <td>${co.created_at ? timeAgo(co.created_at) : '-'}</td>-- <td style="display:flex;gap:6px">-- <button class="btn btn-sm btn-success" onclick="approveCashout('${co.id}')"><i class="fa-solid fa-check"></i> Approve</button>-- <button class="btn btn-sm btn-danger" onclick="denyCashout('${co.id}')"><i class="fa-solid fa-xmark"></i> Deny</button>-- </td>-- </tr>-- `).join('')}</tbody>-- </table>`;-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }--}----async function approveCashout(id) {-- try {-- await api(`/api/admin/streamer-funds/cashout/${id}/approve`, { method: 'POST' });-- toast('Cashout approved', 'success');-- loadCashouts();-- } catch (e) { toast(e.message, 'error'); }--}----async function denyCashout(id) {-- const reason = prompt('Denial reason:');-- if (reason === null) return;-- try {-- await api(`/api/admin/streamer-funds/cashout/${id}/deny`, { method: 'POST', body: { reason } });-- toast('Cashout denied', 'info');-- loadCashouts();-- } catch (e) { toast(e.message, 'error'); }--}++// Cashouts: the tab is a pointer to OpenVibe.Billing's console (admin.html #sec-cashouts); Live's old cashout queue++// (/api/funds) answers 409 since the Billing cutover.// ═══════════════════════════════════════════════════════════════// Verification Keys@@@ -39,9 +39,11 @@ const AREAS = [{ area: 'Moderation, money and loyalty', items: [{ item: 'Moderation audit across services', where: [{ kind: 'tab', tab: 'modlog' }], service: 'network' },{ item: 'Chat logs and chat moderation', where: [{ kind: 'tab', tab: 'chat-logs' }], service: 'chat' },++ { item: 'Messages held for IP approval (was the VPN queue)', where: [{ kind: 'url', url: 'https://openvibe.live/dashboard/moderation', note: 'per channel: the streamer and channel mods review them' }], service: 'chat' },++ { item: 'Text-to-speech settings', where: [{ kind: 'tab', tab: 'tts' }], service: 'chat' },{ item: 'Community moderation', where: [{ kind: 'api', api: 'OpenVibe.Community staff routes', note: 'no console yet' }], service: 'community' },{ item: 'Space forum moderation', where: [{ kind: 'api', api: 'OpenVibe.Space forum moderator routes', note: 'no console yet' }], service: 'space' },-- { item: 'Payments, cashouts and the money freeze', where: [{ kind: 'url', url: 'https://billing.openvibe.network/', note: 'Billing console' }, { kind: 'tab', tab: 'cashouts' }, { kind: 'tab', tab: 'payments' }], service: 'billing' },++ { item: 'Payments, cashouts and the money freeze', where: [{ kind: 'url', url: 'https://billing.openvibe.network/', note: 'Billing console: cashouts, receipts, the freeze' }, { kind: 'tab', tab: 'payments', note: 'what Live offers and its prices; provider webhooks' }], service: 'billing' },{ item: 'Loyalty (OpenCoins, channel points)', where: [{ kind: 'api', api: 'live.loyalty user module; Network /internal/coins/*', note: 'summaries only; loyalty is never money' }], service: 'network' },{ item: 'Developer apps and trust (Services)', where: [{ kind: 'url', url: 'https://openvibe.services/staff' }], service: 'services' },] },nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++/**++ * The admin panel's service proxies (public/js/admin/*): each forwards to the service that owns the job, as the++ * signed-in staff member. The token sent on is re-minted from their current record, so a role granted a moment ago++ * counts upstream too; every service checks the person's role again.++ *++ * /api/admin/streamer/* → Live /api/admin/* admin++ * /api/admin/streamer-mod/* → Live /api/mod/* admin or global mod++ * /api/admin/streamer-tts/* → Chat /api/tts/* admin (Chat serves TTS since the chat cutover; it keeps the++ * provider credentials owner-only)++ * /api/admin/streamer-pastes/* → Live /api/pastes/* admin (Live forwards to OpenVibe.Community, which owns pastes)++ * /api/admin/streamer-funds/* 410: cashouts are decided in OpenVibe.Billing's console, not here++ *++ * test/admin-service-proxies.test.js++ */++const express = require('express');++++const BILLING_CONSOLE = 'https://billing.openvibe.network';++++/** The JSON (or problem+json) types passed through as they are; anything else goes back as plain text. */++const JSON_TYPE = /^application\/([a-z.+-]+\+)?json\b/i;++++/**++ * Forward the request to `targetUrl` with the staff member's token and send back the upstream status and body.++ * `service` names the upstream in the 502 when it cannot be reached.++ */++async function proxyJsonRequest(req, res, targetUrl, { label, service, signToken }) {++ try {++ let upstreamToken = req.token;++ try {++ if (req.user && req.app.locals.privateKey && typeof signToken === 'function') {++ upstreamToken = signToken(req.user, req.app.locals.privateKey, req.app.locals.config, { renew: req.tokenClaims || {} });++ }++ } catch (e) {++ console.error('[AdminProxy] token re-mint failed, forwarding original:', e.message);++ }++ const fetchOpts = {++ method: req.method,++ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${upstreamToken}` },++ };++ if (req.method !== 'GET' && req.method !== 'HEAD' && req.body) fetchOpts.body = JSON.stringify(req.body);++++ const upstream = await fetch(targetUrl, fetchOpts);++ const contentType = upstream.headers.get('content-type') || '';++ const raw = await upstream.text();++ res.status(upstream.status);++ if (JSON_TYPE.test(contentType)) return res.type(contentType).send(raw || '{}');++ return res.type('text/plain').send(raw);++ } catch (err) {++ console.error(`[AdminProxy] ${label}:`, err.message);++ return res.status(502).json({ error: `Could not reach ${service}` });++ }++}++++const allow = (roles, error) => (req, res, next) => (req.user && roles.includes(req.user.role) ? next() : res.status(403).json({ error }));++++/**++ * The proxies as one router, mounted at /api/admin. `services` is config.services (each service's internalUrl, read++ * on every request); `signToken` is auth/routes' (it re-mints the staff member's token).++ */++function createServiceProxies({ requireAuth, signToken, services }) {++ const r = express.Router();++ const admin = allow(['admin'], 'Admin access required');++ const staff = allow(['admin', 'global_mod'], 'Staff access required');++ const to = (base, prefix, service, label) => async (req, res) => await proxyJsonRequest(req, res, `${base()}${prefix}${req.url}`, { label, service, signToken });++ const live = () => services.live.internalUrl;++ const chat = () => services.chat.internalUrl;++++ r.use('/streamer', requireAuth, admin, to(live, '/api/admin', 'OpenVibe.Live', 'Streamer proxy error'));++ r.use('/streamer-mod', requireAuth, staff, to(live, '/api/mod', 'OpenVibe.Live', 'Mod proxy error'));++ r.use('/streamer-tts', requireAuth, admin, to(chat, '/api/tts', 'OpenVibe.Chat', 'TTS proxy error'));++ r.use('/streamer-pastes', requireAuth, admin, to(live, '/api/pastes', 'OpenVibe.Live', 'Pastes proxy error'));++ // Live's /api/funds cashout admin is gone (Billing is the only money ledger since 2026-10-17).++ r.use('/streamer-funds', requireAuth, admin, (req, res) => res.status(410).json({++ error: 'Cashouts are decided in OpenVibe.Billing\'s console now',++ console: `${BILLING_CONSOLE}/cashouts`,++ }));++ return r;++}++++module.exports = { createServiceProxies, proxyJsonRequest, BILLING_CONSOLE };@@@ -117,5 +117,9 @@ module.exports = {media: {internalUrl: process.env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100',},++ // OpenVibe.Chat: the admin panel's TTS settings (server/admin/service-proxies.js).++ chat: {++ internalUrl: process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400',++ },},};@@@ -87,45 +87,6 @@ function sendLandingPage(res) {return res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));}--async function proxyJsonRequest(req, res, targetUrl, errorLabel) {-- try {-- // Re-mint a fresh token from the user's CURRENT server-side record so that-- // a just-granted role (e.g. admin) propagates to upstream services-- // immediately, instead of relying on the client's possibly-stale token.-- let upstreamToken = req.token;-- try {-- if (req.user && req.app.locals.privateKey) {-- upstreamToken = signToken(req.user, req.app.locals.privateKey, req.app.locals.config, { renew: req.tokenClaims || {} });-- }-- } catch (e) {-- console.error('[AdminProxy] token re-mint failed, forwarding original:', e.message);-- }-- const fetchOpts = {-- method: req.method,-- headers: {-- 'Content-Type': 'application/json',-- 'Authorization': `Bearer ${upstreamToken}`,-- },-- };-- if (req.method !== 'GET' && req.method !== 'HEAD' && req.body) {-- fetchOpts.body = JSON.stringify(req.body);-- }---- const upstream = await fetch(targetUrl, fetchOpts);-- const contentType = upstream.headers.get('content-type') || '';-- const raw = await upstream.text();---- res.status(upstream.status);-- if (contentType.includes('application/json')) {-- return res.json(raw ? JSON.parse(raw) : {});-- }-- return res.send(raw);-- } catch (err) {-- console.error(`[AdminProxy] ${errorLabel}:`, err.message);-- return res.status(502).json({ error: 'Could not reach OpenVibe.Live service' });-- }--}--// ── Security ─────────────────────────────────────────────────app.set('trust proxy', 2); // Cloudflare → Nginx → Node@@@ -753,59 +714,9 @@ app.use('/api/admin', createAdminRoutes(db, notificationService, emailService, rconst createAnalyticsRoutes = require('./admin/analytics-routes');app.use('/api/admin/analytics', createAnalyticsRoutes(analytics, requireAuth, config, { selfToken }));--// ── Admin Proxy to OpenVibe.Live ──────────────────────────────--// Proxies /api/admin/streamer/* → openvibe.live /api/admin/*--// This lets the unified admin panel on openvibe.network manage OpenVibe.Live features.--const OPENVIBELIVE_INTERNAL = config.services.live.internalUrl;--app.use('/api/admin/streamer', requireAuth, (req, res, next) => {-- if (!req.user || req.user.role !== 'admin') {-- return res.status(403).json({ error: 'Admin access required' });-- }-- next();--}, async (req, res) => {-- return await proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/admin${req.url}`, 'Streamer proxy error');--});----// Proxy /api/mod/* for moderator routes--app.use('/api/admin/streamer-mod', requireAuth, (req, res, next) => {-- if (!req.user || (req.user.role !== 'admin' && req.user.role !== 'global_mod')) {-- return res.status(403).json({ error: 'Staff access required' });-- }-- next();--}, async (req, res) => {-- return await proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/mod${req.url}`, 'Mod proxy error');--});----app.use('/api/admin/streamer-tts', requireAuth, (req, res, next) => {-- if (!req.user || req.user.role !== 'admin') {-- return res.status(403).json({ error: 'Admin access required' });-- }-- next();--}, async (req, res) => {-- return await proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/tts${req.url}`, 'TTS proxy error');--});----app.use('/api/admin/streamer-funds', requireAuth, (req, res, next) => {-- if (!req.user || req.user.role !== 'admin') {-- return res.status(403).json({ error: 'Admin access required' });-- }-- next();--}, async (req, res) => {-- return await proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/funds${req.url}`, 'Funds proxy error');--});----// Pastes — the admin panel's Pastes tab (public/js/admin/content.js) reads GET /config and /admin/stats--// and calls DELETE /admin/forks through this proxy. The chain is Network → Live → Community:--// /api/admin/streamer-pastes/:rest → openvibe.live /api/pastes/:rest, which checks Live's admin role and--// forwards to OpenVibe.Community's /api/pastes/:rest as Live's service (Community owns pastes).--app.use('/api/admin/streamer-pastes', requireAuth, (req, res, next) => {-- if (!req.user || req.user.role !== 'admin') {-- return res.status(403).json({ error: 'Admin access required' });-- }-- next();--}, async (req, res) => {-- return await proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/pastes${req.url}`, 'Pastes proxy error');--});++// ── Admin panel proxies to the services that own each job (server/admin/service-proxies.js) ──++// /api/admin/streamer(-mod|-pastes)/* → Live, /api/admin/streamer-tts/* → Chat, /api/admin/streamer-funds/* 410 (Billing).++app.use('/api/admin', require('./admin/service-proxies').createServiceProxies({ requireAuth, signToken, services: config.services }));// Internal API (server-to-server, capability-scoped service tokens only)app.use('/internal', require('./internal/routes'));nnew file mode 100644@@@ -0,0 +1,146 @@++'use strict';++// The admin panel's service proxies (server/admin/service-proxies.js) reach the service that owns each job: TTS goes++// to OpenVibe.Chat (Live has no /api/tts since the chat cutover), cashouts answer 410 with Billing's console, the++// Live and pastes proxies still go to Live; each checks the staff role first and forwards the re-minted token.++// The panel's scripts no longer call the retired routes or show Live's old payment webhooks and secrets.++// node test/admin-service-proxies.test.js++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const path = require('path');++const express = require('express');++const { createServiceProxies } = require('../server/admin/service-proxies');++++/** A fake upstream that records each request and answers JSON. */++function upstream(name) {++ const seen = [];++ const srv = http.createServer((req, res) => {++ let body = '';++ req.on('data', (d) => { body += d; });++ req.on('end', () => {++ seen.push({ method: req.method, url: req.url, auth: req.headers.authorization || null, body: body ? JSON.parse(body) : null });++ if (req.url.startsWith('/api/tts/broken')) return res.writeHead(500, { 'content-type': 'application/problem+json' }).end('{"title":"boom"}');++ if (req.url.startsWith('/api/tts/text')) return res.writeHead(200, { 'content-type': 'text/html' }).end('<b>hi</b>');++ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }).end(JSON.stringify({ from: name, url: req.url }));++ });++ });++ return new Promise((resolve) => srv.listen(0, '127.0.0.1', () => resolve({ srv, seen, url: `http://127.0.0.1:${srv.address().port}` })));++}++++(async () => {++ const live = await upstream('live');++ const chat = await upstream('chat');++ const services = { live: { internalUrl: live.url }, chat: { internalUrl: chat.url } };++ // requireAuth stand-in: X-Test-User is the signed-in person; none → 401, as Network's requireAuth answers.++ const requireAuth = (req, res, next) => {++ const u = req.headers['x-test-user'];++ if (!u) return res.status(401).json({ error: 'Authentication required' });++ req.user = JSON.parse(u);++ req.token = `client-token-${req.user.id}`;++ next();++ };++ const signToken = (user) => `minted-${user.id}-${user.role}`;++ const app = express();++ app.locals.privateKey = 'test-key';++ app.locals.config = {};++ app.use(express.json());++ app.use('/api/admin', createServiceProxies({ requireAuth, signToken, services }));++ app.use((req, res) => res.status(404).json({ error: 'Not found' }));++ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++++ const admin = { id: 1, role: 'admin' };++ const mod = { id: 2, role: 'global_mod' };++ const user = { id: 3, role: 'user' };++ async function call(who, method, p, body) {++ const headers = { 'content-type': 'application/json' };++ if (who) headers['x-test-user'] = JSON.stringify(who);++ const r = await fetch(base + p, { method, headers, body: body ? JSON.stringify(body) : undefined });++ const text = await r.text();++ let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ }++ return { status: r.status, type: r.headers.get('content-type') || '', text, json };++ }++ const reset = () => { live.seen.length = 0; chat.seen.length = 0; };++++ try {++ // ── TTS → Chat ───────────────────────────────────────────────++ let r = await call(admin, 'GET', '/api/admin/streamer-tts/admin/settings');++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(r.json, { from: 'chat', url: '/api/tts/admin/settings' }, 'TTS settings come from Chat');++ assert.match(r.type, /^application\/json/);++ assert.strictEqual(chat.seen[0].auth, 'Bearer minted-1-admin', 'Chat gets the staff member\'s re-minted token');++ r = await call(admin, 'GET', '/api/admin/streamer-tts/voices?x=1');++ assert.strictEqual(r.json.url, '/api/tts/voices?x=1', 'the path and query reach Chat as they were');++ r = await call(admin, 'PUT', '/api/admin/streamer-tts/admin/settings', { settings: { tts_enabled: false } });++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(chat.seen.at(-1).body, { settings: { tts_enabled: false } }, 'a save carries its body to Chat');++ assert.strictEqual(chat.seen.at(-1).method, 'PUT');++ r = await call(admin, 'POST', '/api/admin/streamer-tts/admin/test', { voiceId: 'gary' });++ assert.strictEqual(chat.seen.at(-1).url, '/api/tts/admin/test');++ assert.strictEqual(live.seen.length, 0, 'nothing about TTS goes to Live');++ r = await call(admin, 'GET', '/api/admin/streamer-tts/broken');++ assert.strictEqual(r.status, 500, 'an upstream error keeps its status');++ assert.match(r.type, /^application\/problem\+json/, 'problem+json passes through as JSON');++ r = await call(admin, 'GET', '/api/admin/streamer-tts/text');++ assert.match(r.type, /^text\/plain/, 'a non-JSON answer is sent back as plain text, never as HTML on Network\'s origin');++++ // Who may: admins only; no session → 401; nothing forwarded for a refusal.++ reset();++ assert.strictEqual((await call(null, 'GET', '/api/admin/streamer-tts/admin/settings')).status, 401);++ assert.strictEqual((await call(user, 'GET', '/api/admin/streamer-tts/admin/settings')).status, 403);++ assert.strictEqual((await call(mod, 'PUT', '/api/admin/streamer-tts/admin/settings', { settings: {} })).status, 403);++ assert.strictEqual(chat.seen.length, 0, 'a refused request never reaches Chat');++++ // ── Cashouts: 410 with Billing's console, nothing forwarded ──++ r = await call(admin, 'GET', '/api/admin/streamer-funds/cashouts/pending');++ assert.strictEqual(r.status, 410);++ assert.strictEqual(r.json.console, 'https://billing.openvibe.network/cashouts');++ assert.match(r.json.error, /Billing/);++ r = await call(admin, 'POST', '/api/admin/streamer-funds/cashout/7/approve');++ assert.strictEqual(r.status, 410);++ assert.strictEqual((await call(null, 'GET', '/api/admin/streamer-funds/cashouts/pending')).status, 401);++ assert.strictEqual((await call(user, 'GET', '/api/admin/streamer-funds/cashouts/pending')).status, 403);++ assert.strictEqual(live.seen.length + chat.seen.length, 0, 'cashouts reach neither Live nor Chat');++++ // ── Live and pastes proxies unchanged ────────────────────────++ r = await call(admin, 'GET', '/api/admin/streamer/settings');++ assert.deepStrictEqual(r.json, { from: 'live', url: '/api/admin/settings' });++ r = await call(admin, 'GET', '/api/admin/streamer-pastes/config');++ assert.deepStrictEqual(r.json, { from: 'live', url: '/api/pastes/config' });++ r = await call(mod, 'GET', '/api/admin/streamer-mod/reports');++ assert.deepStrictEqual(r.json, { from: 'live', url: '/api/mod/reports' }, 'global mods use the mod proxy');++ assert.strictEqual((await call(mod, 'GET', '/api/admin/streamer/settings')).status, 403, 'the Live admin proxy is admins only');++ assert.strictEqual((await call(user, 'GET', '/api/admin/streamer-mod/reports')).status, 403);++ // Other /api/admin paths are not the proxies' business.++ assert.strictEqual((await call(null, 'GET', '/api/admin/streamerx')).status, 404);++++ // ── A service that is down: 502 naming it ────────────────────++ await new Promise((resolve) => chat.srv.close(resolve));++ r = await call(admin, 'GET', '/api/admin/streamer-tts/admin/settings');++ assert.strictEqual(r.status, 502);++ assert.strictEqual(r.json.error, 'Could not reach OpenVibe.Chat');++ } finally {++ server.close();++ live.srv.close();++ if (chat.srv.listening) chat.srv.close();++ }++++ // ── The panel's scripts ───────────────────────────────────────────++ const read = (f) => fs.readFileSync(path.join(__dirname, '..', 'public', f), 'utf8');++ const panel = ['admin.html', ...fs.readdirSync(path.join(__dirname, '..', 'public', 'js', 'admin')).map((f) => `js/admin/${f}`)].map(read).join('\n');++ for (const gone of ['/api/admin/streamer-funds', '/api/admin/streamer/vpn-queue', 'openvibe.live/api/payments/webhook', "showTab('vpn'"]) {++ assert.ok(!panel.includes(gone), `the admin panel no longer uses ${gone}`);++ }++ const payments = read('js/admin/payments.js');++ for (const key of ['paypal_client_secret', 'paypal_webhook_id', 'stripe_webhook_secret', 'ccbill_salt', 'ccbill_webhook_secret', 'crypto_ipn_secret']) {++ assert.ok(!payments.includes(`'${key}'`), `the Payments form no longer saves ${key} into Live's settings (Billing reads its environment)`);++ }++ for (const hook of ['stripe', 'paypal', 'ccbill', 'nowpayments', 'powerchat']) assert.ok(payments.includes(`'/webhooks/${hook}`), `Billing's ${hook} webhook is shown`);++ assert.ok(payments.includes("'https://billing.openvibe.network'"), 'webhooks are on Billing\'s origin');++ const content = read('js/admin/content.js');++ assert.ok(!/paste_(max|cooldown|anon|image|screenshot)/.test(content), 'the Pastes tab saves no paste_* settings nobody reads');++ assert.ok(content.includes("/api/admin/streamer-pastes/config"), 'the Pastes tab shows Community\'s limits');++ assert.ok(read('admin.html').includes('https://billing.openvibe.network/cashouts'), 'the Cashouts tab points to Billing\'s console');++++ console.log('admin service proxies: all checks passed');++})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });@@@ -35,7 +35,7 @@ async function bootServer({ env = {}, child = false, timeoutMs = 60000 } = {}) {BOOTSTRAP_PROFILE: 'local-dev',OV_NETWORK_INTERNAL_URL: `http://127.0.0.1:${port}`,OV_LIVE_INTERNAL_URL: 'http://127.0.0.1:9', OV_TOOLS_INTERNAL_URL: 'http://127.0.0.1:9',-- OV_GAMES_INTERNAL_URL: 'http://127.0.0.1:9', OV_MEDIA_INTERNAL_URL: 'http://127.0.0.1:9',++ OV_GAMES_INTERNAL_URL: 'http://127.0.0.1:9', OV_MEDIA_INTERNAL_URL: 'http://127.0.0.1:9', OV_CHAT_INTERNAL_URL: 'http://127.0.0.1:9',OV_AI_INTERNAL_URL: 'http://127.0.0.1:9',};@@@ -120,7 +120,7 @@ const REVIEWED = {'server/discord/discord-service.js discord.js': [1, 'discord.js login: Discord gateway only'],'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],'server/frame/service.js fetch': [2, 'Live and AI internal URLs from config (navbar ranking copy)'],-- 'server/index.js fetch': [1, 'the admin streamer proxy: Live\'s internal URL from config, fixed path prefixes'],++ 'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],'server/profile/public.js fetch': [3, 'OpenVibe.Inventory (OV_INVENTORY_INTERNAL_URL, loopback 5030) and OpenVibe.Quest (OV_QUEST_INTERNAL_URL, loopback 4980) only; the paths are fixed, the subject is a checked usr_ id'],
@@ -86,6 +86,7 @@ OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
@@ -86,6 +86,7 @@ OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
OV_TOOLS_INTERNAL_URL=http://127.0.0.1:4001
OV_TOOLS_INTERNAL_URL=http://127.0.0.1:4001
OV_GAMES_INTERNAL_URL=http://127.0.0.1:8000
OV_GAMES_INTERNAL_URL=http://127.0.0.1:8000
OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
+OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
# Optional: SSH admin-tab context (non-secret display values)
# Optional: SSH admin-tab context (non-secret display values)
#SSH_SERVER_HOST=
#SSH_SERVER_HOST=
@@ -48,7 +48,6 @@
@@ -48,7 +48,6 @@
<button class="admin-tab-btn owner-only" onclick="showTab('cashouts',this)"><i class="fa-solid fa-money-bill-transfer"></i> Cashouts</button>
<button class="admin-tab-btn owner-only" onclick="showTab('cashouts',this)"><i class="fa-solid fa-money-bill-transfer"></i> Cashouts</button>
<button class="admin-tab-btn owner-only" onclick="showTab('payments',this)"><i class="fa-solid fa-credit-card"></i> Payments</button>
<button class="admin-tab-btn owner-only" onclick="showTab('payments',this)"><i class="fa-solid fa-credit-card"></i> Payments</button>
<button class="admin-tab-btn" onclick="showTab('ai',this)"><i class="fa-solid fa-robot"></i> AI</button>
<button class="admin-tab-btn" onclick="showTab('ai',this)"><i class="fa-solid fa-robot"></i> AI</button>
- <button class="admin-tab-btn" onclick="showTab('vpn',this)"><i class="fa-solid fa-user-shield"></i> VPN Queue</button>
<button class="admin-tab-btn" onclick="showTab('pastes',this)"><i class="fa-solid fa-paste"></i> Pastes</button>
<button class="admin-tab-btn" onclick="showTab('pastes',this)"><i class="fa-solid fa-paste"></i> Pastes</button>
<button class="admin-tab-btn owner-only" onclick="showTab('storage',this)"><i class="fa-solid fa-hard-drive"></i> Storage</button>
<button class="admin-tab-btn owner-only" onclick="showTab('storage',this)"><i class="fa-solid fa-hard-drive"></i> Storage</button>
<button class="admin-tab-btn" onclick="showTab('notifications',this)"><i class="fa-solid fa-bell"></i> Notifications</button>
<button class="admin-tab-btn" onclick="showTab('notifications',this)"><i class="fa-solid fa-bell"></i> Notifications</button>
@@ -150,12 +149,20 @@
@@ -150,12 +149,20 @@
<div id="tts-content"><div class="loading">Loading...</div></div>
<div id="tts-content"><div class="loading">Loading...</div></div>
</div>
</div>
- <!-- Cashouts -->
+ <!-- Cashouts: decided in OpenVibe.Billing's staff console (Billing is the only money ledger since 2026-10-17) -->
<div class="section" id="sec-cashouts">
<div class="section" id="sec-cashouts">
- <div id="cashouts-content"><div class="loading">Loading...</div></div>
+ <div class="card" style="max-width:760px">
+ <h3><i class="fa-solid fa-money-bill-transfer" aria-hidden="true"></i> Cashouts are decided in OpenVibe.Billing</h3>
+ <p class="muted" style="margin-bottom:8px">Billing holds every creator's balance. Its staff console lists cashouts still in escrow, ready to pay, paid and denied. Approving one records the payout reference; denying one puts the amount back in the creator's balance. The money freeze is there too.</p>
+ <p class="muted">Sign in with your OpenVibe account. Billing lets in the admins listed in its <code>BILLING_STAFF_SUBJECTS</code> setting.</p>
+ <div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:16px">
+ <a class="btn btn-primary" href="https://billing.openvibe.network/cashouts" target="_blank" rel="noopener"><i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i> Open cashouts in Billing</a>
+ <a class="btn btn-outline" href="https://billing.openvibe.network/freeze" target="_blank" rel="noopener"><i class="fa-solid fa-snowflake" aria-hidden="true"></i> Money freeze</a>
+ </div>
+ </div>
</div>
</div>
- <!-- Payments: configure PayPal / Stripe / CCBill / crypto + subscription pricing -->
+ <!-- Payments: the switches and prices Live reads; provider webhooks and credentials are OpenVibe.Billing's -->
<div class="section" id="sec-payments">
<div class="section" id="sec-payments">
<div id="payments-content"><div class="loading">Loading...</div></div>
<div id="payments-content"><div class="loading">Loading...</div></div>
</div>
</div>
@@ -165,11 +172,6 @@
@@ -165,11 +172,6 @@
<div id="ai-content"><div class="loading">Loading...</div></div>
<div id="ai-content"><div class="loading">Loading...</div></div>
</div>
</div>
- <!-- VPN Queue -->
- <div class="section" id="sec-vpn">
- <div id="vpn-content"><div class="loading">Loading...</div></div>
- </div>
-
<!-- Pastes -->
<!-- Pastes -->
<div class="section" id="sec-pastes">
<div class="section" id="sec-pastes">
<div id="pastes-content"><div class="loading">Loading...</div></div>
<div id="pastes-content"><div class="loading">Loading...</div></div>
@@ -74,6 +74,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
@@ -74,6 +74,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
.btn-success{background:rgba(46,204,113,0.1);color:var(--success);border:1px solid rgba(46,204,113,0.2)}
.btn-success{background:rgba(46,204,113,0.1);color:var(--success);border:1px solid rgba(46,204,113,0.2)}
.btn-success:hover{background:rgba(46,204,113,0.2)}
.btn-success:hover{background:rgba(46,204,113,0.2)}
.btn-sm{padding:5px 10px;font-size:11px}
.btn-sm{padding:5px 10px;font-size:11px}
+a.btn{text-decoration:none}
.btn:disabled{opacity:.4;cursor:not-allowed}
.btn:disabled{opacity:.4;cursor:not-allowed}
/* Form fields */
/* Form fields */
@@ -84,6 +85,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
@@ -84,6 +85,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
border-radius:8px;color:var(--text);font-size:13px;outline:none;transition:border-color .2s;
border-radius:8px;color:var(--text);font-size:13px;outline:none;transition:border-color .2s;
}
}
.form-field input:focus,.form-field select:focus,.form-field textarea:focus{border-color:var(--accent)}
.form-field input:focus,.form-field select:focus,.form-field textarea:focus{border-color:var(--accent)}
+.form-field input:disabled,.form-field select:disabled,.form-field textarea:disabled{opacity:.55;cursor:not-allowed}
/* Input inline */
/* Input inline */
.input-row{display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap}
.input-row{display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap}
@@ -153,6 +155,6 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
@@ -153,6 +155,6 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
.tabs-scroll-btn{width:30px;height:30px;flex-basis:30px;border-radius:8px}
.tabs-scroll-btn{width:30px;height:30px;flex-basis:30px;border-radius:8px}
}
}
.ssh-prompt{background:var(--bg-elev,#111);border:1px solid var(--border,#2a2a2a);border-radius:8px;padding:14px;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.8rem;line-height:1.55;color:var(--text,#ddd);max-height:520px;overflow:auto;margin:0}
.ssh-prompt{background:var(--bg-elev,#111);border:1px solid var(--border,#2a2a2a);border-radius:8px;padding:14px;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.8rem;line-height:1.55;color:var(--text,#ddd);max-height:520px;overflow:auto;margin:0}
-#sec-ssh code{background:var(--bg-elev,#111);border:1px solid var(--border,#2a2a2a);border-radius:5px;padding:2px 7px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.8rem;display:inline-block;margin-top:3px;word-break:break-all}
+#sec-ssh code,#sec-payments code,#sec-cashouts code{background:var(--bg-elev,#111);border:1px solid var(--border,#2a2a2a);border-radius:5px;padding:2px 7px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.8rem;display:inline-block;margin-top:3px;word-break:break-all}
#sec-ssh .staff-pill{font-size:0.62rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:3px 8px;border-radius:999px;vertical-align:middle;margin-left:8px;color:var(--warning,#f0b74a);background:color-mix(in srgb,var(--warning,#f0b74a) 18%,transparent);border:1px solid color-mix(in srgb,var(--warning,#f0b74a) 40%,transparent)}
#sec-ssh .staff-pill{font-size:0.62rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:3px 8px;border-radius:999px;vertical-align:middle;margin-left:8px;color:var(--warning,#f0b74a);background:color-mix(in srgb,var(--warning,#f0b74a) 18%,transparent);border:1px solid color-mix(in srgb,var(--warning,#f0b74a) 40%,transparent)}
#sec-ssh .staff-pill.user-pill{color:var(--accent,#6ea8ff);background:color-mix(in srgb,var(--accent,#6ea8ff) 18%,transparent);border-color:color-mix(in srgb,var(--accent,#6ea8ff) 40%,transparent)}
#sec-ssh .staff-pill.user-pill{color:var(--accent,#6ea8ff);background:color-mix(in srgb,var(--accent,#6ea8ff) 18%,transparent);border-color:color-mix(in srgb,var(--accent,#6ea8ff) 40%,transparent)}
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
- OpenVibe.Network — admin panel (/admin): AI, AI viewers, streamer media tools and TTS.
+ OpenVibe.Network — admin panel (/admin): AI, AI viewers, streamer media tools and TTS (OpenVibe.Chat).
Split out of admin.html. Classic scripts, global scope: they rely on
Split out of admin.html. Classic scripts, global scope: they rely on
admin/core.js's helpers (api, esc, toast, API, token) and on the
admin/core.js's helpers (api, esc, toast, API, token) and on the
tabLoaders map in admin/boot.js (loaded last). No ES modules.
tabLoaders map in admin/boot.js (loaded last). No ES modules.
@@ -404,7 +404,9 @@ async function testStreamerMediaExtraction() {
@@ -404,7 +404,9 @@ async function testStreamerMediaExtraction() {
}
}
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
-// TTS (via openvibelive TTS proxy)
+// TTS: OpenVibe.Chat's /api/tts admin routes, through the admin proxy (/api/admin/streamer-tts → Chat). Chat checks
+// the admin role again and keeps the provider credentials owner-only (it masks them for other admins and ignores
+// them in their saves); the settings themselves are stored with Live's site settings.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
async function loadTTS() {
async function loadTTS() {
const c = document.getElementById('tts-content');
const c = document.getElementById('tts-content');
@@ -415,87 +417,103 @@ async function loadTTS() {
@@ -415,87 +417,103 @@ async function loadTTS() {
api('/api/admin/streamer-tts/voices'),
api('/api/admin/streamer-tts/voices'),
]);
]);
const s = settingsRes.settings || {};
const s = settingsRes.settings || {};
+ const locked = !!s._secretsRedacted; // not the owner: credentials are shown masked and cannot be changed
const voices = voicesRes.voices || [];
const voices = voicesRes.voices || [];
const byEngine = (engine) => voices.filter(v => v.engine === engine);
const byEngine = (engine) => voices.filter(v => v.engine === engine);
const voiceOptions = (list) => list.map(v => `<option value="${esc(v.id)}" ${v.id === s.defaultVoice ? 'selected' : ''}>${esc(v.name)} (${esc(v.rarity || 'standard')})${v.available ? '' : ' ⚠ unavailable'}</option>`).join('');
const voiceOptions = (list) => list.map(v => `<option value="${esc(v.id)}" ${v.id === s.defaultVoice ? 'selected' : ''}>${esc(v.name)} (${esc(v.rarity || 'standard')})${v.available ? '' : ' ⚠ unavailable'}</option>`).join('');
+ const secret = (id, label, value) => `<div class="form-field"><label for="${id}">${label}</label><input type="password" id="${id}" value="${esc(value || '')}" autocomplete="off"${locked ? ' disabled' : ''}></div>`;
+ const ownerNote = locked ? '<p class="muted" style="margin-bottom:12px"><i class="fa-solid fa-lock" aria-hidden="true"></i> Only the owner can see or change provider credentials.</p>' : '';
c.innerHTML = `
c.innerHTML = `
<form id="tts-form" style="display:grid;gap:16px;max-width:760px">
<form id="tts-form" style="display:grid;gap:16px;max-width:760px">
+ <p class="muted">Text-to-speech in chat runs in OpenVibe.Chat. These settings apply to every channel.</p>
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-sliders"></i> TTS Configuration</h3>
- <div class="setting-row"><label><strong>Enabled</strong><br><small>Allow TTS requests on OpenVibe.Live.</small></label><input type="checkbox" id="tts-enabled" ${s.enabled ? 'checked' : ''} style="width:18px;height:18px"></div>
- <div class="form-field"><label>Provider</label><select id="tts-provider"><option value="espeak-ng" ${s.provider === 'espeak-ng' ? 'selected' : ''}>espeak-ng</option><option value="google-cloud" ${s.provider === 'google-cloud' ? 'selected' : ''}>Google Cloud</option><option value="amazon-polly" ${s.provider === 'amazon-polly' ? 'selected' : ''}>Amazon Polly</option></select></div>
- <div class="form-field"><label>Default Voice</label><select id="tts-default-voice"><optgroup label="espeak-ng">${voiceOptions(byEngine('espeak-ng'))}</optgroup><optgroup label="Google Cloud">${voiceOptions(byEngine('google-cloud'))}</optgroup><optgroup label="Amazon Polly">${voiceOptions(byEngine('amazon-polly'))}</optgroup></select></div>
- <div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px">
- <div class="form-field"><label>Max Message Length</label><input type="number" id="tts-max-length" value="${esc(s.maxLength || 200)}"></div>
- <div class="form-field"><label>Max Queue / User</label><input type="number" id="tts-max-per-user" value="${esc(s.maxQueuePerUser || 3)}"></div>
- <div class="form-field"><label>Max Global Queue</label><input type="number" id="tts-max-global" value="${esc(s.maxQueueGlobal || 20)}"></div>
+ <h3><i class="fa-solid fa-sliders" aria-hidden="true"></i> TTS Configuration</h3>
+ <div class="setting-row"><label for="tts-enabled"><strong>Enabled</strong><br><small>Allow text-to-speech messages in chat.</small></label><input type="checkbox" id="tts-enabled" ${s.enabled ? 'checked' : ''} style="width:18px;height:18px"></div>
+ <div class="form-field"><label for="tts-provider">Provider</label><select id="tts-provider"><option value="espeak-ng" ${s.provider === 'espeak-ng' ? 'selected' : ''}>espeak-ng</option><option value="google-cloud" ${s.provider === 'google-cloud' ? 'selected' : ''}>Google Cloud</option><option value="amazon-polly" ${s.provider === 'amazon-polly' ? 'selected' : ''}>Amazon Polly</option></select></div>
+ <div class="form-field"><label for="tts-default-voice">Default Voice</label><select id="tts-default-voice"><optgroup label="espeak-ng">${voiceOptions(byEngine('espeak-ng'))}</optgroup><optgroup label="Google Cloud">${voiceOptions(byEngine('google-cloud'))}</optgroup><optgroup label="Amazon Polly">${voiceOptions(byEngine('amazon-polly'))}</optgroup></select></div>
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px">
+ <div class="form-field"><label for="tts-max-length">Max Message Length</label><input type="number" id="tts-max-length" value="${esc(s.maxLength || 200)}"></div>
+ <div class="form-field"><label for="tts-max-per-user">Max Queue / User</label><input type="number" id="tts-max-per-user" value="${esc(s.maxQueuePerUser || 3)}"></div>
+ <div class="form-field"><label for="tts-max-global">Max Global Queue</label><input type="number" id="tts-max-global" value="${esc(s.maxQueueGlobal || 20)}"></div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card">
- <h3><i class="fa-brands fa-google"></i> Google Cloud</h3>
- <div class="form-field"><label>API Key</label><input type="password" id="tts-google-api-key" value="${esc(s.googleApiKey || '')}" autocomplete="off"></div>
- <div class="form-field"><label>Service Account JSON</label><textarea id="tts-google-sa" rows="4" style="resize:vertical">${esc(s.googleServiceAccount || '')}</textarea></div>
+ <h3><i class="fa-brands fa-google" aria-hidden="true"></i> Google Cloud</h3>
+ ${ownerNote}
+ ${secret('tts-google-api-key', 'API Key', s.googleApiKey)}
+ <div class="form-field"><label for="tts-google-sa">Service Account JSON</label><textarea id="tts-google-sa" rows="4" style="resize:vertical"${locked ? ' disabled' : ''}>${esc(s.googleServiceAccount || '')}</textarea></div>
</div>
</div>
<div class="card">
<div class="card">
- <h3><i class="fa-brands fa-aws"></i> Amazon Polly</h3>
- <div class="form-field"><label>AWS Access Key ID</label><input type="password" id="tts-aws-key" value="${esc(s.awsAccessKeyId || '')}" autocomplete="off"></div>
- <div class="form-field"><label>AWS Secret Access Key</label><input type="password" id="tts-aws-secret" value="${esc(s.awsSecretAccessKey || '')}" autocomplete="off"></div>
- <div class="form-field"><label>AWS Region</label><input type="text" id="tts-aws-region" value="${esc(s.awsRegion || 'us-east-1')}"></div>
+ <h3><i class="fa-brands fa-aws" aria-hidden="true"></i> Amazon Polly</h3>
+ ${ownerNote}
+ ${secret('tts-aws-key', 'AWS Access Key ID', s.awsAccessKeyId)}
+ ${secret('tts-aws-secret', 'AWS Secret Access Key', s.awsSecretAccessKey)}
+ <div class="form-field"><label for="tts-aws-region">AWS Region</label><input type="text" id="tts-aws-region" value="${esc(s.awsRegion || 'us-east-1')}"></div>
</div>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap">
<div style="display:flex;gap:10px;flex-wrap:wrap">
- <button type="button" class="btn btn-primary" onclick="saveTTS()"><i class="fa-solid fa-floppy-disk"></i> Save TTS Settings</button>
- <button type="button" class="btn btn-outline" onclick="testTTSVoice()"><i class="fa-solid fa-volume-high"></i> Test Voice</button>
+ <button type="button" class="btn btn-primary" onclick="saveTTS()"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Save TTS Settings</button>
+ <button type="button" class="btn btn-outline" onclick="testTTSVoice()"><i class="fa-solid fa-volume-high" aria-hidden="true"></i> Test Voice</button>
</div>
</div>
</form>`;
</form>`;
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
}
}
async function saveTTS() {
async function saveTTS() {
- try {
- await api('/api/admin/streamer-tts/admin/settings', {
- method: 'PUT',
- body: {
- settings: {
- tts_enabled: document.getElementById('tts-enabled').checked,
- tts_provider: document.getElementById('tts-provider').value,
- tts_default_voice: document.getElementById('tts-default-voice').value,
- tts_max_length: parseInt(document.getElementById('tts-max-length').value, 10) || 200,
- tts_max_queue_per_user: parseInt(document.getElementById('tts-max-per-user').value, 10) || 3,
- tts_max_queue_global: parseInt(document.getElementById('tts-max-global').value, 10) || 20,
- tts_google_api_key: document.getElementById('tts-google-api-key').value,
- tts_google_service_account: document.getElementById('tts-google-sa').value,
- tts_aws_access_key_id: document.getElementById('tts-aws-key').value,
- tts_aws_secret_access_key: document.getElementById('tts-aws-secret').value,
- tts_aws_region: document.getElementById('tts-aws-region').value || 'us-east-1',
- },
- },
+ const val = (id) => document.getElementById(id).value;
+ const settings = {
+ tts_enabled: document.getElementById('tts-enabled').checked,
+ tts_provider: val('tts-provider'),
+ tts_default_voice: val('tts-default-voice'),
+ tts_max_length: parseInt(val('tts-max-length'), 10) || 200,
+ tts_max_queue_per_user: parseInt(val('tts-max-per-user'), 10) || 3,
+ tts_max_queue_global: parseInt(val('tts-max-global'), 10) || 20,
+ tts_aws_region: val('tts-aws-region') || 'us-east-1',
+ };
+ // Credentials go only from the owner's form (Chat ignores them from anyone else, and never stores a masked value).
+ if (!document.getElementById('tts-aws-secret').disabled) {
+ Object.assign(settings, {
+ tts_google_api_key: val('tts-google-api-key'),
+ tts_google_service_account: val('tts-google-sa'),
+ tts_aws_access_key_id: val('tts-aws-key'),
+ tts_aws_secret_access_key: val('tts-aws-secret'),
});
});
+ }
+ try {
+ await api('/api/admin/streamer-tts/admin/settings', { method: 'PUT', body: { settings } });
toast('TTS settings saved', 'success');
toast('TTS settings saved', 'success');
} catch (e) { toast(e.message, 'error'); }
} catch (e) { toast(e.message, 'error'); }
}
}
+// The test clip plays through Web Audio: this page's Content-Security-Policy (media-src 'self') refuses blob:
+// and data: audio, and the clip URL Chat returns is on Live's origin.
+let _ttsAudioCtx = null;
async function testTTSVoice() {
async function testTTSVoice() {
try {
try {
const voiceId = document.getElementById('tts-default-voice').value;
const voiceId = document.getElementById('tts-default-voice').value;
const result = await api('/api/admin/streamer-tts/admin/test', {
const result = await api('/api/admin/streamer-tts/admin/test', {
method: 'POST',
method: 'POST',
- body: { voiceId, text: 'Hello, this is a OpenVibe.Live voice test from the unified admin panel.' },
+ body: { voiceId, text: 'Hello, this is a voice test from the OpenVibe admin panel.' },
});
});
- if (!result.audio || !result.mimeType) {
+ if (!result.audio) {
toast('No audio returned from the provider', 'warning');
toast('No audio returned from the provider', 'warning');
return;
return;
}
}
const binary = atob(result.audio);
const binary = atob(result.audio);
const bytes = new Uint8Array(binary.length);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
- const url = URL.createObjectURL(new Blob([bytes], { type: result.mimeType }));
- const audio = new Audio(url);
- audio.onended = () => URL.revokeObjectURL(url);
- await audio.play();
+ const Ctx = window.AudioContext || window.webkitAudioContext;
+ if (!Ctx) { toast('This browser cannot play the test clip', 'warning'); return; }
+ _ttsAudioCtx = _ttsAudioCtx || new Ctx();
+ if (_ttsAudioCtx.state === 'suspended') await _ttsAudioCtx.resume();
+ const buffer = await _ttsAudioCtx.decodeAudioData(bytes.buffer);
+ const src = _ttsAudioCtx.createBufferSource();
+ src.buffer = buffer;
+ src.connect(_ttsAudioCtx.destination);
+ src.start();
toast(`Testing ${result.voiceName || voiceId}`, 'info');
toast(`Testing ${result.voiceName || voiceId}`, 'info');
- } catch (e) { toast(e.message, 'error'); }
+ } catch (e) { toast(e.message || 'Could not play the test clip', 'error'); }
}
}
@@ -14,10 +14,9 @@ const tabLoaders = {
@@ -14,10 +14,9 @@ const tabLoaders = {
moderators: loadModerators,
moderators: loadModerators,
settings: loadSettings,
settings: loadSettings,
tts: loadTTS,
tts: loadTTS,
- cashouts: loadCashouts,
+ cashouts: () => {}, // a pointer to OpenVibe.Billing's console
payments: loadPayments,
payments: loadPayments,
ai: loadAi,
ai: loadAi,
- vpn: loadVPNQueue,
pastes: loadPastesAdmin,
pastes: loadPastesAdmin,
storage: loadStorage,
storage: loadStorage,
notifications: () => {},
notifications: () => {},
@@ -1,50 +1,15 @@
@@ -1,50 +1,15 @@
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
- OpenVibe.Network — admin panel (/admin): VPN queue, pastes, storage, VODs, notifications broadcast and email.
+ OpenVibe.Network — admin panel (/admin): pastes, storage, VODs, notifications broadcast and email.
Split out of admin.html. Classic scripts, global scope: they rely on
Split out of admin.html. Classic scripts, global scope: they rely on
admin/core.js's helpers (api, esc, toast, API, token) and on the
admin/core.js's helpers (api, esc, toast, API, token) and on the
tabLoaders map in admin/boot.js (loaded last). No ES modules.
tabLoaders map in admin/boot.js (loaded last). No ES modules.
═══════════════════════════════════════════════════════════════ */
═══════════════════════════════════════════════════════════════ */
-// ═══════════════════════════════════════════════════════════════
-// VPN queue (via admin proxy)
-// ═══════════════════════════════════════════════════════════════
-async function loadVPNQueue() {
- const c = document.getElementById('vpn-content');
- c.innerHTML = '<div class="loading">Loading...</div>';
- try {
- const data = await api('/api/admin/streamer/vpn-queue');
- const queue = data.queue || [];
- if (!queue.length) { c.innerHTML = '<p class="muted">VPN approval queue empty</p>'; return; }
- c.innerHTML = `
- <table class="admin-table">
- <thead><tr><th>User</th><th>IP</th><th>Reason</th><th>Status</th><th>Actions</th></tr></thead>
- <tbody>${queue.map(item => `
- <tr>
- <td><strong>${esc(item.username || item.user_id || '-')}</strong></td>
- <td>${esc(item.ip_address || '-')}</td>
- <td>${esc(item.reason || '-')}</td>
- <td><span class="badge badge-${item.status === 'approved' ? 'success' : item.status === 'denied' ? 'danger' : 'warning'}">${esc(item.status || 'pending')}</span></td>
- <td>${item.status === 'pending' ? `
- <div style="display:flex;gap:6px">
- <button class="btn btn-sm btn-success" onclick="updateVPNStatus('${item.id}','approved')"><i class="fa-solid fa-check"></i> Approve</button>
- <button class="btn btn-sm btn-danger" onclick="updateVPNStatus('${item.id}','denied')"><i class="fa-solid fa-xmark"></i> Deny</button>
- </div>` : '<span class="muted">Handled</span>'}
- </td>
- </tr>
- `).join('')}</tbody>
- </table>`;
- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
-}
-
-async function updateVPNStatus(id, status) {
- try {
- await api(`/api/admin/streamer/vpn-queue/${id}`, { method: 'PUT', body: { status } });
- toast(`VPN request ${status}`, status === 'approved' ? 'success' : 'info');
- loadVPNQueue();
- } catch (e) { toast(e.message, 'error'); }
-}
+// The VPN queue tab is gone: nothing writes Live's vpn_approvals since IP approval moved to OpenVibe.Chat. Held
+// messages are reviewed per channel by the streamer and channel mods (openvibe.live/dashboard/moderation); the
+// operator checklist points there.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
-// Pastes (via pastes proxy + streamer settings)
+// Pastes (OpenVibe.Community, through the pastes proxy: Network → Live → Community)
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
async function loadPastesAdmin() {
async function loadPastesAdmin() {
const c = document.getElementById('pastes-content');
const c = document.getElementById('pastes-content');
@@ -56,50 +21,35 @@ async function loadPastesAdmin() {
@@ -56,50 +21,35 @@ async function loadPastesAdmin() {
]);
]);
const s = statsData.stats || {};
const s = statsData.stats || {};
const cfg = configData || {};
const cfg = configData || {};
+ const n = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—');
+ const limit = (name, value) => `
+ <div class="setting-row">
+ <span style="flex:1"><strong style="font-size:13px">${esc(name)}</strong></span>
+ <span style="font-size:13px">${esc(value)}</span>
+ </div>`;
c.innerHTML = `
c.innerHTML = `
<div class="stats-grid">
<div class="stats-grid">
- <div class="stat-card"><div class="value">${Number(s.total || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-paste"></i> Total Pastes</div></div>
- <div class="stat-card"><div class="value">${Number(s.textPastes || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-code"></i> Text Pastes</div></div>
- <div class="stat-card"><div class="value">${Number(s.screenshots || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-image"></i> Screenshots</div></div>
- <div class="stat-card"><div class="value">${Number(s.forks || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-code-fork"></i> Forks</div></div>
+ <div class="stat-card"><div class="value">${n(s.total || 0)}</div><div class="label"><i class="fa-solid fa-paste" aria-hidden="true"></i> Total Pastes</div></div>
+ <div class="stat-card"><div class="value">${n(s.textPastes || 0)}</div><div class="label"><i class="fa-solid fa-code" aria-hidden="true"></i> Text Pastes</div></div>
+ <div class="stat-card"><div class="value">${n(s.screenshots || 0)}</div><div class="label"><i class="fa-solid fa-image" aria-hidden="true"></i> Screenshots</div></div>
+ <div class="stat-card"><div class="value">${n(s.forks || 0)}</div><div class="label"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Forks</div></div>
</div>
</div>
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-sliders"></i> Paste Limits</h3>
- <div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px">
- <div class="form-field"><label>Max Paste Size (KB)</label><input type="number" id="paste-max-size-kb" value="${esc(cfg.maxSizeKb || 512)}"></div>
- <div class="form-field"><label>Max Image Size (MB)</label><input type="number" id="paste-screenshot-max-mb" value="${esc(cfg.screenshotMaxSizeMb || 8)}"></div>
- <div class="form-field"><label>Cooldown (seconds)</label><input type="number" id="paste-cooldown" value="${esc(cfg.cooldownSeconds || 30)}"></div>
- <div class="form-field"><label>Max / User / Day</label><input type="number" id="paste-max-per-day" value="${esc(cfg.maxPerUserPerDay || 50)}"></div>
- </div>
- <div class="setting-row"><label><strong>Allow anonymous pastes</strong></label><input type="checkbox" id="paste-anon" ${cfg.anonAllowed !== false ? 'checked' : ''} style="width:18px;height:18px"></div>
- <div class="setting-row"><label><strong>Allow image uploads</strong></label><input type="checkbox" id="paste-image-upload" ${cfg.imageUploadEnabled !== false ? 'checked' : ''} style="width:18px;height:18px"></div>
- <div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:10px">
- <button class="btn btn-primary" onclick="savePasteAdminConfig()"><i class="fa-solid fa-floppy-disk"></i> Save Paste Settings</button>
- <button class="btn btn-danger" onclick="deleteAllPasteForks()"><i class="fa-solid fa-trash-can"></i> Delete All Forks (${Number(s.forks || 0).toLocaleString()})</button>
- </div>
+ <h3><i class="fa-solid fa-sliders" aria-hidden="true"></i> Paste limits</h3>
+ <p class="muted" style="margin-bottom:8px">OpenVibe.Community owns pastes and sets these limits itself; they cannot be changed from here.</p>
+ ${limit('Largest text paste', `${n(cfg.maxSizeKb)} KB`)}
+ ${limit('Largest image', `${n(cfg.screenshotMaxSizeMb)} MB`)}
+ ${limit('Wait between pastes', `${n(cfg.cooldownSeconds)} seconds`)}
+ ${limit('Pastes per person per day', Number(cfg.maxPerUserPerDay) === 0 ? 'No limit' : n(cfg.maxPerUserPerDay))}
+ </div>
+ <div class="card">
+ <h3><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Forks</h3>
+ <p class="muted" style="margin-bottom:12px">Copies people made of other pastes. Deleting them cannot be undone.</p>
+ <button class="btn btn-danger" onclick="deleteAllPasteForks()"${Number(s.forks || 0) ? '' : ' disabled'}><i class="fa-solid fa-trash-can" aria-hidden="true"></i> Delete all forks (${n(s.forks || 0)})</button>
</div>`;
</div>`;
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
}
}
-async function savePasteAdminConfig() {
- try {
- await api('/api/admin/streamer/settings', {
- method: 'PUT',
- body: {
- settings: {
- paste_max_size_kb: document.getElementById('paste-max-size-kb').value,
- paste_screenshot_max_size_mb: document.getElementById('paste-screenshot-max-mb').value,
- paste_cooldown_seconds: document.getElementById('paste-cooldown').value,
- paste_max_per_user_per_day: document.getElementById('paste-max-per-day').value,
- paste_anon_allowed: document.getElementById('paste-anon').checked ? 'true' : 'false',
- paste_image_upload_enabled: document.getElementById('paste-image-upload').checked ? 'true' : 'false',
- },
- },
- });
- toast('Paste settings saved', 'success');
- } catch (e) { toast(e.message, 'error'); }
-}
-
async function deleteAllPasteForks() {
async function deleteAllPasteForks() {
if (!confirm('Delete all forked pastes? This cannot be undone.')) return;
if (!confirm('Delete all forked pastes? This cannot be undone.')) return;
try {
try {
@@ -1,23 +1,49 @@
@@ -1,23 +1,49 @@
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
- OpenVibe.Network — admin panel (/admin): payments, cashouts and verification keys.
+ OpenVibe.Network — admin panel (/admin): payment settings, the Billing pointer and verification keys.
Split out of admin.html. Classic scripts, global scope: they rely on
Split out of admin.html. Classic scripts, global scope: they rely on
admin/core.js's helpers (api, esc, toast, API, token) and on the
admin/core.js's helpers (api, esc, toast, API, token) and on the
tabLoaders map in admin/boot.js (loaded last). No ES modules.
tabLoaders map in admin/boot.js (loaded last). No ES modules.
═══════════════════════════════════════════════════════════════ */
═══════════════════════════════════════════════════════════════ */
-// ── Payments: configure PayPal / Stripe / CCBill / crypto + subscriptions ──
+// ── Payments: the switches and prices OpenVibe.Live reads ──
+// OpenVibe.Billing runs every checkout, webhook, subscription and cashout since the cutover of 2026-10-17. Live
+// still reads these site settings to decide which ways to pay it offers and at what price (Live's
+// server/monetization/payments.js). Provider credentials are Billing's environment variables, never settings here.
const PAYMENT_GROUPS = [
const PAYMENT_GROUPS = [
- { title: 'General', keys: ['payments_enabled', 'bucks_per_usd', 'bucks_min_purchase_usd', 'sub_price_usd', 'sub_streamer_share_pct'] },
- { title: 'PayPal', keys: ['paypal_enabled', 'paypal_mode', 'paypal_client_id', 'paypal_client_secret', 'paypal_webhook_id'] },
- { title: 'Stripe', keys: ['stripe_enabled', 'stripe_secret_key', 'stripe_publishable_key', 'stripe_webhook_secret'] },
- { title: 'CCBill', keys: ['ccbill_enabled', 'ccbill_client_account', 'ccbill_subaccount', 'ccbill_flexform_id', 'ccbill_salt', 'ccbill_webhook_secret'] },
- { title: 'Crypto (NOWPayments)', keys: ['crypto_enabled', 'crypto_provider', 'crypto_api_key', 'crypto_ipn_secret'] },
+ { title: 'General', keys: ['payments_enabled', 'bucks_per_usd', 'bucks_min_purchase_bucks', 'sub_price_usd', 'sub_streamer_share_pct', 'sub_site_route_fee_pct'] },
+ { title: 'PayPal', keys: ['paypal_enabled', 'paypal_client_id'] },
+ { title: 'Stripe', keys: ['stripe_enabled', 'stripe_secret_key', 'stripe_publishable_key'] },
+ { title: 'CCBill', keys: ['ccbill_enabled', 'ccbill_flexform_id'] },
+ { title: 'Crypto (NOWPayments)', keys: ['crypto_enabled', 'crypto_api_key'] },
];
];
-const PAYMENT_WEBHOOKS = {
- Stripe: 'https://openvibe.live/api/payments/webhook/stripe',
- PayPal: 'https://openvibe.live/api/payments/webhook/paypal',
- CCBill: 'https://openvibe.live/api/payments/webhook/ccbill?secret=YOUR_ccbill_webhook_secret',
- 'Crypto (NOWPayments)': 'https://openvibe.live/api/payments/webhook/crypto',
-};
+// Where providers send payments (OpenVibe.Billing server/api/webhooks.js) and the variables Billing reads
+// (its server/config.js), set in /etc/openvibe/billing.env on the host.
+const BILLING_URL = 'https://billing.openvibe.network';
+const BILLING_PROVIDERS = [
+ { name: 'Stripe', webhook: '/webhooks/stripe', env: ['STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'] },
+ { name: 'PayPal', webhook: '/webhooks/paypal', env: ['PAYPAL_CLIENT_ID', 'PAYPAL_CLIENT_SECRET', 'PAYPAL_WEBHOOK_ID', 'PAYPAL_MODE'] },
+ { name: 'CCBill', webhook: '/webhooks/ccbill?secret=<CCBILL_WEBHOOK_SECRET>', env: ['CCBILL_WEBHOOK_SECRET', 'CCBILL_CLIENT_ACCOUNT', 'CCBILL_SUBACCOUNT', 'CCBILL_FLEXFORM_ID', 'CCBILL_SALT'] },
+ { name: 'Crypto (NOWPayments)', webhook: '/webhooks/nowpayments', env: ['NOWPAYMENTS_API_KEY', 'NOWPAYMENTS_IPN_SECRET'] },
+ { name: 'PowerChat', webhook: '/webhooks/powerchat', env: ['POWERCHAT_WEBHOOK_SECRET', 'POWERCHAT_SITE_USERNAME'] },
+];
+
+function billingProvidersCard() {
+ const label = (t) => `<span class="muted" style="font-size:11px;display:block">${esc(t)}</span>`;
+ const rows = BILLING_PROVIDERS.map(p => `
+ <div class="setting-row" style="flex-wrap:wrap;align-items:flex-start;gap:6px 16px">
+ <strong style="font-size:13px;flex:0 0 150px">${esc(p.name)}</strong>
+ <div style="flex:1 1 300px;min-width:0;display:grid;gap:8px">
+ <div>${label('Webhook URL')}<code>${esc(BILLING_URL + p.webhook)}</code></div>
+ <div>${label('Billing environment variables')}${p.env.map(v => `<code>${esc(v)}</code>`).join(' ')}</div>
+ </div>
+ </div>`).join('');
+ return `
+ <div class="card">
+ <h3><i class="fa-solid fa-link" aria-hidden="true"></i> Provider webhooks and credentials</h3>
+ <p class="muted" style="margin-bottom:4px">Providers send payments to OpenVibe.Billing: set each provider's webhook to the URL below. Their keys and secrets go in Billing's environment file on the host, <code>/etc/openvibe/billing.env</code>, under these variable names; nothing on this page holds them.</p>
+ ${rows}
+ <p style="margin-top:14px"><a class="btn btn-outline" href="${BILLING_URL}/" target="_blank" rel="noopener"><i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i> Open the Billing console</a></p>
+ </div>`;
+}
async function loadPayments() {
async function loadPayments() {
const c = document.getElementById('payments-content');
const c = document.getElementById('payments-content');
@@ -27,17 +53,18 @@ async function loadPayments() {
@@ -27,17 +53,18 @@ async function loadPayments() {
const byKey = {}; (data.settings || []).forEach(s => { byKey[s.key] = s; });
const byKey = {}; (data.settings || []).forEach(s => { byKey[s.key] = s; });
const field = (s) => {
const field = (s) => {
if (!s) return '';
if (!s) return '';
- const sensitive = /(secret|salt|api[_-]?key|client_secret|ipn)/i.test(s.key);
+ const sensitive = /(secret|api[_-]?key)/i.test(s.key);
+ const id = `pay-${s.key}`;
if (s.type === 'boolean') return `
if (s.type === 'boolean') return `
<div class="setting-row">
<div class="setting-row">
- <label><strong>${esc(s.key)}</strong><br><small>${esc(s.description||'')}</small></label>
- <input type="checkbox" data-key="${esc(s.key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer">
+ <label for="${esc(id)}"><strong>${esc(s.key)}</strong><br><small>${esc(s.description||'')}</small></label>
+ <input type="checkbox" id="${esc(id)}" data-key="${esc(s.key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer">
</div>`;
</div>`;
const type = s.type === 'number' ? 'number' : (sensitive ? 'password' : 'text');
const type = s.type === 'number' ? 'number' : (sensitive ? 'password' : 'text');
return `
return `
<div class="setting-row" style="flex-direction:column;align-items:stretch">
<div class="setting-row" style="flex-direction:column;align-items:stretch">
- <label><strong>${esc(s.key)}</strong><br><small>${esc(s.description||'')}</small></label>
- <input type="${type}" data-key="${esc(s.key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}"
+ <label for="${esc(id)}"><strong>${esc(s.key)}</strong><br><small>${esc(s.description||'')}</small></label>
+ <input type="${type}" id="${esc(id)}" data-key="${esc(s.key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}" ${sensitive ? 'autocomplete="off"' : ''}
style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
</div>`;
</div>`;
};
};
@@ -45,14 +72,14 @@ async function loadPayments() {
@@ -45,14 +72,14 @@ async function loadPayments() {
<fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
<fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
<legend style="padding:0 8px;font-weight:700">${esc(g.title)}</legend>
<legend style="padding:0 8px;font-weight:700">${esc(g.title)}</legend>
${g.keys.map(k => field(byKey[k])).join('')}
${g.keys.map(k => field(byKey[k])).join('')}
- ${PAYMENT_WEBHOOKS[g.title] ? `<p class="muted" style="font-size:12px;margin-top:8px"><i class="fa-solid fa-link"></i> Webhook URL: <code>${esc(PAYMENT_WEBHOOKS[g.title])}</code></p>` : ''}
</fieldset>`).join('');
</fieldset>`).join('');
c.innerHTML = `
c.innerHTML = `
- <div style="max-width:760px">
- <p class="muted" style="margin-bottom:12px">Configure real-money payments for Vibes & channel subscriptions. Nothing goes live until <strong>payments_enabled</strong> is on and the provider is enabled with valid keys. Set each provider's webhook to the URL shown, and add the redirect/return URLs from OpenVibe.Live.</p>
+ <div style="max-width:860px">
+ <p class="muted" style="margin-bottom:12px">OpenVibe.Billing runs every checkout, subscription and cashout. These are the switches and prices OpenVibe.Live reads to decide which ways to pay it offers: nothing is offered until <strong>payments_enabled</strong> and the provider are on. Live also checks that <strong>stripe_secret_key</strong> and <strong>crypto_api_key</strong> are filled in before it offers Stripe or crypto; the keys Billing charges with are in its environment (below).</p>
<form id="payments-form">${groups}
<form id="payments-form">${groups}
- <button type="button" class="btn btn-primary" onclick="savePayments()"><i class="fa-solid fa-floppy-disk"></i> Save Payment Settings</button>
+ <button type="button" class="btn btn-primary" onclick="savePayments()"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Save payment settings</button>
</form>
</form>
+ <div style="margin-top:20px">${billingProvidersCard()}</div>
</div>`;
</div>`;
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
}
}
@@ -69,53 +96,8 @@ async function savePayments() {
@@ -69,53 +96,8 @@ async function savePayments() {
} catch (e) { toast(e.message, 'error'); }
} catch (e) { toast(e.message, 'error'); }
}
}
-// ═══════════════════════════════════════════════════════════════
-// Cashouts (via funds proxy)
-// ═══════════════════════════════════════════════════════════════
-async function loadCashouts() {
- const c = document.getElementById('cashouts-content');
- c.innerHTML = '<div class="loading">Loading...</div>';
- try {
- const data = await api('/api/admin/streamer-funds/cashouts/pending');
- const cashouts = data.cashouts || [];
- if (!cashouts.length) { c.innerHTML = '<p class="muted">No pending cashouts</p>'; return; }
- c.innerHTML = `
- <table class="admin-table">
- <thead><tr><th>User</th><th>Amount</th><th>USD</th><th>PayPal</th><th>Requested</th><th>Actions</th></tr></thead>
- <tbody>${cashouts.map(co => `
- <tr>
- <td><strong>${esc(co.username || '-')}</strong></td>
- <td>${Number(co.amount || 0).toLocaleString()} CF</td>
- <td>$${((Number(co.amount) || 0) * 0.01).toFixed(2)}</td>
- <td>${esc(co.paypal_email || '-')}</td>
- <td>${co.created_at ? timeAgo(co.created_at) : '-'}</td>
- <td style="display:flex;gap:6px">
- <button class="btn btn-sm btn-success" onclick="approveCashout('${co.id}')"><i class="fa-solid fa-check"></i> Approve</button>
- <button class="btn btn-sm btn-danger" onclick="denyCashout('${co.id}')"><i class="fa-solid fa-xmark"></i> Deny</button>
- </td>
- </tr>
- `).join('')}</tbody>
- </table>`;
- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
-}
-
-async function approveCashout(id) {
- try {
- await api(`/api/admin/streamer-funds/cashout/${id}/approve`, { method: 'POST' });
- toast('Cashout approved', 'success');
- loadCashouts();
- } catch (e) { toast(e.message, 'error'); }
-}
-
-async function denyCashout(id) {
- const reason = prompt('Denial reason:');
- if (reason === null) return;
- try {
- await api(`/api/admin/streamer-funds/cashout/${id}/deny`, { method: 'POST', body: { reason } });
- toast('Cashout denied', 'info');
- loadCashouts();
- } catch (e) { toast(e.message, 'error'); }
-}
+// Cashouts: the tab is a pointer to OpenVibe.Billing's console (admin.html #sec-cashouts); Live's old cashout queue
+// (/api/funds) answers 409 since the Billing cutover.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// Verification Keys
// Verification Keys
@@ -39,9 +39,11 @@ const AREAS = [
@@ -39,9 +39,11 @@ const AREAS = [
{ area: 'Moderation, money and loyalty', items: [
{ area: 'Moderation, money and loyalty', items: [
{ item: 'Moderation audit across services', where: [{ kind: 'tab', tab: 'modlog' }], service: 'network' },
{ item: 'Moderation audit across services', where: [{ kind: 'tab', tab: 'modlog' }], service: 'network' },
{ item: 'Chat logs and chat moderation', where: [{ kind: 'tab', tab: 'chat-logs' }], service: 'chat' },
{ item: 'Chat logs and chat moderation', where: [{ kind: 'tab', tab: 'chat-logs' }], service: 'chat' },
+ { item: 'Messages held for IP approval (was the VPN queue)', where: [{ kind: 'url', url: 'https://openvibe.live/dashboard/moderation', note: 'per channel: the streamer and channel mods review them' }], service: 'chat' },
+ { item: 'Text-to-speech settings', where: [{ kind: 'tab', tab: 'tts' }], service: 'chat' },
{ item: 'Community moderation', where: [{ kind: 'api', api: 'OpenVibe.Community staff routes', note: 'no console yet' }], service: 'community' },
{ item: 'Community moderation', where: [{ kind: 'api', api: 'OpenVibe.Community staff routes', note: 'no console yet' }], service: 'community' },
{ item: 'Space forum moderation', where: [{ kind: 'api', api: 'OpenVibe.Space forum moderator routes', note: 'no console yet' }], service: 'space' },
{ item: 'Space forum moderation', where: [{ kind: 'api', api: 'OpenVibe.Space forum moderator routes', note: 'no console yet' }], service: 'space' },
- { item: 'Payments, cashouts and the money freeze', where: [{ kind: 'url', url: 'https://billing.openvibe.network/', note: 'Billing console' }, { kind: 'tab', tab: 'cashouts' }, { kind: 'tab', tab: 'payments' }], service: 'billing' },
+ { item: 'Payments, cashouts and the money freeze', where: [{ kind: 'url', url: 'https://billing.openvibe.network/', note: 'Billing console: cashouts, receipts, the freeze' }, { kind: 'tab', tab: 'payments', note: 'what Live offers and its prices; provider webhooks' }], service: 'billing' },
{ item: 'Loyalty (OpenCoins, channel points)', where: [{ kind: 'api', api: 'live.loyalty user module; Network /internal/coins/*', note: 'summaries only; loyalty is never money' }], service: 'network' },
{ item: 'Loyalty (OpenCoins, channel points)', where: [{ kind: 'api', api: 'live.loyalty user module; Network /internal/coins/*', note: 'summaries only; loyalty is never money' }], service: 'network' },
{ item: 'Developer apps and trust (Services)', where: [{ kind: 'url', url: 'https://openvibe.services/staff' }], service: 'services' },
{ item: 'Developer apps and trust (Services)', where: [{ kind: 'url', url: 'https://openvibe.services/staff' }], service: 'services' },
] },
] },
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+/**
+ * The admin panel's service proxies (public/js/admin/*): each forwards to the service that owns the job, as the
+ * signed-in staff member. The token sent on is re-minted from their current record, so a role granted a moment ago
+ * counts upstream too; every service checks the person's role again.
+ *
+ * /api/admin/streamer/* → Live /api/admin/* admin
+ * /api/admin/streamer-mod/* → Live /api/mod/* admin or global mod
+ * /api/admin/streamer-tts/* → Chat /api/tts/* admin (Chat serves TTS since the chat cutover; it keeps the
+ * provider credentials owner-only)
+ * /api/admin/streamer-pastes/* → Live /api/pastes/* admin (Live forwards to OpenVibe.Community, which owns pastes)
+ * /api/admin/streamer-funds/* 410: cashouts are decided in OpenVibe.Billing's console, not here
+ *
+ * test/admin-service-proxies.test.js
+ */
+const express = require('express');
+
+const BILLING_CONSOLE = 'https://billing.openvibe.network';
+
+/** The JSON (or problem+json) types passed through as they are; anything else goes back as plain text. */
+const JSON_TYPE = /^application\/([a-z.+-]+\+)?json\b/i;
+
+/**
+ * Forward the request to `targetUrl` with the staff member's token and send back the upstream status and body.
+ * `service` names the upstream in the 502 when it cannot be reached.
+ */
+async function proxyJsonRequest(req, res, targetUrl, { label, service, signToken }) {
+ try {
+ let upstreamToken = req.token;
+ try {
+ if (req.user && req.app.locals.privateKey && typeof signToken === 'function') {
+ upstreamToken = signToken(req.user, req.app.locals.privateKey, req.app.locals.config, { renew: req.tokenClaims || {} });
+ }
+ } catch (e) {
+ console.error('[AdminProxy] token re-mint failed, forwarding original:', e.message);
+ }
+ const fetchOpts = {
+ method: req.method,
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${upstreamToken}` },
+ };
+ if (req.method !== 'GET' && req.method !== 'HEAD' && req.body) fetchOpts.body = JSON.stringify(req.body);
+
+ const upstream = await fetch(targetUrl, fetchOpts);
+ const contentType = upstream.headers.get('content-type') || '';
+ const raw = await upstream.text();
+ res.status(upstream.status);
+ if (JSON_TYPE.test(contentType)) return res.type(contentType).send(raw || '{}');
+ return res.type('text/plain').send(raw);
+ } catch (err) {
+ console.error(`[AdminProxy] ${label}:`, err.message);
+ return res.status(502).json({ error: `Could not reach ${service}` });
+ }
+}
+
+const allow = (roles, error) => (req, res, next) => (req.user && roles.includes(req.user.role) ? next() : res.status(403).json({ error }));
+
+/**
+ * The proxies as one router, mounted at /api/admin. `services` is config.services (each service's internalUrl, read
+ * on every request); `signToken` is auth/routes' (it re-mints the staff member's token).
+ */
+function createServiceProxies({ requireAuth, signToken, services }) {
+ const r = express.Router();
+ const admin = allow(['admin'], 'Admin access required');
+ const staff = allow(['admin', 'global_mod'], 'Staff access required');
+ const to = (base, prefix, service, label) => async (req, res) => await proxyJsonRequest(req, res, `${base()}${prefix}${req.url}`, { label, service, signToken });
+ const live = () => services.live.internalUrl;
+ const chat = () => services.chat.internalUrl;
+
+ r.use('/streamer', requireAuth, admin, to(live, '/api/admin', 'OpenVibe.Live', 'Streamer proxy error'));
+ r.use('/streamer-mod', requireAuth, staff, to(live, '/api/mod', 'OpenVibe.Live', 'Mod proxy error'));
+ r.use('/streamer-tts', requireAuth, admin, to(chat, '/api/tts', 'OpenVibe.Chat', 'TTS proxy error'));
+ r.use('/streamer-pastes', requireAuth, admin, to(live, '/api/pastes', 'OpenVibe.Live', 'Pastes proxy error'));
+ // Live's /api/funds cashout admin is gone (Billing is the only money ledger since 2026-10-17).
+ r.use('/streamer-funds', requireAuth, admin, (req, res) => res.status(410).json({
+ error: 'Cashouts are decided in OpenVibe.Billing\'s console now',
+ console: `${BILLING_CONSOLE}/cashouts`,
+ }));
+ return r;
+}
+
+module.exports = { createServiceProxies, proxyJsonRequest, BILLING_CONSOLE };
@@ -117,5 +117,9 @@ module.exports = {
@@ -117,5 +117,9 @@ module.exports = {
media: {
media: {
internalUrl: process.env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100',
internalUrl: process.env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100',
},
},
+ // OpenVibe.Chat: the admin panel's TTS settings (server/admin/service-proxies.js).
+ chat: {
+ internalUrl: process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400',
+ },
},
},
};
};
@@ -87,45 +87,6 @@ function sendLandingPage(res) {
@@ -87,45 +87,6 @@ function sendLandingPage(res) {
return res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
return res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
}
}
-async function proxyJsonRequest(req, res, targetUrl, errorLabel) {
- try {
- // Re-mint a fresh token from the user's CURRENT server-side record so that
- // a just-granted role (e.g. admin) propagates to upstream services
- // immediately, instead of relying on the client's possibly-stale token.
- let upstreamToken = req.token;
- try {
- if (req.user && req.app.locals.privateKey) {
- upstreamToken = signToken(req.user, req.app.locals.privateKey, req.app.locals.config, { renew: req.tokenClaims || {} });
- }
- } catch (e) {
- console.error('[AdminProxy] token re-mint failed, forwarding original:', e.message);
- }
- const fetchOpts = {
- method: req.method,
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${upstreamToken}`,
- },
- };
- if (req.method !== 'GET' && req.method !== 'HEAD' && req.body) {
- fetchOpts.body = JSON.stringify(req.body);
- }
-
- const upstream = await fetch(targetUrl, fetchOpts);
- const contentType = upstream.headers.get('content-type') || '';
- const raw = await upstream.text();
-
- res.status(upstream.status);
- if (contentType.includes('application/json')) {
- return res.json(raw ? JSON.parse(raw) : {});
- }
- return res.send(raw);
- } catch (err) {
- console.error(`[AdminProxy] ${errorLabel}:`, err.message);
- return res.status(502).json({ error: 'Could not reach OpenVibe.Live service' });
- }
-}
-
// ── Security ─────────────────────────────────────────────────
// ── Security ─────────────────────────────────────────────────
app.set('trust proxy', 2); // Cloudflare → Nginx → Node
app.set('trust proxy', 2); // Cloudflare → Nginx → Node
@@ -753,59 +714,9 @@ app.use('/api/admin', createAdminRoutes(db, notificationService, emailService, r
@@ -753,59 +714,9 @@ app.use('/api/admin', createAdminRoutes(db, notificationService, emailService, r
const createAnalyticsRoutes = require('./admin/analytics-routes');
const createAnalyticsRoutes = require('./admin/analytics-routes');
app.use('/api/admin/analytics', createAnalyticsRoutes(analytics, requireAuth, config, { selfToken }));
app.use('/api/admin/analytics', createAnalyticsRoutes(analytics, requireAuth, config, { selfToken }));
-// ── Admin Proxy to OpenVibe.Live ──────────────────────────────
-// Proxies /api/admin/streamer/* → openvibe.live /api/admin/*
-// This lets the unified admin panel on openvibe.network manage OpenVibe.Live features.
-const OPENVIBELIVE_INTERNAL = config.services.live.internalUrl;
-app.use('/api/admin/streamer', requireAuth, (req, res, next) => {
- if (!req.user || req.user.role !== 'admin') {
- return res.status(403).json({ error: 'Admin access required' });
- }
- next();
-}, async (req, res) => {
- return await proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/admin${req.url}`, 'Streamer proxy error');
-});
-
-// Proxy /api/mod/* for moderator routes
-app.use('/api/admin/streamer-mod', requireAuth, (req, res, next) => {
- if (!req.user || (req.user.role !== 'admin' && req.user.role !== 'global_mod')) {
- return res.status(403).json({ error: 'Staff access required' });
- }
- next();
-}, async (req, res) => {
- return await proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/mod${req.url}`, 'Mod proxy error');
-});
-
-app.use('/api/admin/streamer-tts', requireAuth, (req, res, next) => {
- if (!req.user || req.user.role !== 'admin') {
- return res.status(403).json({ error: 'Admin access required' });
- }
- next();
-}, async (req, res) => {
- return await proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/tts${req.url}`, 'TTS proxy error');
-});
-
-app.use('/api/admin/streamer-funds', requireAuth, (req, res, next) => {
- if (!req.user || req.user.role !== 'admin') {
- return res.status(403).json({ error: 'Admin access required' });
- }
- next();
-}, async (req, res) => {
- return await proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/funds${req.url}`, 'Funds proxy error');
-});
-
-// Pastes — the admin panel's Pastes tab (public/js/admin/content.js) reads GET /config and /admin/stats
-// and calls DELETE /admin/forks through this proxy. The chain is Network → Live → Community:
-// /api/admin/streamer-pastes/:rest → openvibe.live /api/pastes/:rest, which checks Live's admin role and
-// forwards to OpenVibe.Community's /api/pastes/:rest as Live's service (Community owns pastes).
-app.use('/api/admin/streamer-pastes', requireAuth, (req, res, next) => {
- if (!req.user || req.user.role !== 'admin') {
- return res.status(403).json({ error: 'Admin access required' });
- }
- next();
-}, async (req, res) => {
- return await proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/pastes${req.url}`, 'Pastes proxy error');
-});
+// ── Admin panel proxies to the services that own each job (server/admin/service-proxies.js) ──
+// /api/admin/streamer(-mod|-pastes)/* → Live, /api/admin/streamer-tts/* → Chat, /api/admin/streamer-funds/* 410 (Billing).
+app.use('/api/admin', require('./admin/service-proxies').createServiceProxies({ requireAuth, signToken, services: config.services }));
// Internal API (server-to-server, capability-scoped service tokens only)
// Internal API (server-to-server, capability-scoped service tokens only)
app.use('/internal', require('./internal/routes'));
app.use('/internal', require('./internal/routes'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -0,0 +1,146 @@
+'use strict';
+// The admin panel's service proxies (server/admin/service-proxies.js) reach the service that owns each job: TTS goes
+// to OpenVibe.Chat (Live has no /api/tts since the chat cutover), cashouts answer 410 with Billing's console, the
+// Live and pastes proxies still go to Live; each checks the staff role first and forwards the re-minted token.
+// The panel's scripts no longer call the retired routes or show Live's old payment webhooks and secrets.
+// node test/admin-service-proxies.test.js
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const path = require('path');
+const express = require('express');
+const { createServiceProxies } = require('../server/admin/service-proxies');
+
+/** A fake upstream that records each request and answers JSON. */
+function upstream(name) {
+ const seen = [];
+ const srv = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (d) => { body += d; });
+ req.on('end', () => {
+ seen.push({ method: req.method, url: req.url, auth: req.headers.authorization || null, body: body ? JSON.parse(body) : null });
+ if (req.url.startsWith('/api/tts/broken')) return res.writeHead(500, { 'content-type': 'application/problem+json' }).end('{"title":"boom"}');
+ if (req.url.startsWith('/api/tts/text')) return res.writeHead(200, { 'content-type': 'text/html' }).end('<b>hi</b>');
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }).end(JSON.stringify({ from: name, url: req.url }));
+ });
+ });
+ return new Promise((resolve) => srv.listen(0, '127.0.0.1', () => resolve({ srv, seen, url: `http://127.0.0.1:${srv.address().port}` })));
+}
+
+(async () => {
+ const live = await upstream('live');
+ const chat = await upstream('chat');
+ const services = { live: { internalUrl: live.url }, chat: { internalUrl: chat.url } };
+ // requireAuth stand-in: X-Test-User is the signed-in person; none → 401, as Network's requireAuth answers.
+ const requireAuth = (req, res, next) => {
+ const u = req.headers['x-test-user'];
+ if (!u) return res.status(401).json({ error: 'Authentication required' });
+ req.user = JSON.parse(u);
+ req.token = `client-token-${req.user.id}`;
+ next();
+ };
+ const signToken = (user) => `minted-${user.id}-${user.role}`;
+ const app = express();
+ app.locals.privateKey = 'test-key';
+ app.locals.config = {};
+ app.use(express.json());
+ app.use('/api/admin', createServiceProxies({ requireAuth, signToken, services }));
+ app.use((req, res) => res.status(404).json({ error: 'Not found' }));
+ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+
+ const admin = { id: 1, role: 'admin' };
+ const mod = { id: 2, role: 'global_mod' };
+ const user = { id: 3, role: 'user' };
+ async function call(who, method, p, body) {
+ const headers = { 'content-type': 'application/json' };
+ if (who) headers['x-test-user'] = JSON.stringify(who);
+ const r = await fetch(base + p, { method, headers, body: body ? JSON.stringify(body) : undefined });
+ const text = await r.text();
+ let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ }
+ return { status: r.status, type: r.headers.get('content-type') || '', text, json };
+ }
+ const reset = () => { live.seen.length = 0; chat.seen.length = 0; };
+
+ try {
+ // ── TTS → Chat ───────────────────────────────────────────────
+ let r = await call(admin, 'GET', '/api/admin/streamer-tts/admin/settings');
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(r.json, { from: 'chat', url: '/api/tts/admin/settings' }, 'TTS settings come from Chat');
+ assert.match(r.type, /^application\/json/);
+ assert.strictEqual(chat.seen[0].auth, 'Bearer minted-1-admin', 'Chat gets the staff member\'s re-minted token');
+ r = await call(admin, 'GET', '/api/admin/streamer-tts/voices?x=1');
+ assert.strictEqual(r.json.url, '/api/tts/voices?x=1', 'the path and query reach Chat as they were');
+ r = await call(admin, 'PUT', '/api/admin/streamer-tts/admin/settings', { settings: { tts_enabled: false } });
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(chat.seen.at(-1).body, { settings: { tts_enabled: false } }, 'a save carries its body to Chat');
+ assert.strictEqual(chat.seen.at(-1).method, 'PUT');
+ r = await call(admin, 'POST', '/api/admin/streamer-tts/admin/test', { voiceId: 'gary' });
+ assert.strictEqual(chat.seen.at(-1).url, '/api/tts/admin/test');
+ assert.strictEqual(live.seen.length, 0, 'nothing about TTS goes to Live');
+ r = await call(admin, 'GET', '/api/admin/streamer-tts/broken');
+ assert.strictEqual(r.status, 500, 'an upstream error keeps its status');
+ assert.match(r.type, /^application\/problem\+json/, 'problem+json passes through as JSON');
+ r = await call(admin, 'GET', '/api/admin/streamer-tts/text');
+ assert.match(r.type, /^text\/plain/, 'a non-JSON answer is sent back as plain text, never as HTML on Network\'s origin');
+
+ // Who may: admins only; no session → 401; nothing forwarded for a refusal.
+ reset();
+ assert.strictEqual((await call(null, 'GET', '/api/admin/streamer-tts/admin/settings')).status, 401);
+ assert.strictEqual((await call(user, 'GET', '/api/admin/streamer-tts/admin/settings')).status, 403);
+ assert.strictEqual((await call(mod, 'PUT', '/api/admin/streamer-tts/admin/settings', { settings: {} })).status, 403);
+ assert.strictEqual(chat.seen.length, 0, 'a refused request never reaches Chat');
+
+ // ── Cashouts: 410 with Billing's console, nothing forwarded ──
+ r = await call(admin, 'GET', '/api/admin/streamer-funds/cashouts/pending');
+ assert.strictEqual(r.status, 410);
+ assert.strictEqual(r.json.console, 'https://billing.openvibe.network/cashouts');
+ assert.match(r.json.error, /Billing/);
+ r = await call(admin, 'POST', '/api/admin/streamer-funds/cashout/7/approve');
+ assert.strictEqual(r.status, 410);
+ assert.strictEqual((await call(null, 'GET', '/api/admin/streamer-funds/cashouts/pending')).status, 401);
+ assert.strictEqual((await call(user, 'GET', '/api/admin/streamer-funds/cashouts/pending')).status, 403);
+ assert.strictEqual(live.seen.length + chat.seen.length, 0, 'cashouts reach neither Live nor Chat');
+
+ // ── Live and pastes proxies unchanged ────────────────────────
+ r = await call(admin, 'GET', '/api/admin/streamer/settings');
+ assert.deepStrictEqual(r.json, { from: 'live', url: '/api/admin/settings' });
+ r = await call(admin, 'GET', '/api/admin/streamer-pastes/config');
+ assert.deepStrictEqual(r.json, { from: 'live', url: '/api/pastes/config' });
+ r = await call(mod, 'GET', '/api/admin/streamer-mod/reports');
+ assert.deepStrictEqual(r.json, { from: 'live', url: '/api/mod/reports' }, 'global mods use the mod proxy');
+ assert.strictEqual((await call(mod, 'GET', '/api/admin/streamer/settings')).status, 403, 'the Live admin proxy is admins only');
+ assert.strictEqual((await call(user, 'GET', '/api/admin/streamer-mod/reports')).status, 403);
+ // Other /api/admin paths are not the proxies' business.
+ assert.strictEqual((await call(null, 'GET', '/api/admin/streamerx')).status, 404);
+
+ // ── A service that is down: 502 naming it ────────────────────
+ await new Promise((resolve) => chat.srv.close(resolve));
+ r = await call(admin, 'GET', '/api/admin/streamer-tts/admin/settings');
+ assert.strictEqual(r.status, 502);
+ assert.strictEqual(r.json.error, 'Could not reach OpenVibe.Chat');
+ } finally {
+ server.close();
+ live.srv.close();
+ if (chat.srv.listening) chat.srv.close();
+ }
+
+ // ── The panel's scripts ───────────────────────────────────────────
+ const read = (f) => fs.readFileSync(path.join(__dirname, '..', 'public', f), 'utf8');
+ const panel = ['admin.html', ...fs.readdirSync(path.join(__dirname, '..', 'public', 'js', 'admin')).map((f) => `js/admin/${f}`)].map(read).join('\n');
+ for (const gone of ['/api/admin/streamer-funds', '/api/admin/streamer/vpn-queue', 'openvibe.live/api/payments/webhook', "showTab('vpn'"]) {
+ assert.ok(!panel.includes(gone), `the admin panel no longer uses ${gone}`);
+ }
+ const payments = read('js/admin/payments.js');
+ for (const key of ['paypal_client_secret', 'paypal_webhook_id', 'stripe_webhook_secret', 'ccbill_salt', 'ccbill_webhook_secret', 'crypto_ipn_secret']) {
+ assert.ok(!payments.includes(`'${key}'`), `the Payments form no longer saves ${key} into Live's settings (Billing reads its environment)`);
+ }
+ for (const hook of ['stripe', 'paypal', 'ccbill', 'nowpayments', 'powerchat']) assert.ok(payments.includes(`'/webhooks/${hook}`), `Billing's ${hook} webhook is shown`);
+ assert.ok(payments.includes("'https://billing.openvibe.network'"), 'webhooks are on Billing\'s origin');
+ const content = read('js/admin/content.js');
+ assert.ok(!/paste_(max|cooldown|anon|image|screenshot)/.test(content), 'the Pastes tab saves no paste_* settings nobody reads');
+ assert.ok(content.includes("/api/admin/streamer-pastes/config"), 'the Pastes tab shows Community\'s limits');
+ assert.ok(read('admin.html').includes('https://billing.openvibe.network/cashouts'), 'the Cashouts tab points to Billing\'s console');
+
+ console.log('admin service proxies: all checks passed');
+})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
@@ -35,7 +35,7 @@ async function bootServer({ env = {}, child = false, timeoutMs = 60000 } = {}) {
@@ -35,7 +35,7 @@ async function bootServer({ env = {}, child = false, timeoutMs = 60000 } = {}) {
BOOTSTRAP_PROFILE: 'local-dev',
BOOTSTRAP_PROFILE: 'local-dev',
OV_NETWORK_INTERNAL_URL: `http://127.0.0.1:${port}`,
OV_NETWORK_INTERNAL_URL: `http://127.0.0.1:${port}`,
OV_LIVE_INTERNAL_URL: 'http://127.0.0.1:9', OV_TOOLS_INTERNAL_URL: 'http://127.0.0.1:9',
OV_LIVE_INTERNAL_URL: 'http://127.0.0.1:9', OV_TOOLS_INTERNAL_URL: 'http://127.0.0.1:9',
- OV_GAMES_INTERNAL_URL: 'http://127.0.0.1:9', OV_MEDIA_INTERNAL_URL: 'http://127.0.0.1:9',
+ OV_GAMES_INTERNAL_URL: 'http://127.0.0.1:9', OV_MEDIA_INTERNAL_URL: 'http://127.0.0.1:9', OV_CHAT_INTERNAL_URL: 'http://127.0.0.1:9',
OV_AI_INTERNAL_URL: 'http://127.0.0.1:9',
OV_AI_INTERNAL_URL: 'http://127.0.0.1:9',
};
};
@@ -120,7 +120,7 @@ const REVIEWED = {
@@ -120,7 +120,7 @@ const REVIEWED = {
'server/discord/discord-service.js discord.js': [1, 'discord.js login: Discord gateway only'],
'server/discord/discord-service.js discord.js': [1, 'discord.js login: Discord gateway only'],
'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],
'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],
'server/frame/service.js fetch': [2, 'Live and AI internal URLs from config (navbar ranking copy)'],
'server/frame/service.js fetch': [2, 'Live and AI internal URLs from config (navbar ranking copy)'],
- 'server/index.js fetch': [1, 'the admin streamer proxy: Live\'s internal URL from config, fixed path prefixes'],
+ 'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
'server/profile/public.js fetch': [3, 'OpenVibe.Inventory (OV_INVENTORY_INTERNAL_URL, loopback 5030) and OpenVibe.Quest (OV_QUEST_INTERNAL_URL, loopback 4980) only; the paths are fixed, the subject is a checked usr_ id'],
'server/profile/public.js fetch': [3, 'OpenVibe.Inventory (OV_INVENTORY_INTERNAL_URL, loopback 5030) and OpenVibe.Quest (OV_QUEST_INTERNAL_URL, loopback 4980) only; the paths are fixed, the subject is a checked usr_ id'],