openvibers/openvibe.live Commits
Chat bans: a null stream id is site-wide and global staff only — offline channel-room /ban, /timeout, /unban are scoped to the channel's stream, a channel /unban never lifts a site-wide row, and /internal/chat-effects/ban re-checks both; nginx: client address headers only from $remote_addr (realip), never appended to or passed through from the client
Changes
@@@ -81,7 +81,8 @@ server {proxy_http_version 1.1;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;proxy_set_header X-Forwarded-Proto $scheme;}@@@ -91,7 +92,8 @@ server {proxy_http_version 1.1;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;proxy_set_header X-Forwarded-Proto $scheme;}@@@ -99,6 +101,9 @@ server {proxy_pass http://127.0.0.1:3000;proxy_http_version 1.1;proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;expires 7d;add_header Cache-Control "public, max-age=604800, immutable";}@@@ -107,6 +112,9 @@ server {proxy_pass http://127.0.0.1:3000;proxy_http_version 1.1;proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;expires 1h;add_header Cache-Control "public, max-age=3600";}@@@ -118,7 +126,8 @@ server {proxy_http_version 1.1;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;proxy_set_header X-Forwarded-Proto $scheme;proxy_read_timeout 300s;proxy_send_timeout 300s;@@@ -135,7 +144,8 @@ server {proxy_http_version 1.1;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;proxy_set_header X-Forwarded-Proto $scheme;proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection "upgrade";@@@ -159,7 +169,8 @@ server {proxy_http_version 1.1;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;proxy_set_header X-Forwarded-Proto $scheme;}@@@ -168,9 +179,9 @@ server {proxy_http_version 1.1;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;proxy_set_header X-Forwarded-Proto $scheme;-- proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection "upgrade";@@@ -1647,6 +1647,15 @@ class ChatServer {const target = args.split(' ')[0];if (!target) return;++ // Where the ban applies. In an offline channel room it is that channel's latest stream: a++ // null stream id is a SITE-WIDE ban row (and /unban with null lifts site-wide rows), which++ // only global chat — where canModerate() already requires global staff — may write.++ const scopeStreamId = client.streamId || (client.channelUserId ? this._moderationStreamFor(client) : null);++ if (!scopeStreamId && client.channelUserId) {++ this.sendTo(ws, { type: 'system', message: 'This channel has no stream to moderate yet.' });++ return;++ }++ const scoped = !!scopeStreamId;switch (action) {case 'ban': {@@@ -1659,24 +1668,24 @@ class ChatServer {}db.run(`INSERT INTO bans (stream_id, user_id, reason, banned_by) VALUES (?, ?, ?, ?)`,-- [client.streamId, targetUser.id, 'Banned by moderator', client.user.id]++ [scopeStreamId, targetUser.id, 'Banned by moderator', client.user.id]);this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });this.broadcastToStream(client.streamId, {type: 'system', message: `${target} has been banned.`});-- this.logChatModeration(client, client.streamId ? 'channel_ban' : 'site_ban', { username: targetUser.username }, targetUser.id);++ this.logChatModeration(client, scoped ? 'channel_ban' : 'site_ban', { username: targetUser.username }, targetUser.id);} else {// Ban by anon IDconst anonTarget = this.findClientByAnonId(target, client.streamId);if (anonTarget) {db.run(`INSERT INTO bans (stream_id, ip_address, anon_id, reason, banned_by) VALUES (?, ?, ?, ?, ?)`,-- [client.streamId, anonTarget.ip, target, 'Banned by moderator', client.user.id]++ [scopeStreamId, anonTarget.ip, target, 'Banned by moderator', client.user.id]);this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });}-- this.logChatModeration(client, client.streamId ? 'channel_anon_ban' : 'site_anon_ban', { anon_id: target });++ this.logChatModeration(client, scoped ? 'channel_anon_ban' : 'site_anon_ban', { anon_id: target });}break;}@@@ -1687,9 +1696,9 @@ class ChatServer {if (targetUser) {db.run(`INSERT INTO bans (stream_id, user_id, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?)`,-- [client.streamId, targetUser.id, `Timeout ${duration}s`, client.user.id, expires]++ [scopeStreamId, targetUser.id, `Timeout ${duration}s`, client.user.id, expires]);-- this.logChatModeration(client, client.streamId ? 'channel_timeout' : 'site_timeout', { username: targetUser.username, duration }, targetUser.id);++ this.logChatModeration(client, scoped ? 'channel_timeout' : 'site_timeout', { username: targetUser.username, duration }, targetUser.id);}this.sendTo(ws, { type: 'system', message: `${target} timed out for ${duration}s.` });break;@@@ -1697,9 +1706,10 @@ class ChatServer {case 'unban': {const targetUser = db.getUserByUsername(target);if (targetUser) {-- db.run('DELETE FROM bans WHERE user_id = ? AND (stream_id = ? OR stream_id IS NULL)',-- [targetUser.id, client.streamId]);-- this.logChatModeration(client, client.streamId ? 'channel_unban' : 'site_unban', { username: targetUser.username }, targetUser.id);++ // A channel unban lifts that channel's rows only; site-wide rows are global chat's.++ if (scoped) db.run('DELETE FROM bans WHERE user_id = ? AND stream_id = ?', [targetUser.id, scopeStreamId]);++ else db.run('DELETE FROM bans WHERE user_id = ? AND stream_id IS NULL', [targetUser.id]);++ this.logChatModeration(client, scoped ? 'channel_unban' : 'site_unban', { username: targetUser.username }, targetUser.id);}this.sendTo(ws, { type: 'system', message: `${target} has been unbanned.` });break;@@@ -322,10 +322,15 @@ effectsRouter.post('/ban', (req, res) => {const allowed = actor && !actor.is_banned && (permissions.isGlobalModOrAbove(actor) || (modStream && permissions.canModerateStream(actor, modStream)));if (!allowed) return fail(res, 403, 'You do not have permission.');const streamId = b.stream_id != null ? int(b.stream_id) || null : null;++ // A null stream id is a SITE-WIDE ban (or lifts one): global staff only. A channel moderator++ // acts on the stream they moderate, never on another channel's.++ const staff = permissions.isGlobalModOrAbove(actor);++ if (!staff && (!streamId || !permissions.canModerateStream(actor, streamId))) return fail(res, 403, 'You do not have permission.');if (b.action === 'unban') {const userId = int(b.user_id);if (!userId) return fail(res, 400, 'user_id required');-- db.run('DELETE FROM bans WHERE user_id = ? AND (stream_id = ? OR stream_id IS NULL)', [userId, streamId]);++ if (streamId) db.run('DELETE FROM bans WHERE user_id = ? AND stream_id = ?', [userId, streamId]);++ else db.run('DELETE FROM bans WHERE user_id = ? AND stream_id IS NULL', [userId]);return res.json({ ok: true });}if (b.action !== 'ban') return fail(res, 400, 'action must be ban or unban');@@@ -181,6 +181,14 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));assert.strictEqual(b2.bans.length, 1, 'the version moved with the table');assert.strictEqual((await ban({ action: 'unban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: viewer })).status, 200);assert.ok(!db.isUserBanned(viewer, streamId));++ // A null stream id is a site-wide ban: global staff only, and a channel unban never lifts one.++ assert.strictEqual((await ban({ action: 'ban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: null, user_id: viewer })).status, 403, 'a channel mod cannot ban site-wide');++ assert.strictEqual((await ban({ action: 'ban', actor_user_id: admin, stream_id: null, user_id: viewer, reason: 'site' })).status, 200);++ assert.strictEqual((await ban({ action: 'unban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: null, user_id: viewer })).status, 403, 'a channel mod cannot lift a site-wide ban');++ assert.strictEqual((await ban({ action: 'unban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: viewer })).status, 200);++ assert.ok(db.get('SELECT 1 FROM bans WHERE user_id = ? AND stream_id IS NULL', [viewer]), 'the channel unban left the site-wide row');++ assert.strictEqual((await ban({ action: 'unban', actor_user_id: admin, stream_id: null, user_id: viewer })).status, 200);++ assert.ok(!db.get('SELECT 1 FROM bans WHERE user_id = ?', [viewer]));// 7. TTS settings: admins; credentials only the owner.const put = (actor, settingsBody) => call('POST', '/internal/chat-effects/site-settings', { token: WRITE, body: { actor_user_id: actor, settings: settingsBody } });
@@ -81,7 +81,8 @@ server {
@@ -81,7 +81,8 @@ server {
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
@@ -91,7 +92,8 @@ server {
@@ -91,7 +92,8 @@ server {
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
@@ -99,6 +101,9 @@ server {
@@ -99,6 +101,9 @@ server {
proxy_pass http://127.0.0.1:3000;
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
expires 7d;
expires 7d;
add_header Cache-Control "public, max-age=604800, immutable";
add_header Cache-Control "public, max-age=604800, immutable";
}
}
@@ -107,6 +112,9 @@ server {
@@ -107,6 +112,9 @@ server {
proxy_pass http://127.0.0.1:3000;
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
expires 1h;
expires 1h;
add_header Cache-Control "public, max-age=3600";
add_header Cache-Control "public, max-age=3600";
}
}
@@ -118,7 +126,8 @@ server {
@@ -118,7 +126,8 @@ server {
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_send_timeout 300s;
@@ -135,7 +144,8 @@ server {
@@ -135,7 +144,8 @@ server {
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Connection "upgrade";
@@ -159,7 +169,8 @@ server {
@@ -159,7 +169,8 @@ server {
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
@@ -168,9 +179,9 @@ server {
@@ -168,9 +179,9 @@ server {
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
- proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Connection "upgrade";
@@ -1647,6 +1647,15 @@ class ChatServer {
@@ -1647,6 +1647,15 @@ class ChatServer {
const target = args.split(' ')[0];
const target = args.split(' ')[0];
if (!target) return;
if (!target) return;
+ // Where the ban applies. In an offline channel room it is that channel's latest stream: a
+ // null stream id is a SITE-WIDE ban row (and /unban with null lifts site-wide rows), which
+ // only global chat — where canModerate() already requires global staff — may write.
+ const scopeStreamId = client.streamId || (client.channelUserId ? this._moderationStreamFor(client) : null);
+ if (!scopeStreamId && client.channelUserId) {
+ this.sendTo(ws, { type: 'system', message: 'This channel has no stream to moderate yet.' });
+ return;
+ }
+ const scoped = !!scopeStreamId;
switch (action) {
switch (action) {
case 'ban': {
case 'ban': {
@@ -1659,24 +1668,24 @@ class ChatServer {
@@ -1659,24 +1668,24 @@ class ChatServer {
}
}
db.run(
db.run(
`INSERT INTO bans (stream_id, user_id, reason, banned_by) VALUES (?, ?, ?, ?)`,
`INSERT INTO bans (stream_id, user_id, reason, banned_by) VALUES (?, ?, ?, ?)`,
- [client.streamId, targetUser.id, 'Banned by moderator', client.user.id]
+ [scopeStreamId, targetUser.id, 'Banned by moderator', client.user.id]
);
);
this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });
this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });
this.broadcastToStream(client.streamId, {
this.broadcastToStream(client.streamId, {
type: 'system', message: `${target} has been banned.`
type: 'system', message: `${target} has been banned.`
});
});
- this.logChatModeration(client, client.streamId ? 'channel_ban' : 'site_ban', { username: targetUser.username }, targetUser.id);
+ this.logChatModeration(client, scoped ? 'channel_ban' : 'site_ban', { username: targetUser.username }, targetUser.id);
} else {
} else {
// Ban by anon ID
// Ban by anon ID
const anonTarget = this.findClientByAnonId(target, client.streamId);
const anonTarget = this.findClientByAnonId(target, client.streamId);
if (anonTarget) {
if (anonTarget) {
db.run(
db.run(
`INSERT INTO bans (stream_id, ip_address, anon_id, reason, banned_by) VALUES (?, ?, ?, ?, ?)`,
`INSERT INTO bans (stream_id, ip_address, anon_id, reason, banned_by) VALUES (?, ?, ?, ?, ?)`,
- [client.streamId, anonTarget.ip, target, 'Banned by moderator', client.user.id]
+ [scopeStreamId, anonTarget.ip, target, 'Banned by moderator', client.user.id]
);
);
this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });
this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });
}
}
- this.logChatModeration(client, client.streamId ? 'channel_anon_ban' : 'site_anon_ban', { anon_id: target });
+ this.logChatModeration(client, scoped ? 'channel_anon_ban' : 'site_anon_ban', { anon_id: target });
}
}
break;
break;
}
}
@@ -1687,9 +1696,9 @@ class ChatServer {
@@ -1687,9 +1696,9 @@ class ChatServer {
if (targetUser) {
if (targetUser) {
db.run(
db.run(
`INSERT INTO bans (stream_id, user_id, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?)`,
`INSERT INTO bans (stream_id, user_id, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?)`,
- [client.streamId, targetUser.id, `Timeout ${duration}s`, client.user.id, expires]
+ [scopeStreamId, targetUser.id, `Timeout ${duration}s`, client.user.id, expires]
);
);
- this.logChatModeration(client, client.streamId ? 'channel_timeout' : 'site_timeout', { username: targetUser.username, duration }, targetUser.id);
+ this.logChatModeration(client, scoped ? 'channel_timeout' : 'site_timeout', { username: targetUser.username, duration }, targetUser.id);
}
}
this.sendTo(ws, { type: 'system', message: `${target} timed out for ${duration}s.` });
this.sendTo(ws, { type: 'system', message: `${target} timed out for ${duration}s.` });
break;
break;
@@ -1697,9 +1706,10 @@ class ChatServer {
@@ -1697,9 +1706,10 @@ class ChatServer {
case 'unban': {
case 'unban': {
const targetUser = db.getUserByUsername(target);
const targetUser = db.getUserByUsername(target);
if (targetUser) {
if (targetUser) {
- db.run('DELETE FROM bans WHERE user_id = ? AND (stream_id = ? OR stream_id IS NULL)',
- [targetUser.id, client.streamId]);
- this.logChatModeration(client, client.streamId ? 'channel_unban' : 'site_unban', { username: targetUser.username }, targetUser.id);
+ // A channel unban lifts that channel's rows only; site-wide rows are global chat's.
+ if (scoped) db.run('DELETE FROM bans WHERE user_id = ? AND stream_id = ?', [targetUser.id, scopeStreamId]);
+ else db.run('DELETE FROM bans WHERE user_id = ? AND stream_id IS NULL', [targetUser.id]);
+ this.logChatModeration(client, scoped ? 'channel_unban' : 'site_unban', { username: targetUser.username }, targetUser.id);
}
}
this.sendTo(ws, { type: 'system', message: `${target} has been unbanned.` });
this.sendTo(ws, { type: 'system', message: `${target} has been unbanned.` });
break;
break;
@@ -322,10 +322,15 @@ effectsRouter.post('/ban', (req, res) => {
@@ -322,10 +322,15 @@ effectsRouter.post('/ban', (req, res) => {
const allowed = actor && !actor.is_banned && (permissions.isGlobalModOrAbove(actor) || (modStream && permissions.canModerateStream(actor, modStream)));
const allowed = actor && !actor.is_banned && (permissions.isGlobalModOrAbove(actor) || (modStream && permissions.canModerateStream(actor, modStream)));
if (!allowed) return fail(res, 403, 'You do not have permission.');
if (!allowed) return fail(res, 403, 'You do not have permission.');
const streamId = b.stream_id != null ? int(b.stream_id) || null : null;
const streamId = b.stream_id != null ? int(b.stream_id) || null : null;
+ // A null stream id is a SITE-WIDE ban (or lifts one): global staff only. A channel moderator
+ // acts on the stream they moderate, never on another channel's.
+ const staff = permissions.isGlobalModOrAbove(actor);
+ if (!staff && (!streamId || !permissions.canModerateStream(actor, streamId))) return fail(res, 403, 'You do not have permission.');
if (b.action === 'unban') {
if (b.action === 'unban') {
const userId = int(b.user_id);
const userId = int(b.user_id);
if (!userId) return fail(res, 400, 'user_id required');
if (!userId) return fail(res, 400, 'user_id required');
- db.run('DELETE FROM bans WHERE user_id = ? AND (stream_id = ? OR stream_id IS NULL)', [userId, streamId]);
+ if (streamId) db.run('DELETE FROM bans WHERE user_id = ? AND stream_id = ?', [userId, streamId]);
+ else db.run('DELETE FROM bans WHERE user_id = ? AND stream_id IS NULL', [userId]);
return res.json({ ok: true });
return res.json({ ok: true });
}
}
if (b.action !== 'ban') return fail(res, 400, 'action must be ban or unban');
if (b.action !== 'ban') return fail(res, 400, 'action must be ban or unban');
@@ -181,6 +181,14 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -181,6 +181,14 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
assert.strictEqual(b2.bans.length, 1, 'the version moved with the table');
assert.strictEqual(b2.bans.length, 1, 'the version moved with the table');
assert.strictEqual((await ban({ action: 'unban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: viewer })).status, 200);
assert.strictEqual((await ban({ action: 'unban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: viewer })).status, 200);
assert.ok(!db.isUserBanned(viewer, streamId));
assert.ok(!db.isUserBanned(viewer, streamId));
+ // A null stream id is a site-wide ban: global staff only, and a channel unban never lifts one.
+ assert.strictEqual((await ban({ action: 'ban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: null, user_id: viewer })).status, 403, 'a channel mod cannot ban site-wide');
+ assert.strictEqual((await ban({ action: 'ban', actor_user_id: admin, stream_id: null, user_id: viewer, reason: 'site' })).status, 200);
+ assert.strictEqual((await ban({ action: 'unban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: null, user_id: viewer })).status, 403, 'a channel mod cannot lift a site-wide ban');
+ assert.strictEqual((await ban({ action: 'unban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: viewer })).status, 200);
+ assert.ok(db.get('SELECT 1 FROM bans WHERE user_id = ? AND stream_id IS NULL', [viewer]), 'the channel unban left the site-wide row');
+ assert.strictEqual((await ban({ action: 'unban', actor_user_id: admin, stream_id: null, user_id: viewer })).status, 200);
+ assert.ok(!db.get('SELECT 1 FROM bans WHERE user_id = ?', [viewer]));
// 7. TTS settings: admins; credentials only the owner.
// 7. TTS settings: admins; credentials only the owner.
const put = (actor, settingsBody) => call('POST', '/internal/chat-effects/site-settings', { token: WRITE, body: { actor_user_id: actor, settings: settingsBody } });
const put = (actor, settingsBody) => call('POST', '/internal/chat-effects/site-settings', { token: WRITE, body: { actor_user_id: actor, settings: settingsBody } });