openvibers/openvibe.live Commits

Go Live needs an account, and a guest is told so: anonymous /broadcast shows a sign-in gate (Sign in with OpenVibe, back to /broadcast) in place of the slot workspace, and loadBroadcastPage returns before any slot, restream or RobotStreamer request. The page used to render the workspace for a guest, swallow the 401s and offer "Create stream slot"; the create-slot entry point now refuses a guest too. test/broadcast-signin-gate.test.js runs the page's own functions as a guest (only the gate and showStreamManager are reached) and signed in, checks the markup and styles, and checks the server still answers 401 to slot and stream creation without a session and writes nothing; the browser smoke checks the gate at 412px, direct and by SPA navigation, with no writes sent

OP OpenVibers committed 24 Sept 2026 · ae4842882f · 6 files, +229 −0


Changes

ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex 4cde8430..ebe81d8e 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -1486,6 +1486,23 @@
line-height: 1.5;
}
++/* Guests get the sign-in gate and nothing else of the Go Live workspace (broadcast.js). */
++#bc-stream-manager.bc-guest > :not(h2):not(.bc-signin-gate) { display: none !important; }
++.bc-signin-gate {
++ max-width: 520px;
++ margin: 24px auto;
++ padding: 28px 20px;
++ text-align: center;
++ border: 1px solid var(--border);
++ border-radius: var(--radius-lg, 12px);
++ background: var(--bg-secondary);
++}
++.bc-signin-gate[hidden] { display: none; }
++.bc-signin-gate-icon { font-size: 2rem; color: var(--accent); margin-bottom: 12px; }
++.bc-signin-gate h3 { margin: 0 0 8px; }
++.bc-signin-gate p { color: var(--text-secondary); line-height: 1.5; margin: 0 0 16px; overflow-wrap: anywhere; }
++.bc-signin-gate .bc-signin-gate-fine { font-size: 0.8rem; color: var(--text-muted); margin: 12px 0 0; }
++
.bc-step-text {
display: flex;
flex-direction: column;
ddiff --git a/public/fragments/broadcast.html b/public/fragments/broadcast.html
iindex 2667ddd8..5e944397 100644
---- a/public/fragments/broadcast.html
++++ b/public/fragments/broadcast.html
@@@ -20,6 +20,16 @@
<h2><i class="fa-solid fa-tower-broadcast"></i> Go Live <button type="button" class="btn btn-outline btn-sm glw-open-btn" onclick="openSetupHub()" title="Everything you can set up, step by step"><i class="fa-solid fa-list-check"></i> Streamer setup</button></h2>
<p class="bc-page-subtitle">Select a stream slot to configure your profile and go live.</p>
++ <!-- Guests: going live needs an account. broadcast.js shows this in place of the slot
++ workspace and loads nothing else (_broadcastSignInGate). -->
++ <div class="bc-signin-gate" id="bc-signin-gate" hidden>
++ <i class="fa-solid fa-user-lock bc-signin-gate-icon" aria-hidden="true"></i>
++ <h3>Sign in to go live</h3>
++ <p>Stream slots, stream keys and restreams belong to your OpenVibe account. Sign in and this page opens your stream slots.</p>
++ <a class="btn btn-primary" href="/api/auth/sso/login?next=%2Fbroadcast"><i class="fa-solid fa-right-to-bracket"></i> Sign in with OpenVibe</a>
++ <p class="bc-signin-gate-fine">No account yet? One is made when you sign in.</p>
++ </div>
++
<!-- Top-level tabs — the "Active Streams" tab only appears when a stream is live -->
<div class="bc-top-tabs" id="bc-top-tabs" style="display:none">
<button class="bc-top-tab" data-bctab="active" style="display:none" onclick="switchBroadcastTopTab('active')">
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex 69efd08b..435f220f 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -2521,6 +2521,8 @@ function _wsFormatDuration(secs) {
/* ── Managed stream CRUD helpers ─────────────────────────────── */
function showCreateManagedStreamModal() {
++ // A guest cannot own a slot: show the sign-in gate instead of a form that would only 401.
++ if (!currentUser) { if (typeof _broadcastSignInGate === 'function') _broadcastSignInGate(false); return; }
showModal('create-managed-stream');
}
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex d7a5b7b1..47acbddc 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -866,8 +866,27 @@ function ensureBroadcastChat(streamId) {
if (typeof initChat === 'function' && streamId) initChat(streamId);
}
++/* ── Sign-in gate ────────────────────────────────────────────── */
++/**
++ * Going live needs an OpenVibe account. For a guest, /broadcast shows the sign-in gate in place of
++ * the slot workspace and nothing else runs: no slot, restream or RobotStreamer request, and no way
++ * to create anything (the empty workspace used to offer "Create stream slot" and swallow the 401).
++ * Returns whether the page may load.
++ */
++function _broadcastSignInGate(signedIn) {
++ const gate = document.getElementById('bc-signin-gate');
++ const manager = document.getElementById('bc-stream-manager');
++ if (gate) gate.hidden = !!signedIn;
++ if (manager) manager.classList.toggle('bc-guest', !signedIn);
++ return !!signedIn;
++}
++
/* ── Initialize Broadcast Page ───────────────────────────────── */
async function loadBroadcastPage() {
++ if (!_broadcastSignInGate(!!currentUser)) {
++ showStreamManager();
++ return;
++ }
loadBroadcastSettings();
loadRobotStreamerIntegration().catch(() => {});
loadRestreamDestinations().catch(() => {});
ddiff --git a/test/broadcast-signin-gate.test.js b/test/broadcast-signin-gate.test.js
nnew file mode 100644
iindex 00000000..5da01e62
---- /dev/null
++++ b/test/broadcast-signin-gate.test.js
@@@ -0,0 +1,152 @@
++/**
++ * Anonymous Go Live: a guest on /broadcast gets a sign-in gate and nothing is created.
++ *
++ * The page used to render the slot workspace for a guest: its requests answered 401, the 401 was
++ * swallowed, and the empty state offered "Create stream slot". Now loadBroadcastPage() shows the
++ * gate and returns before any request; the create-slot entry point refuses a guest; and the
++ * server still answers 401 to a slot creation without a session and writes nothing.
++ *
++ * node test/broadcast-signin-gate.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++const root = path.join(__dirname, '..');
++const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
++
++/** The source of a top-level function declaration, braces matched. */
++function extract(src, name) {
++ const m = new RegExp(`(?:async )?function ${name}\\(`).exec(src);
++ assert.ok(m, `${name}() must exist`);
++ let i = src.indexOf('{', m.index);
++ let depth = 0;
++ for (; i < src.length; i++) {
++ if (src[i] === '{') depth++;
++ else if (src[i] === '}' && --depth === 0) return src.slice(m.index, i + 1);
++ }
++ throw new Error(`unbalanced ${name}`);
++}
++
++/** Minimal DOM: the elements the gate touches, plus a recorder for everything else. */
++function fakeDom() {
++ const classes = new Set();
++ const els = {
++ 'bc-signin-gate': { hidden: true },
++ 'bc-stream-manager': {
++ style: {},
++ classList: {
++ toggle: (c, on) => { if (on) classes.add(c); else classes.delete(c); },
++ contains: (c) => classes.has(c),
++ },
++ },
++ };
++ return { els, document: { getElementById: (id) => els[id] || null, querySelector: () => null } };
++}
++
++/**
++ * Runs the page's own functions with every free identifier they touch recorded: a guest must
++ * reach nothing but the gate and showStreamManager().
++ */
++function load(files, names, fixtures) {
++ const calls = [];
++ const scope = new Proxy({}, {
++ has: (t, k) => typeof k === 'string',
++ get: (t, k) => {
++ if (k === Symbol.unscopables) return undefined;
++ if (Object.prototype.hasOwnProperty.call(fixtures, k)) return fixtures[k];
++ if (k in globalThis) return globalThis[k];
++ return (...args) => { calls.push(k); return Promise.resolve(null); };
++ },
++ set: (t, k, v) => { fixtures[k] = v; return true; },
++ });
++ const src = names.map(([file, name]) => extract(files[file], name)).join('\n');
++ // eslint-disable-next-line no-new-func
++ const fns = new Function('scope', `with (scope) { ${src}\n return { ${names.map(([, n]) => n).join(', ')} }; }`)(scope);
++ return { fns, calls };
++}
++
++const files = { bc: read('public/js/broadcast.js'), ws: read('public/js/broadcast-workspace.js') };
++const NAMES = [['bc', '_broadcastSignInGate'], ['bc', 'loadBroadcastPage'], ['ws', 'showCreateManagedStreamModal']];
++
++(async () => {
++ // ── A guest: the gate, and nothing else ──────────────────────────────────────────
++ {
++ const dom = fakeDom();
++ const { fns, calls } = load(files, NAMES, { currentUser: null, document: dom.document, broadcastState: { streams: new Map() } });
++ await fns.loadBroadcastPage();
++ assert.strictEqual(dom.els['bc-signin-gate'].hidden, false, 'the sign-in gate is shown');
++ assert.ok(dom.els['bc-stream-manager'].classList.contains('bc-guest'), 'the workspace is hidden behind it');
++ assert.deepStrictEqual(calls, ['showStreamManager'], `a guest triggers no request and no loader (got ${calls.join(', ')})`);
++
++ calls.length = 0;
++ fns.showCreateManagedStreamModal();
++ assert.ok(!calls.includes('showModal'), 'a guest cannot open the create-slot form');
++ assert.strictEqual(dom.els['bc-signin-gate'].hidden, false);
++ }
++
++ // ── Signed in: no gate, the workspace loads ──────────────────────────────────────
++ {
++ const dom = fakeDom();
++ dom.els['bc-signin-gate'].hidden = false;
++ const { fns, calls } = load(files, NAMES, { currentUser: { id: 1, username: 'alex' }, document: dom.document, broadcastState: { streams: new Map(), activeStreamId: null } });
++ await fns.loadBroadcastPage();
++ assert.strictEqual(dom.els['bc-signin-gate'].hidden, true, 'no gate for a signed-in user');
++ assert.ok(!dom.els['bc-stream-manager'].classList.contains('bc-guest'));
++ for (const fn of ['loadBroadcastSettings', 'initBroadcastWorkspace', 'loadRestreamDestinations']) {
++ assert.ok(calls.includes(fn), `a signed-in user loads ${fn}()`);
++ }
++ calls.length = 0;
++ fns.showCreateManagedStreamModal();
++ assert.deepStrictEqual(calls, ['showModal'], 'a signed-in user gets the create-slot form');
++ }
++
++ // ── The markup and styles exist ──────────────────────────────────────────────────
++ const fragment = read('public/fragments/broadcast.html');
++ const gate = /<div class="bc-signin-gate" id="bc-signin-gate" hidden>([\s\S]*?)<\/div>/.exec(fragment);
++ assert.ok(gate, 'the broadcast fragment carries the gate, hidden until the script decides');
++ assert.ok(gate[1].includes('href="/api/auth/sso/login?next=%2Fbroadcast"'), 'the gate signs in and comes back to /broadcast');
++ assert.ok(!/free|\$0/i.test(gate[1]), 'no "free" copy');
++ assert.ok(fragment.indexOf('id="bc-signin-gate"') < fragment.indexOf('id="bc-workspace"'), 'the gate sits above the workspace');
++ const css = read('public/css/features/broadcast.css');
++ assert.ok(/#bc-stream-manager\.bc-guest > :not\(h2\):not\(\.bc-signin-gate\)\s*\{\s*display:\s*none/.test(css), 'the guest class hides the workspace');
++
++ // ── The server creates nothing for a guest ──────────────────────────────────────
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-bc-gate-'));
++ process.env.DB_PATH = path.join(tmp, 'live.db');
++ process.env.NODE_ENV = 'test';
++ const quiet = console.log;
++ console.log = () => {};
++ console.warn = () => {};
++ const db = require('../server/db/database');
++ db.initDb();
++ const express = require('express');
++ const app = express();
++ app.use(express.json());
++ app.use('/api/streams', require('../server/streaming/routes'));
++ const server = http.createServer(app).listen(0);
++ const post = (p, body) => new Promise((resolve, reject) => {
++ const data = JSON.stringify(body);
++ const req = http.request({ port: server.address().port, path: p, method: 'POST', headers: { 'content-type': 'application/json' } }, (res) => {
++ res.resume();
++ res.on('end', () => resolve(res.statusCode));
++ });
++ req.on('error', reject);
++ req.end(data);
++ });
++ const before = db.getDb().prepare('SELECT COUNT(*) AS n FROM managed_streams').get().n;
++ assert.strictEqual(await post('/api/streams/managed', { title: 'guest slot' }), 401, 'creating a slot without a session is refused');
++ assert.strictEqual(await post('/api/streams', { title: 'guest stream' }), 401, 'starting a stream without a session is refused');
++ assert.strictEqual(db.getDb().prepare('SELECT COUNT(*) AS n FROM managed_streams').get().n, before, 'and nothing was created');
++ assert.strictEqual(db.getDb().prepare('SELECT COUNT(*) AS n FROM users').get().n, 0, 'no identity was created either');
++ server.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ quiet('broadcast-signin-gate: ok');
++ process.exit(0);
++})().catch((err) => {
++ console.error(err);
++ process.exit(1);
++});
ddiff --git a/test/browser/smoke.js b/test/browser/smoke.js
iindex 95bb6a15..1afc49a6 100644
---- a/test/browser/smoke.js
++++ b/test/browser/smoke.js
@@@ -195,6 +195,35 @@ const pageState = `(() => ({
cdp.close();
}
++ // 3c. A guest on /broadcast gets the sign-in gate, not the slot workspace, and sends no write.
++ if (!ONLY_SIGNED && !ROUTES_ONLY) {
++ const { cdp, errors } = await openPage(412);
++ const writes = [];
++ cdp.on('Network.requestWillBeSent', (p) => { if (p.request.method !== 'GET' && p.request.method !== 'HEAD' && p.request.url.includes('/api/')) writes.push(`${p.request.method} ${p.request.url.replace(BASE, '')}`); });
++ for (const mode of ['direct', 'spa']) {
++ errors.length = 0;
++ writes.length = 0;
++ if (mode === 'direct') { await cdp.send('Page.navigate', { url: BASE + '/broadcast' }); await sleep(SETTLE); }
++ else { await cdp.send('Page.navigate', { url: BASE + '/' }); await sleep(SETTLE); await cdp.evaluate('navigate("/broadcast")'); await sleep(SETTLE); }
++ const st = await cdp.evaluate(`(() => {
++ const gate = document.getElementById('bc-signin-gate');
++ const ws = document.getElementById('bc-workspace');
++ return { user: typeof currentUser !== 'undefined' && !!currentUser, gate: !!gate && !gate.hidden && gate.offsetParent !== null,
++ workspace: !!ws && ws.offsetParent !== null, link: gate && gate.querySelector('a[href^="/api/auth/sso/login"]') ? true : false };
++ })()`);
++ const label = `guest ${mode} /broadcast: sign-in gate`;
++ const problems = [];
++ if (st.user) problems.push('a user is signed in (run without TOKEN in the browser profile)');
++ if (!st.gate) problems.push('gate not visible');
++ if (!st.link) problems.push('gate has no sign-in link');
++ if (st.workspace) problems.push('slot workspace visible to a guest');
++ if (writes.length) problems.push(`writes sent: ${writes.slice(0, 3).join(', ')}`);
++ if (errors.length) problems.push(`errors: ${errors.slice(0, 2).join(' | ')}`);
++ if (problems.length) fail(`${label}: ${problems.join('; ')}`); else pass(label);
++ }
++ cdp.close();
++ }
++
// 4. Leak check over repeated laps.
{
const { cdp, errors } = await openPage(1366);