openvibers/openvibe.live Commits
Staff scope on streams: an admin may moderate another streamer's slot or stream (edit, end, delete, read status) but never the site owner's, and nobody acts as another streamer: regenerating a stream key, the ingest endpoint, going live on another's slot, heartbeats, call settings and WHIP publishing are the streamer's alone (they had an admin bypass). test/staff-stream-scope.test.js
Changes
@@@ -88,6 +88,17 @@ const MAX_PANELS_LENGTH = 20000;const { pushBulkNotification } = require('../utils/notify');const { publicManagedStream, publicChannel, publicStream } = require('../web/serializers');++/**++ * Staff may moderate someone else's stream or slot (edit, end, delete, read status) but never the site++ * owner's, and never act AS its streamer: reading or regenerating a stream key, the ingest endpoint,++ * going live on a slot, heartbeats, call settings and WHIP publishing stay with the streamer alone.++ */++function staffMayModerate(actor, ownerUserId) {++ if (!actor || actor.role !== 'admin') return false;++ const target = db.getUserById(ownerUserId);++ return !(target && target.is_owner);++}++/** A control profile id a slot may point at: one of the caller's own (admins may use anyone's). */function ownControlConfigId(req, raw) {if (raw === null || raw === undefined || raw === '') return null;@@@ -1684,7 +1695,7 @@ router.put('/managed/:id', requireAuth, (req, res) => {const msId = parseInt(req.params.id);const ms = db.getManagedStreamById(msId);if (!ms) return res.status(404).json({ error: 'Managed stream not found' });-- if (ms.user_id !== req.user.id && req.user.role !== 'admin') {++ if (ms.user_id !== req.user.id && !staffMayModerate(req.user, ms.user_id)) {return res.status(403).json({ error: 'Not your managed stream' });}@@@ -1844,7 +1855,7 @@ router.delete('/managed/:id', requireAuth, (req, res) => {const msId = parseInt(req.params.id);const ms = db.getManagedStreamById(msId);if (!ms) return res.status(404).json({ error: 'Managed stream not found' });-- if (ms.user_id !== req.user.id && req.user.role !== 'admin') {++ if (ms.user_id !== req.user.id && !staffMayModerate(req.user, ms.user_id)) {return res.status(403).json({ error: 'Not your managed stream' });}@@@ -1869,7 +1880,7 @@ router.post('/managed/:id/regenerate-key', requireAuth, async (req, res) => {const msId = parseInt(req.params.id);const ms = db.getManagedStreamById(msId);if (!ms) return res.status(404).json({ error: 'Managed stream not found' });-- if (ms.user_id !== req.user.id && req.user.role !== 'admin') {++ if (ms.user_id !== req.user.id) {return res.status(403).json({ error: 'Not your managed stream' });}@@@ -1952,7 +1963,7 @@ router.post('/', requireAuth, (req, res) => {if (managedStreamId) {managedStream = db.getManagedStreamById(managedStreamId);if (!managedStream) return res.status(404).json({ error: 'Managed stream not found' });-- if (managedStream.user_id !== req.user.id && req.user.role !== 'admin') {++ if (managedStream.user_id !== req.user.id) {return res.status(403).json({ error: 'Not your managed stream' });}} else {@@@ -1996,7 +2007,7 @@ router.post('/', requireAuth, (req, res) => {if (!config) {return res.status(404).json({ error: 'Control config not found' });}-- if (config.user_id !== req.user.id && req.user.role !== 'admin') {++ if (config.user_id !== req.user.id) {return res.status(403).json({ error: 'Not authorized for this control profile' });}}@@@ -2091,7 +2102,7 @@ router.put('/:id', requireAuth, (req, res) => {try {const stream = db.getStreamById(req.params.id);if (!stream) return res.status(404).json({ error: 'Stream not found' });-- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {++ if (stream.user_id !== req.user.id && !staffMayModerate(req.user, stream.user_id)) {return res.status(403).json({ error: 'Not your stream' });}@@@ -2142,7 +2153,7 @@ router.delete('/:id', requireAuth, (req, res) => {try {const stream = db.getStreamById(req.params.id);if (!stream) return res.status(404).json({ error: 'Stream not found' });-- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {++ if (stream.user_id !== req.user.id && !staffMayModerate(req.user, stream.user_id)) {return res.status(403).json({ error: 'Not your stream' });}@@@ -2189,7 +2200,7 @@ router.get('/:id/endpoint', requireAuth, async (req, res) => {try {const stream = db.getStreamById(req.params.id);if (!stream) return res.status(404).json({ error: 'Stream not found' });-- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {++ if (stream.user_id !== req.user.id) {return res.status(403).json({ error: 'Not your stream' });}@@@ -2287,7 +2298,7 @@ router.post('/:id/heartbeat', requireAuth, (req, res) => {try {const stream = db.getStreamById(req.params.id);if (!stream) return res.status(404).json({ error: 'Stream not found' });-- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {++ if (stream.user_id !== req.user.id) {return res.status(403).json({ error: 'Not your stream' });}if (!stream.is_live) return res.status(400).json({ error: 'Stream is not live' });@@@ -2305,7 +2316,7 @@ router.get('/:id/rtmp-status', requireAuth, (req, res) => {try {const stream = db.getStreamById(req.params.id);if (!stream) return res.status(404).json({ error: 'Stream not found' });-- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {++ if (stream.user_id !== req.user.id && !staffMayModerate(req.user, stream.user_id)) {return res.status(403).json({ error: 'Not your stream' });}if (stream.protocol !== 'rtmp') {@@@ -2422,7 +2433,7 @@ router.put('/:id/call', requireAuth, (req, res) => {try {const stream = db.getStreamById(req.params.id);if (!stream) return res.status(404).json({ error: 'Stream not found' });-- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {++ if (stream.user_id !== req.user.id) {return res.status(403).json({ error: 'Not your stream' });}if (!stream.is_live) {@@@ -808,7 +808,7 @@ async function handleWhipPost(req, res) {}stream = db.getStreamById(streamId);if (!stream) return sendWhipError(res, 404, 'stream_not_found', 'Stream not found');-- if (stream.user_id !== user.id && user.role !== 'admin') {++ if (stream.user_id !== user.id) { // only the streamer publishes into their own stream (never staff)return sendWhipError(res, 403, 'not_your_stream', 'Not your stream');}if (!stream.is_live) return sendWhipError(res, 409, 'stream_not_live', 'Stream is not live — go live first');nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++++// Staff may moderate someone else's stream or slot (edit, end, delete) but never the site owner's, and++// never act as its streamer: stream keys, the ingest endpoint and heartbeats stay with the streamer.++++const assert = require('assert');++const os = require('os');++const path = require('path');++const http = require('http');++const fs = require('fs');++++const tmp = path.join(os.tmpdir(), `ov-staff-scope-${process.pid}.db`);++process.env.DB_PATH = tmp;++process.env.NODE_ENV = 'test';++console.log = () => {}; console.warn = () => {}; console.error = () => {};++++const db = require('../server/db/database');++db.initDb();++const raw = db.getDb();++const auth = require('../server/auth/auth');++const signIn = (req) => { const id = Number(req.headers['x-test-user'] || 0); const u = id ? db.getUserById(id) : null; if (u) req.user = u; return u; };++auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));++auth.optionalAuth = (req, res, next) => { signIn(req); next(); };++++const addUser = (id, name, role, isOwner = 0) => raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, is_owner, stream_key)++ VALUES (?, ?, ?, ?, 'x', ?, ?, ?)`).run(id, name, name, `${name}@x`, role, isOwner, `key-${name}`);++addUser(1, 'siteowner', 'admin', 1);++addUser(2, 'anadmin', 'admin');++addUser(3, 'streamer', 'streamer');++const slotOf = (uid, slug) => Number(db.createManagedStream({ user_id: uid, slug, title: slug, protocol: 'webrtc', stream_key: `sk-${slug}` }).lastInsertRowid);++const slotStreamer = slotOf(3, 's3');++const slotOwner = slotOf(1, 's1');++const liveOf = (uid, ms) => Number(db.createStream({ user_id: uid, managed_stream_id: ms, title: 't', protocol: 'webrtc' }).lastInsertRowid);++const streamStreamer = liveOf(3, slotStreamer);++const streamOwner = liveOf(1, slotOwner);++++const express = require('express');++const app = express();++app.use(express.json());++app.use('/api/streams', require('../server/streaming/routes'));++const server = http.createServer(app);++++function call(method, p, user, body) {++ return new Promise((resolve, reject) => {++ const data = body ? JSON.stringify(body) : null;++ const req = http.request(`http://127.0.0.1:${server.address().port}${p}`, { method, headers: { 'x-test-user': String(user), 'content-type': 'application/json' } }, (res) => {++ let b = ''; res.on('data', (c) => { b += c; }); res.on('end', () => resolve(res.statusCode));++ });++ req.on('error', reject); if (data) req.write(data); req.end();++ });++}++++server.listen(0, '127.0.0.1', async () => {++ try {++ // Moderation of another streamer's slot: allowed for an admin.++ assert.notStrictEqual(await call('PUT', `/api/streams/managed/${slotStreamer}`, 2, { title: 'moderated' }), 403, 'an admin may edit another streamer\'s slot');++ // ...but never the site owner's.++ assert.strictEqual(await call('PUT', `/api/streams/managed/${slotOwner}`, 2, { title: 'x' }), 403, 'an admin may not edit the owner\'s slot');++ assert.strictEqual(await call('PUT', `/api/streams/${streamOwner}`, 2, { title: 'x' }), 403, 'an admin may not edit the owner\'s live stream');++ assert.strictEqual(await call('DELETE', `/api/streams/${streamOwner}`, 2), 403, 'an admin may not end the owner\'s stream');++ // Acting as the streamer: never, not even for admins.++ assert.strictEqual(await call('POST', `/api/streams/managed/${slotStreamer}/regenerate-key`, 2), 403, 'an admin may not regenerate someone else\'s stream key');++ assert.strictEqual(await call('GET', `/api/streams/${streamStreamer}/endpoint`, 2), 403, 'an admin may not read someone else\'s ingest endpoint');++ assert.strictEqual(await call('POST', `/api/streams/${streamStreamer}/heartbeat`, 2), 403, 'an admin may not heartbeat someone else\'s stream');++ // The streamer keeps everything on their own stream.++ assert.notStrictEqual(await call('GET', `/api/streams/${streamStreamer}/endpoint`, 3), 403);++ assert.notStrictEqual(await call('POST', `/api/streams/managed/${slotStreamer}/regenerate-key`, 3), 403);++ // Even the site owner does not act as another streamer.++ assert.strictEqual(await call('POST', `/api/streams/managed/${slotStreamer}/regenerate-key`, 1), 403);++ console.info('staff stream scope: all checks passed');++ } finally { server.close(); try { fs.rmSync(tmp, { force: true }); } catch { /* */ } }++});
@@ -88,6 +88,17 @@ const MAX_PANELS_LENGTH = 20000;
@@ -88,6 +88,17 @@ const MAX_PANELS_LENGTH = 20000;
const { pushBulkNotification } = require('../utils/notify');
const { pushBulkNotification } = require('../utils/notify');
const { publicManagedStream, publicChannel, publicStream } = require('../web/serializers');
const { publicManagedStream, publicChannel, publicStream } = require('../web/serializers');
+/**
+ * Staff may moderate someone else's stream or slot (edit, end, delete, read status) but never the site
+ * owner's, and never act AS its streamer: reading or regenerating a stream key, the ingest endpoint,
+ * going live on a slot, heartbeats, call settings and WHIP publishing stay with the streamer alone.
+ */
+function staffMayModerate(actor, ownerUserId) {
+ if (!actor || actor.role !== 'admin') return false;
+ const target = db.getUserById(ownerUserId);
+ return !(target && target.is_owner);
+}
+
/** A control profile id a slot may point at: one of the caller's own (admins may use anyone's). */
/** A control profile id a slot may point at: one of the caller's own (admins may use anyone's). */
function ownControlConfigId(req, raw) {
function ownControlConfigId(req, raw) {
if (raw === null || raw === undefined || raw === '') return null;
if (raw === null || raw === undefined || raw === '') return null;
@@ -1684,7 +1695,7 @@ router.put('/managed/:id', requireAuth, (req, res) => {
@@ -1684,7 +1695,7 @@ router.put('/managed/:id', requireAuth, (req, res) => {
const msId = parseInt(req.params.id);
const msId = parseInt(req.params.id);
const ms = db.getManagedStreamById(msId);
const ms = db.getManagedStreamById(msId);
if (!ms) return res.status(404).json({ error: 'Managed stream not found' });
if (!ms) return res.status(404).json({ error: 'Managed stream not found' });
- if (ms.user_id !== req.user.id && req.user.role !== 'admin') {
+ if (ms.user_id !== req.user.id && !staffMayModerate(req.user, ms.user_id)) {
return res.status(403).json({ error: 'Not your managed stream' });
return res.status(403).json({ error: 'Not your managed stream' });
}
}
@@ -1844,7 +1855,7 @@ router.delete('/managed/:id', requireAuth, (req, res) => {
@@ -1844,7 +1855,7 @@ router.delete('/managed/:id', requireAuth, (req, res) => {
const msId = parseInt(req.params.id);
const msId = parseInt(req.params.id);
const ms = db.getManagedStreamById(msId);
const ms = db.getManagedStreamById(msId);
if (!ms) return res.status(404).json({ error: 'Managed stream not found' });
if (!ms) return res.status(404).json({ error: 'Managed stream not found' });
- if (ms.user_id !== req.user.id && req.user.role !== 'admin') {
+ if (ms.user_id !== req.user.id && !staffMayModerate(req.user, ms.user_id)) {
return res.status(403).json({ error: 'Not your managed stream' });
return res.status(403).json({ error: 'Not your managed stream' });
}
}
@@ -1869,7 +1880,7 @@ router.post('/managed/:id/regenerate-key', requireAuth, async (req, res) => {
@@ -1869,7 +1880,7 @@ router.post('/managed/:id/regenerate-key', requireAuth, async (req, res) => {
const msId = parseInt(req.params.id);
const msId = parseInt(req.params.id);
const ms = db.getManagedStreamById(msId);
const ms = db.getManagedStreamById(msId);
if (!ms) return res.status(404).json({ error: 'Managed stream not found' });
if (!ms) return res.status(404).json({ error: 'Managed stream not found' });
- if (ms.user_id !== req.user.id && req.user.role !== 'admin') {
+ if (ms.user_id !== req.user.id) {
return res.status(403).json({ error: 'Not your managed stream' });
return res.status(403).json({ error: 'Not your managed stream' });
}
}
@@ -1952,7 +1963,7 @@ router.post('/', requireAuth, (req, res) => {
@@ -1952,7 +1963,7 @@ router.post('/', requireAuth, (req, res) => {
if (managedStreamId) {
if (managedStreamId) {
managedStream = db.getManagedStreamById(managedStreamId);
managedStream = db.getManagedStreamById(managedStreamId);
if (!managedStream) return res.status(404).json({ error: 'Managed stream not found' });
if (!managedStream) return res.status(404).json({ error: 'Managed stream not found' });
- if (managedStream.user_id !== req.user.id && req.user.role !== 'admin') {
+ if (managedStream.user_id !== req.user.id) {
return res.status(403).json({ error: 'Not your managed stream' });
return res.status(403).json({ error: 'Not your managed stream' });
}
}
} else {
} else {
@@ -1996,7 +2007,7 @@ router.post('/', requireAuth, (req, res) => {
@@ -1996,7 +2007,7 @@ router.post('/', requireAuth, (req, res) => {
if (!config) {
if (!config) {
return res.status(404).json({ error: 'Control config not found' });
return res.status(404).json({ error: 'Control config not found' });
}
}
- if (config.user_id !== req.user.id && req.user.role !== 'admin') {
+ if (config.user_id !== req.user.id) {
return res.status(403).json({ error: 'Not authorized for this control profile' });
return res.status(403).json({ error: 'Not authorized for this control profile' });
}
}
}
}
@@ -2091,7 +2102,7 @@ router.put('/:id', requireAuth, (req, res) => {
@@ -2091,7 +2102,7 @@ router.put('/:id', requireAuth, (req, res) => {
try {
try {
const stream = db.getStreamById(req.params.id);
const stream = db.getStreamById(req.params.id);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
if (!stream) return res.status(404).json({ error: 'Stream not found' });
- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {
+ if (stream.user_id !== req.user.id && !staffMayModerate(req.user, stream.user_id)) {
return res.status(403).json({ error: 'Not your stream' });
return res.status(403).json({ error: 'Not your stream' });
}
}
@@ -2142,7 +2153,7 @@ router.delete('/:id', requireAuth, (req, res) => {
@@ -2142,7 +2153,7 @@ router.delete('/:id', requireAuth, (req, res) => {
try {
try {
const stream = db.getStreamById(req.params.id);
const stream = db.getStreamById(req.params.id);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
if (!stream) return res.status(404).json({ error: 'Stream not found' });
- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {
+ if (stream.user_id !== req.user.id && !staffMayModerate(req.user, stream.user_id)) {
return res.status(403).json({ error: 'Not your stream' });
return res.status(403).json({ error: 'Not your stream' });
}
}
@@ -2189,7 +2200,7 @@ router.get('/:id/endpoint', requireAuth, async (req, res) => {
@@ -2189,7 +2200,7 @@ router.get('/:id/endpoint', requireAuth, async (req, res) => {
try {
try {
const stream = db.getStreamById(req.params.id);
const stream = db.getStreamById(req.params.id);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
if (!stream) return res.status(404).json({ error: 'Stream not found' });
- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {
+ if (stream.user_id !== req.user.id) {
return res.status(403).json({ error: 'Not your stream' });
return res.status(403).json({ error: 'Not your stream' });
}
}
@@ -2287,7 +2298,7 @@ router.post('/:id/heartbeat', requireAuth, (req, res) => {
@@ -2287,7 +2298,7 @@ router.post('/:id/heartbeat', requireAuth, (req, res) => {
try {
try {
const stream = db.getStreamById(req.params.id);
const stream = db.getStreamById(req.params.id);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
if (!stream) return res.status(404).json({ error: 'Stream not found' });
- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {
+ if (stream.user_id !== req.user.id) {
return res.status(403).json({ error: 'Not your stream' });
return res.status(403).json({ error: 'Not your stream' });
}
}
if (!stream.is_live) return res.status(400).json({ error: 'Stream is not live' });
if (!stream.is_live) return res.status(400).json({ error: 'Stream is not live' });
@@ -2305,7 +2316,7 @@ router.get('/:id/rtmp-status', requireAuth, (req, res) => {
@@ -2305,7 +2316,7 @@ router.get('/:id/rtmp-status', requireAuth, (req, res) => {
try {
try {
const stream = db.getStreamById(req.params.id);
const stream = db.getStreamById(req.params.id);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
if (!stream) return res.status(404).json({ error: 'Stream not found' });
- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {
+ if (stream.user_id !== req.user.id && !staffMayModerate(req.user, stream.user_id)) {
return res.status(403).json({ error: 'Not your stream' });
return res.status(403).json({ error: 'Not your stream' });
}
}
if (stream.protocol !== 'rtmp') {
if (stream.protocol !== 'rtmp') {
@@ -2422,7 +2433,7 @@ router.put('/:id/call', requireAuth, (req, res) => {
@@ -2422,7 +2433,7 @@ router.put('/:id/call', requireAuth, (req, res) => {
try {
try {
const stream = db.getStreamById(req.params.id);
const stream = db.getStreamById(req.params.id);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
if (!stream) return res.status(404).json({ error: 'Stream not found' });
- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {
+ if (stream.user_id !== req.user.id) {
return res.status(403).json({ error: 'Not your stream' });
return res.status(403).json({ error: 'Not your stream' });
}
}
if (!stream.is_live) {
if (!stream.is_live) {
@@ -808,7 +808,7 @@ async function handleWhipPost(req, res) {
@@ -808,7 +808,7 @@ async function handleWhipPost(req, res) {
}
}
stream = db.getStreamById(streamId);
stream = db.getStreamById(streamId);
if (!stream) return sendWhipError(res, 404, 'stream_not_found', 'Stream not found');
if (!stream) return sendWhipError(res, 404, 'stream_not_found', 'Stream not found');
- if (stream.user_id !== user.id && user.role !== 'admin') {
+ if (stream.user_id !== user.id) { // only the streamer publishes into their own stream (never staff)
return sendWhipError(res, 403, 'not_your_stream', 'Not your stream');
return sendWhipError(res, 403, 'not_your_stream', 'Not your stream');
}
}
if (!stream.is_live) return sendWhipError(res, 409, 'stream_not_live', 'Stream is not live — go live first');
if (!stream.is_live) return sendWhipError(res, 409, 'stream_not_live', 'Stream is not live — go live first');
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+
+// Staff may moderate someone else's stream or slot (edit, end, delete) but never the site owner's, and
+// never act as its streamer: stream keys, the ingest endpoint and heartbeats stay with the streamer.
+
+const assert = require('assert');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const fs = require('fs');
+
+const tmp = path.join(os.tmpdir(), `ov-staff-scope-${process.pid}.db`);
+process.env.DB_PATH = tmp;
+process.env.NODE_ENV = 'test';
+console.log = () => {}; console.warn = () => {}; console.error = () => {};
+
+const db = require('../server/db/database');
+db.initDb();
+const raw = db.getDb();
+const auth = require('../server/auth/auth');
+const signIn = (req) => { const id = Number(req.headers['x-test-user'] || 0); const u = id ? db.getUserById(id) : null; if (u) req.user = u; return u; };
+auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));
+auth.optionalAuth = (req, res, next) => { signIn(req); next(); };
+
+const addUser = (id, name, role, isOwner = 0) => raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, is_owner, stream_key)
+ VALUES (?, ?, ?, ?, 'x', ?, ?, ?)`).run(id, name, name, `${name}@x`, role, isOwner, `key-${name}`);
+addUser(1, 'siteowner', 'admin', 1);
+addUser(2, 'anadmin', 'admin');
+addUser(3, 'streamer', 'streamer');
+const slotOf = (uid, slug) => Number(db.createManagedStream({ user_id: uid, slug, title: slug, protocol: 'webrtc', stream_key: `sk-${slug}` }).lastInsertRowid);
+const slotStreamer = slotOf(3, 's3');
+const slotOwner = slotOf(1, 's1');
+const liveOf = (uid, ms) => Number(db.createStream({ user_id: uid, managed_stream_id: ms, title: 't', protocol: 'webrtc' }).lastInsertRowid);
+const streamStreamer = liveOf(3, slotStreamer);
+const streamOwner = liveOf(1, slotOwner);
+
+const express = require('express');
+const app = express();
+app.use(express.json());
+app.use('/api/streams', require('../server/streaming/routes'));
+const server = http.createServer(app);
+
+function call(method, p, user, body) {
+ return new Promise((resolve, reject) => {
+ const data = body ? JSON.stringify(body) : null;
+ const req = http.request(`http://127.0.0.1:${server.address().port}${p}`, { method, headers: { 'x-test-user': String(user), 'content-type': 'application/json' } }, (res) => {
+ let b = ''; res.on('data', (c) => { b += c; }); res.on('end', () => resolve(res.statusCode));
+ });
+ req.on('error', reject); if (data) req.write(data); req.end();
+ });
+}
+
+server.listen(0, '127.0.0.1', async () => {
+ try {
+ // Moderation of another streamer's slot: allowed for an admin.
+ assert.notStrictEqual(await call('PUT', `/api/streams/managed/${slotStreamer}`, 2, { title: 'moderated' }), 403, 'an admin may edit another streamer\'s slot');
+ // ...but never the site owner's.
+ assert.strictEqual(await call('PUT', `/api/streams/managed/${slotOwner}`, 2, { title: 'x' }), 403, 'an admin may not edit the owner\'s slot');
+ assert.strictEqual(await call('PUT', `/api/streams/${streamOwner}`, 2, { title: 'x' }), 403, 'an admin may not edit the owner\'s live stream');
+ assert.strictEqual(await call('DELETE', `/api/streams/${streamOwner}`, 2), 403, 'an admin may not end the owner\'s stream');
+ // Acting as the streamer: never, not even for admins.
+ assert.strictEqual(await call('POST', `/api/streams/managed/${slotStreamer}/regenerate-key`, 2), 403, 'an admin may not regenerate someone else\'s stream key');
+ assert.strictEqual(await call('GET', `/api/streams/${streamStreamer}/endpoint`, 2), 403, 'an admin may not read someone else\'s ingest endpoint');
+ assert.strictEqual(await call('POST', `/api/streams/${streamStreamer}/heartbeat`, 2), 403, 'an admin may not heartbeat someone else\'s stream');
+ // The streamer keeps everything on their own stream.
+ assert.notStrictEqual(await call('GET', `/api/streams/${streamStreamer}/endpoint`, 3), 403);
+ assert.notStrictEqual(await call('POST', `/api/streams/managed/${slotStreamer}/regenerate-key`, 3), 403);
+ // Even the site owner does not act as another streamer.
+ assert.strictEqual(await call('POST', `/api/streams/managed/${slotStreamer}/regenerate-key`, 1), 403);
+ console.info('staff stream scope: all checks passed');
+ } finally { server.close(); try { fs.rmSync(tmp, { force: true }); } catch { /* */ } }
+});