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)

OP OpenVibers committed 22 days ago · 6c9c80977b · 14 files, +394 −296

Changes

ddiff --git a/.env.example b/.env.example
iindex ac275909..730dc2e9 100644
---- a/.env.example
++++ b/.env.example
@@@ -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_GAMES_INTERNAL_URL=http://127.0.0.1:8000
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)
#SSH_SERVER_HOST=
ddiff --git a/public/admin.html b/public/admin.html
iindex a1f56d15..df7ce611 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -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>
ddiff --git a/public/css/admin.css b/public/css/admin.css
iindex 0954437d..037683e6 100644
---- a/public/css/admin.css
++++ b/public/css/admin.css
@@@ -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,BlinkMa
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: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)}
ddiff --git a/public/js/admin/ai.js b/public/js/admin/ai.js
iindex 05f74732..a5d517bc 100644
---- a/public/js/admin/ai.js
++++ b/public/js/admin/ai.js
@@@ -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
admin/core.js's helpers (api, esc, toast, API, token) and on the
tabLoaders 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 changed
const 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'); }
}
ddiff --git a/public/js/admin/boot.js b/public/js/admin/boot.js
iindex 2c8e2a48..fb3247ed 100644
---- a/public/js/admin/boot.js
++++ b/public/js/admin/boot.js
@@@ -14,10 +14,9 @@ const tabLoaders = {
moderators: loadModerators,
settings: loadSettings,
tts: loadTTS,
-- cashouts: loadCashouts,
++ cashouts: () => {}, // a pointer to OpenVibe.Billing's console
payments: loadPayments,
ai: loadAi,
-- vpn: loadVPNQueue,
pastes: loadPastesAdmin,
storage: loadStorage,
notifications: () => {},
ddiff --git a/public/js/admin/content.js b/public/js/admin/content.js
iindex 4b75fd11..cdc7622c 100644
---- a/public/js/admin/content.js
++++ b/public/js/admin/content.js
@@@ -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
admin/core.js's helpers (api, esc, toast, API, token) and on the
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() {
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 {
ddiff --git a/public/js/admin/payments.js b/public/js/admin/payments.js
iindex bab57f0d..b98ba991 100644
---- a/public/js/admin/payments.js
++++ b/public/js/admin/payments.js
@@@ -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
admin/core.js's helpers (api, esc, toast, API, token) and on the
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 = [
-- { 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 &amp; 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
ddiff --git a/server/admin/operator-checklist.js b/server/admin/operator-checklist.js
iindex 8345318c..0369a7d2 100644
---- a/server/admin/operator-checklist.js
++++ b/server/admin/operator-checklist.js
@@@ -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' },
] },
ddiff --git a/server/admin/service-proxies.js b/server/admin/service-proxies.js
nnew file mode 100644
iindex 00000000..ae39fca7
---- /dev/null
++++ b/server/admin/service-proxies.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
iindex 4e2021d3..f45036f1 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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',
++ },
},
};
ddiff --git a/server/index.js b/server/index.js
iindex 688977cf..a5dc3d6c 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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, r
const 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'));
ddiff --git a/test/admin-service-proxies.test.js b/test/admin-service-proxies.test.js
nnew file mode 100644
iindex 00000000..179f54da
---- /dev/null
++++ b/test/admin-service-proxies.test.js
@@@ -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); });
ddiff --git a/test/helpers/boot-server.js b/test/helpers/boot-server.js
iindex cb469b06..b5afe7a8 100644
---- a/test/helpers/boot-server.js
++++ b/test/helpers/boot-server.js
@@@ -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',
};
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex e8549ce8..84f3d110 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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'],