openvibers/openvibe.network Commits

Admin: only the owner lifts a site-wide ban; bans as cards; every on/off a switch (#902)

OP OpenVibers committed 5 h ago · 539cd24ae4 · 8 files, +131 −34

* Admin: only the owner lifts a site-wide ban; bans as cards with who and why; every on/off is the network's switch

* Admin: Notifications is the owner's (and broadcasting); Email shows admins its numbers, the owner its settings

Changes

ddiff --git a/public/css/admin.css b/public/css/admin.css
iindex f9e12a50..f05564ac 100644
---- a/public/css/admin.css
++++ b/public/css/admin.css
@@@ -277,7 +277,7 @@ a.btn{text-decoration:none}
.pai-table th{text-align:left;font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted);padding:6px 8px;border-bottom:1px solid var(--border);white-space:nowrap}
.pai-table td{padding:7px 8px;border-bottom:1px solid var(--border);vertical-align:top;overflow-wrap:anywhere}
@media(max-width:800px){.pai-provider{grid-template-columns:minmax(0,1fr)}.pai-provider-actions{justify-content:flex-start;max-width:none}.pai-fields{grid-template-columns:minmax(0,1fr)}}
--@media(max-width:640px){.pai-form-row{grid-template-columns:minmax(0,1fr)}#platform-ai .btn,#platform-ai input,#platform-ai select,#platform-ai textarea,#platform-ai summary,#platform-ai .pai-index a,#platform-ai .pai-switch{min-height:44px}#platform-ai .pai-chip button{min-width:44px;min-height:44px}.pai-index{gap:2px;padding:5px}.pai-index a{padding:8px 9px;font-size:11.5px}.pai-section{scroll-margin-top:96px}.pai-dialog{width:calc(100vw - 16px)}.pai-form{padding:14px}.pai-card{padding:13px}}
++@media(max-width:640px){.pai-form-row{grid-template-columns:minmax(0,1fr)}#platform-ai .btn,#platform-ai input:not([type=checkbox]):not([type=radio]),#platform-ai select,#platform-ai textarea,#platform-ai summary,#platform-ai .pai-index a,#platform-ai .pai-switch{min-height:44px}#platform-ai .pai-chip button{min-width:44px;min-height:44px}.pai-index{gap:2px;padding:5px}.pai-index a{padding:8px 9px;font-size:11.5px}.pai-section{scroll-margin-top:96px}.pai-dialog{width:calc(100vw - 16px)}.pai-form{padding:14px}.pai-card{padding:13px}}
/* Owner clarity: scoped to the legacy admin sections; AI and Setup own their forms. */
.admin-clarity { min-width:0; overflow-wrap:anywhere; }
@@@ -286,7 +286,7 @@ a.btn{text-decoration:none}
.admin-clarity .admin-help { display:block; color:var(--text-secondary); font-size:13px; font-weight:400; text-transform:none; letter-spacing:normal; line-height:1.55; margin:6px 0 10px; }
.admin-clarity .admin-machine-key { display:inline-block; color:var(--text-muted); font-size:12px; font-weight:400; overflow-wrap:anywhere; }
.admin-clarity .admin-field { display:flex; flex:1 1 220px; flex-direction:column; min-width:0; max-width:100%; }
--.admin-clarity :is(input,select,textarea) { max-width:100%; min-height:44px; font-size:14px; }
++.admin-clarity :is(input:not([type=checkbox]):not([type=radio]),select,textarea) { max-width:100%; min-height:44px; font-size:14px; }
.admin-clarity :is(button,.btn,summary) { min-height:44px; min-width:44px; white-space:normal; justify-content:center; }
.admin-clarity input[type=checkbox] { width:44px!important; height:44px!important; flex:0 0 44px; accent-color:var(--accent); }
.admin-clarity .setting-row { gap:12px; align-items:flex-start; }
@@@ -549,3 +549,23 @@ body:has(.admin-clarity.active) .ov-sel-opt { min-height:44px; }
.oin-kind-body{padding:0 12px 12px}
#platform-ai .oin-way{min-height:44px}
}
++
++/* Bans as cards (2026-11-13): who, why, by whom and when, how long; lifting a ban is the owner's. */
++.ban-count{margin:0 0 10px}
++.ban-list{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fill,minmax(min(100%,320px),1fr))}
++.ban-card{display:grid;gap:8px;padding:14px;border:1px solid var(--border);border-radius:14px;background:var(--bg-card,var(--bg-secondary))}
++.ban-top{display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0}
++.ban-who{font-weight:800;font-size:15px;color:var(--text-primary);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.ban-who:hover{text-decoration:underline}
++.ban-len{flex:none;font-size:12px;font-weight:700;padding:3px 9px;border-radius:999px;background:color-mix(in srgb,var(--text-primary) 8%,transparent);color:var(--text-secondary)}
++.ban-len.is-perm{background:color-mix(in srgb,var(--danger,#ef4444) 16%,transparent);color:var(--danger,#f87171)}
++.ban-reason{margin:0;font-size:14px;line-height:1.45;overflow-wrap:anywhere}
++.ban-meta{font-size:12.5px}
++.ban-meta .ban-who{font-size:12.5px;font-weight:700}
++.ban-act{display:flex;justify-content:flex-end}
++.ban-owner{font-size:12.5px}
++
++/* Every on/off in the panel is the network's switch (2026-11-13, the owner: "the whole entire admin panel needs to use
++ openvibe.design, especially for like these checkboxes"): core.js marks a lone checkbox role="switch" and ov-forms draws
++ it; nothing here may stretch it to a field's height. */
++.admin-clarity input[type=checkbox], .admin-clarity input[type=radio], #platform-ai input[type=checkbox] { min-height:0; }
ddiff --git a/public/js/admin/content.js b/public/js/admin/content.js
iindex c7b39c94..697bd038 100644
---- a/public/js/admin/content.js
++++ b/public/js/admin/content.js
@@@ -331,7 +331,7 @@ async function loadEmail() {
</div>
</div>
-- <form id="email-form" style="max-width:600px;margin-bottom:16px">
++ ${data.can_edit === false ? '<p class="muted email-view-only"><i class="fa-solid fa-lock"></i> Only the owner changes how email is sent. You see the delivery numbers.</p>' : `<form id="email-form" style="max-width:600px;margin-bottom:16px">
<div class="setting-row">
<label for="email-enabled"><strong>Send email</strong></label>
<input type="checkbox" id="email-enabled" aria-label="Send outgoing email" aria-describedby="help-email-enabled" ${email.enabled ? 'checked' : ''} style="width:18px;height:18px;cursor:pointer"><span class="admin-help" id="help-email-enabled">Off by default, so no email is sent. Turn it on after configuring Resend; turning it off stops delivery, and turning it on resumes it.</span>
@@@ -355,9 +355,9 @@ async function loadEmail() {
</div>
<div style="background:#2a2a3822;border:1px solid #333340;border-radius:8px;padding:12px 14px;margin-top:16px;font-size:12px;color:var(--text-muted)">
<strong style="color:var(--text)"><i class="fa-solid fa-globe"></i> DNS Setup</strong><br>
-- Add your domain at <a href="https://resend.com/domains" target="_blank" style="color:var(--accent)">resend.com/domains</a> and add the SPF (TXT + MX) and DKIM records to your DNS provider. Once verified, emails will be sent from your domain.
++ Add your domain at <a href="https://resend.com/domains" target="_blank" rel="noopener" style="color:var(--accent)">resend.com/domains</a> and add the SPF (TXT + MX) and DKIM records to your DNS provider. Once verified, emails will be sent from your domain.
</div>
-- </form>
++ </form>`}
<div class="card" style="padding:16px;margin-bottom:16px">
<h3 style="margin-bottom:10px"><i class="fa-solid fa-layer-group"></i> Delivery breakdown · last 30 days</h3><p class="admin-intro">This counts sent and failed messages by type over the last 30 days. Review it when one kind of email is failing more often than others.</p>
@@@ -377,7 +377,7 @@ async function loadEmail() {
: '<p class="muted">No email deliveries recorded. Save the configuration and send a test email to check delivery.</p>'}
</div>
-- <div class="card" style="padding:16px">
++ ${data.can_edit === false ? '' : `<div class="card" style="padding:16px">
<h3 style="margin-bottom:10px"><i class="fa-solid fa-clock-rotate-left"></i> Recent Email Activity</h3><p class="admin-intro">These are the most recent email delivery attempts and provider errors. Check a failed row after a delivery report, then correct the configuration and send a new test.</p>
${recent.length ? `
<table class="admin-table">
@@@ -396,7 +396,7 @@ async function loadEmail() {
</tbody>
</table>`
: '<p class="muted">No email deliveries recorded. Save the configuration and send a test email to check delivery.</p>'}
-- </div>`;
++ </div>`}`;
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
}
ddiff --git a/public/js/admin/core.js b/public/js/admin/core.js
iindex b9952b03..fe4ef66a 100644
---- a/public/js/admin/core.js
++++ b/public/js/admin/core.js
@@@ -17,6 +17,22 @@ let currentUser = null;
// short inside value="..." — which is how JSON settings got rendered as '{' and saved back as '{'.
function esc(s) { return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
++/**
++ * Every on/off in the panel is the network's switch (openvibe-shared ov-forms: a checkbox with role="switch"; 2026-11-13,
++ * the owner: "these checkboxes … use openvibe.design"). A checkbox in a table picks rows, so it stays a checkbox;
++ * data-ov-native keeps the browser's own.
++ */
++(function adminSwitches() {
++ if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return;
++ const one = (c) => { if (c.type === 'checkbox' && !c.hasAttribute('role') && !c.closest('table, [data-ov-native]')) c.setAttribute('role', 'switch'); };
++ const mark = (root) => { if (root.matches && root.matches('input[type="checkbox"]')) one(root); if (root.querySelectorAll) root.querySelectorAll('input[type="checkbox"]').forEach(one); };
++ const start = () => {
++ mark(document);
++ new MutationObserver((list) => { for (const m of list) for (const n of m.addedNodes) if (n.nodeType === 1) mark(n); }).observe(document.body, { childList: true, subtree: true });
++ };
++ if (document.body) start(); else document.addEventListener('DOMContentLoaded', start, { once: true });
++})();
++
function timeAgo(dateStr) {
const d = new Date(dateStr);
const s = Math.floor((Date.now() - d.getTime()) / 1000);
@@@ -81,7 +97,9 @@ let currentTab = 'dashboard';
// NOTE: 'ai' is NOT here — admins may use its analytics/explorer tools; only the
// AI configuration form inside it is owner-gated (see loadAi).
// 'setup' is the owner's checklist of keys and accounts (js/admin/setup.js); its API answers 403 to anyone else too.
--const OWNER_ONLY_TABS = new Set(['guide', 'setup', 'ai-setup', 'deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings', 'grants', 'eventsops']);
++// The owner's alone (2026-11-13: Notifications too, "we don't want admins to be able to do this"). Email is open to
++// admins as a read-only view of its delivery numbers; its settings and changes are the owner's (server/admin/routes.js).
++const OWNER_ONLY_TABS = new Set(['guide', 'setup', 'ai-setup', 'deploy', 'domains', 'payments', 'cashouts', 'storage', 'notifications', 'settings', 'grants', 'eventsops']);
function showTab(id, btn) {
if (!tabLoaders[id]) id = 'dashboard';
ddiff --git a/public/js/admin/moderation.js b/public/js/admin/moderation.js
iindex 267e83d6..1fc02b8f 100644
---- a/public/js/admin/moderation.js
++++ b/public/js/admin/moderation.js
@@@ -14,24 +14,23 @@ async function loadStreams() {
const data = await api('/api/admin/streamer/streams');
const streams = data.streams || [];
if (!streams.length) { c.innerHTML = '<p class="muted">No streams are live. Return when someone starts broadcasting.</p>'; return; }
-- c.innerHTML = `
-- <table class="admin-table">
-- <thead><tr>
-- <th>Title</th><th>Streamer</th><th>Protocol</th><th>Viewers</th><th>Started</th><th>Actions</th>
-- </tr></thead>
-- <tbody>${streams.map(s => `
-- <tr>
-- <td>${esc(s.title || 'Untitled')}</td>
-- <td><strong>${esc(s.username || '-')}</strong></td>
-- <td><span class="badge badge-info">${esc(s.protocol)}</span></td>
-- <td>${s.viewer_count || 0}</td>
-- <td>${s.started_at ? timeAgo(s.started_at) : '-'}</td>
-- <td>
-- <button class="btn btn-sm btn-danger" onclick="endStream('${s.id}')"><i class="fa-solid fa-stop"></i> End broadcast</button><span class="admin-help">Disconnects viewers; the streamer must restart to resume.</span>
-- </td>
-- </tr>
-- `).join('')}</tbody>
-- </table>`;
++ // Each ban is a card (the owner, 11-13: on a phone the table scrolled who and why out of sight): who, as a name
++ // that opens their card; the reason; who banned them and when; how long; and, for the owner, lifting it.
++ const when = (t) => (t ? `${timeAgo(t)}` : '');
++ const nameTag = (u, id) => u
++ ? `<a class="ban-who" href="https://openvibe.network/@${encodeURIComponent(u)}" data-ov-user="${esc(u)}">@${esc(u)}</a>`
++ : `<span class="ban-who muted">user ${esc(String(id))}</span>`;
++ c.innerHTML = `<p class="muted ban-count">${bans.length === 1 ? '1 person is' : `${bans.length} people are`} banned across the site.</p>
++ <ul class="ban-list">${bans.map((b) => `
++ <li class="ban-card">
++ <div class="ban-top">${nameTag(b.username, b.user_id)}<span class="ban-len ${b.expires_at ? '' : 'is-perm'}">${b.expires_at ? `until ${esc(new Date(b.expires_at).toLocaleDateString())}` : 'Permanent'}</span></div>
++ <p class="ban-reason">${b.reason ? esc(b.reason) : '<span class="muted">No reason was written.</span>'}</p>
++ <div class="ban-meta muted">Banned ${b.banned_by_username ? `by ${nameTag(b.banned_by_username)}` : ''} ${esc(when(b.created_at))}</div>
++ <div class="ban-act">${owner
++ ? `<button class="btn btn-sm btn-outline" onclick="unbanStreamer('${esc(String(b.user_id))}')"><i class="fa-solid fa-user-check"></i> Allow access again</button>`
++ : '<span class="muted ban-owner" title="Only the owner can lift a site-wide ban">Only the owner can lift it</span>'}</div>
++ </li>`).join('')}</ul>`;
++ if (window.OVUser && typeof window.OVUser.bind === 'function') { try { window.OVUser.bind(c); } catch (e) { /* the card is optional */ } }
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
}
@@@ -82,6 +81,8 @@ async function loadBans() {
try {
const data = await api('/api/admin/streamer/bans');
const bans = data.bans || [];
++ // Only the owner lifts a site-wide ban (2026-11-13); admins see the list, who and why.
++ const owner = !!(typeof currentUser !== 'undefined' && currentUser && currentUser.is_owner);
if (!bans.length) { c.innerHTML = '<p class="muted">No active bans. Review Users if an account needs a restriction.</p>'; return; }
c.innerHTML = `
<table class="admin-table">
@@@ -95,9 +96,9 @@ async function loadBans() {
<td>${esc(b.banned_by_username || '-')}</td>
<td>${b.created_at ? timeAgo(b.created_at) : '-'}</td>
<td>${b.expires_at ? new Date(b.expires_at).toLocaleString() : 'Permanent'}</td>
-- <td>
-- <button class="btn btn-sm btn-outline" onclick="unbanStreamer('${b.user_id}')"><i class="fa-solid fa-user-check"></i> Allow access again</button>
-- </td>
++ <td>${owner
++ ? `<button class="btn btn-sm btn-outline" onclick="unbanStreamer('${esc(String(b.user_id))}')"><i class="fa-solid fa-user-check"></i> Allow access again</button>`
++ : '<span class="muted" title="Only the owner can lift a site-wide ban">Owner only</span>'}</td>
</tr>
`).join('')}</tbody>
</table>`;
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 5b370417..7ac552f8 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -228,11 +228,18 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// Email Configuration (Resend)
// ═══════════════════════════════════════════════════════
-- // GET /api/admin/email — get current email config (owner-only: exposes key metadata)
-- router.get('/email', requireOwner, async (req, res) => {
++ // GET /api/admin/email — the delivery numbers for every admin; the configuration (key metadata), the recent messages
++ // (they carry addresses) and the right to change anything are the owner's (2026-11-13, the owner: "admin should only
++ // be able to view the basic stats on this page, no editing, while owner can edit").
++ router.get('/email', async (req, res) => {
try {
++ const metrics = await getEmailMetrics();
++ if (!isOwner(req.user)) {
++ const status = await emailService.getStatus();
++ return res.json({ ok: true, can_edit: false, email: { enabled: !!(status && status.enabled) }, metrics: { summary: metrics.summary || {}, by_type: metrics.by_type || [] } });
++ }
const status = await emailService.getStatus();
-- res.json({ ok: true, email: status, metrics: await getEmailMetrics() });
++ res.json({ ok: true, can_edit: true, email: status, metrics });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
@@@ -805,7 +812,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// Broadcast Notifications
// ═══════════════════════════════════════════════════════
-- router.post('/broadcast', async (req, res) => {
++ // A message to every person on the network is the owner's to send (2026-11-13).
++ router.post('/broadcast', requireOwner, async (req, res) => {
try {
const { title, message, icon, url, priority, category } = req.body;
if (!title) return res.status(400).json({ ok: false, error: 'Title required' });
ddiff --git a/server/admin/service-proxies.js b/server/admin/service-proxies.js
iindex 9067e519..05d7db34 100644
---- a/server/admin/service-proxies.js
++++ b/server/admin/service-proxies.js
@@@ -67,7 +67,12 @@ function createServiceProxies({ requireAuth, signToken, services }) {
const chat = () => services.chat.internalUrl;
r.use('/tools-workers', require('./tools-workers').createToolsWorkers({ requireAuth, signToken, services }));
-- r.use('/streamer', requireAuth, admin, to(live, '/api/admin', 'OpenVibe.Live', 'Streamer proxy error'));
++ // Lifting a site-wide ban is the owner's alone (the owner, 2026-11-13: "admins can't unban … only owner rank, but they
++ // can view"); Live checks it too, so this answers first and plainly.
++ const { isOwner } = require('../auth/owner-guard');
++ const ownerUnban = (req, res, next) => (req.method === 'DELETE' && /^\/(?:users\/[^/]+\/ban|ban\/[^/]+)\/?$/.test(req.path) && !isOwner(req.user)
++ ? res.status(403).json({ error: 'Only the owner can lift a site-wide ban', code: 'owner_only' }) : next());
++ r.use('/streamer', requireAuth, admin, ownerUnban, 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'));
ddiff --git a/test/admin-bans-switches.test.js b/test/admin-bans-switches.test.js
nnew file mode 100644
iindex 00000000..5f0cab88
---- /dev/null
++++ b/test/admin-bans-switches.test.js
@@@ -0,0 +1,28 @@
++'use strict';
++// The admin panel, 2026-11-13 (the owner's phone): bans are cards that show who (a name that opens their card), the
++// reason and who banned them, and only the owner sees "Allow access again"; every on/off is the network's switch, never
++// a checkbox stretched to a text field's height.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
++
++const mod = read('public/js/admin/moderation.js');
++assert.ok(/const owner = !!\(typeof currentUser !== 'undefined' && currentUser && currentUser\.is_owner\);/.test(mod), 'the owner is known on the page');
++assert.ok(/<li class="ban-card">/.test(mod) && /data-ov-user="\$\{esc\(u\)\}"/.test(mod), 'each ban is a card with a name that opens the person\'s card');
++assert.ok(/No reason was written\./.test(mod), 'a ban without a reason says so');
++assert.ok(/\$\{owner\s*\?\s*`<button[^`]*unbanStreamer/.test(mod) && /Only the owner can lift it/.test(mod), 'only the owner gets the button');
++
++const core = read('public/js/admin/core.js');
++assert.ok(/function adminSwitches\(\)/.test(core) && /c\.setAttribute\('role', 'switch'\)/.test(core) && /c\.closest\('table, \[data-ov-native\]'\)/.test(core), 'a lone checkbox becomes the switch; a table\'s stays a checkbox');
++const css = read('public/css/admin.css');
++assert.ok(css.includes('.admin-clarity :is(input:not([type=checkbox]):not([type=radio]),select,textarea) { max-width:100%; min-height:44px;'), 'the field height is for fields only');
++assert.ok(css.includes('.admin-clarity input[type=checkbox], .admin-clarity input[type=radio], #platform-ai input[type=checkbox] { min-height:0; }'), 'and never for a switch');
++// Email: every admin sees the delivery numbers; the settings, the recent messages (addresses) and changes are the owner's.
++const routes = read('server/admin/routes.js');
++assert.ok(/router\.get\('\/email', async \(req, res\) => \{[\s\S]{0,300}if \(!isOwner\(req\.user\)\) \{[\s\S]{0,300}can_edit: false/.test(routes), 'an admin reads the numbers only');
++assert.ok(/router\.put\('\/email', requireOwner/.test(routes) && /router\.post\('\/broadcast', requireOwner/.test(routes), 'changing email and broadcasting are the owner\'s');
++const content = read('public/js/admin/content.js');
++assert.ok(/data\.can_edit === false \? '<p class="muted email-view-only">/.test(content), 'the page says it is read-only');
++assert.ok(/OWNER_ONLY_TABS = new Set\(\[[^\]]*'notifications'/.test(core) && !/OWNER_ONLY_TABS = new Set\(\[[^\]]*'email'/.test(core), 'Notifications is the owner\'s tab; Email is open to read');
++console.log('admin-bans-switches: bans as cards with who and why, unban for the owner only, every on/off a switch');
ddiff --git a/test/admin-service-proxies.test.js b/test/admin-service-proxies.test.js
iindex 67432774..bdfee058 100644
---- a/test/admin-service-proxies.test.js
++++ b/test/admin-service-proxies.test.js
@@@ -63,6 +63,23 @@ function upstream(name) {
const reset = () => { live.seen.length = 0; chat.seen.length = 0; };
try {
++ // ── Lifting a site-wide ban is the owner's (2026-11-13) ──────────
++ {
++ const ownerUser = { id: 9, role: 'admin', username: process.env.OWNER_USERNAME || 'goosely' };
++ const adminNamed = { id: 1, role: 'admin', username: 'someadmin' };
++ reset();
++ let u = await call(adminNamed, 'DELETE', '/api/admin/streamer/users/5/ban');
++ assert.deepStrictEqual([u.status, u.json && u.json.code], [403, 'owner_only'], 'an admin may not lift a site-wide ban');
++ u = await call(adminNamed, 'DELETE', '/api/admin/streamer/ban/77');
++ assert.strictEqual(u.status, 403, 'nor by its ban id');
++ assert.strictEqual(live.seen.length, 0, 'Live never hears it');
++ u = await call(adminNamed, 'GET', '/api/admin/streamer/bans');
++ assert.strictEqual(u.status, 200, 'an admin still sees the list');
++ u = await call(ownerUser, 'DELETE', '/api/admin/streamer/users/5/ban');
++ assert.strictEqual(u.status, 200, 'the owner may');
++ assert.strictEqual(live.seen.at(-1).url, '/api/admin/users/5/ban');
++ reset();
++ }
// ── TTS → Chat ───────────────────────────────────────────────
let r = await call(admin, 'GET', '/api/admin/streamer-tts/admin/settings');
assert.strictEqual(r.status, 200);