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
Changes
@@@ -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;@@@ -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')">@@@ -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');}@@@ -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(() => {});nnew file mode 100644@@@ -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);++});@@@ -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);
@@ -1486,6 +1486,23 @@
@@ -1486,6 +1486,23 @@
line-height: 1.5;
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 {
.bc-step-text {
display: flex;
display: flex;
flex-direction: column;
flex-direction: column;
@@ -20,6 +20,16 @@
@@ -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>
<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>
<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 -->
<!-- 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">
<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')">
<button class="bc-top-tab" data-bctab="active" style="display:none" onclick="switchBroadcastTopTab('active')">
@@ -2521,6 +2521,8 @@ function _wsFormatDuration(secs) {
@@ -2521,6 +2521,8 @@ function _wsFormatDuration(secs) {
/* ── Managed stream CRUD helpers ─────────────────────────────── */
/* ── Managed stream CRUD helpers ─────────────────────────────── */
function showCreateManagedStreamModal() {
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');
showModal('create-managed-stream');
}
}
@@ -866,8 +866,27 @@ function ensureBroadcastChat(streamId) {
@@ -866,8 +866,27 @@ function ensureBroadcastChat(streamId) {
if (typeof initChat === 'function' && streamId) initChat(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 ───────────────────────────────── */
/* ── Initialize Broadcast Page ───────────────────────────────── */
async function loadBroadcastPage() {
async function loadBroadcastPage() {
+ if (!_broadcastSignInGate(!!currentUser)) {
+ showStreamManager();
+ return;
+ }
loadBroadcastSettings();
loadBroadcastSettings();
loadRobotStreamerIntegration().catch(() => {});
loadRobotStreamerIntegration().catch(() => {});
loadRestreamDestinations().catch(() => {});
loadRestreamDestinations().catch(() => {});
new file mode 100644
new file mode 100644
@@ -0,0 +1,152 @@
@@ -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);
+});
@@ -195,6 +195,35 @@ const pageState = `(() => ({
@@ -195,6 +195,35 @@ const pageState = `(() => ({
cdp.close();
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.
// 4. Leak check over repeated laps.
{
{
const { cdp, errors } = await openPage(1366);
const { cdp, errors } = await openPage(1366);