openvibers/openvibe.live Commits

Chat parity gaps, Live's side (roadmap WS-I task 6): the subscriber read for sub-only chat, sub_only as a channel setting, the dashboard's slow mode actually saved, a dashboard delete reaching the whole channel room

OP OpenVibers committed 26 Sept 2026 · d4e849f287 · 9 files, +221 −18

Sub-only chat is enforced by OpenVibe.Chat; Live answers who subscribes and stores the setting. GET /internal/chat-context/subscriber?user_id|subject&streamer_id (live.chat_context.read) → { subscriber }: an ACTIVE channel subscription by Live's own rule (db.isActiveSubscriber: status active, the paid period not over; Billing's entitlement, refreshed first, under BILLING_AUTHORITY=billing); by Live user id or Network subject; Network VIP is not a subscription. channel_moderation_settings.sub_only (schema, an idempotent ADD COLUMN, the defaults row, upsertChannelModerationSettings like tts_max_length); /subonly reaches it through the channel-settings effect (moderator re-checked), the dashboard through PUT /api/channels/:id/moderation (sub_only; owner, channel mods, staff) and a "Sub-only chat" toggle, both through chat-tables.write as the staged-table authority requires.

The dashboard sent and read slowmode_seconds, which no writer knows, so its slow mode was never saved (and always showed 0); it uses slow_mode_seconds now, and the route still takes the old name from a cached dashboard. Chat now enforces the saved value.

Deleting one line from the dashboard's channel moderation (POST /api/channels/:id/moderation/messages/:id/delete) reached only the stream's sockets (or pure global chat for an offline line); it reaches the whole channel room (every slot, the offline room, a channel popout) and the global feed, as /api/mod/delete-message does.

Chat autocomplete: /subonly, and /clear says it clears screens. docs/chat-system.md: sub-only, slow mode, purge reach, blocks. Tests: chat-context (the subscriber read: none, active, by subject, another channel, a lapsed period, canceled; sub_only through the effect and the policy read), channel-moderation-routes (new: the dashboard's slow mode and sub-only saved, who may set them, a delete's reach live and offline).

Changes

ddiff --git a/docs/chat-system.md b/docs/chat-system.md
iindex bd4279a5..f55d4591 100644
---- a/docs/chat-system.md
++++ b/docs/chat-system.md
@@@ -41,9 +41,17 @@ with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches
### Moderation Tools
- Ban, timeout, unban users
--- Message deletion (individual or bulk time-range purge)
--- Slow mode (configurable cooldown)
--- Subscribers-only mode
++- Message deletion (individual or bulk time-range purge): the lines leave every surface that showed
++ them (the stream, the rest of the channel room and its popouts, the global feed) and cursor reads
++ name them in `deleted_ids`. `/clear` only clears screens; the lines stay in history
++- Slow mode: the channel's saved `slow_mode_seconds` (dashboard, or `/slow N` / `/slow off`), enforced
++ by OpenVibe.Chat and kept across restarts; moderators are exempt
++- Sub-only mode (`sub_only`: dashboard, or `/subonly` / `/subonly off` by the streamer, channel mods
++ and chat staff): only people with an active channel subscription (`subscriptions`, asked through
++ `GET /internal/chat-context/subscriber`), the streamer, channel mods and chat staff may chat; Network
++ VIP does not count, anonymous viewers cannot chat, and when Live cannot be asked the answer is no
++- Blocks: someone who blocked a person on OpenVibe.Network no longer gets that person's lines, live or
++ in history reads (only their own view; moderation logs show everything)
- Word filtering and opsec filtering
### Chat Logs & Admin
ddiff --git a/public/js/chat-autocomplete.js b/public/js/chat-autocomplete.js
iindex 90a2d1b9..104f204a 100644
---- a/public/js/chat-autocomplete.js
++++ b/public/js/chat-autocomplete.js
@@@ -23,10 +23,11 @@ const SLASH_COMMANDS = [
{ name: 'me', desc: 'Send an action message', usage: '/me <action>' },
{ name: 'paste', desc: 'Share a paste in chat', usage: '/paste <content>' },
{ name: 'slow', desc: 'Set slow mode (mod)', usage: '/slow <seconds>' },
++ { name: 'subonly', desc: 'Sub-only chat on/off (mod)', usage: '/subonly [off]' },
{ name: 'ban', desc: 'Ban a user (mod)', usage: '/ban <user>' },
{ name: 'unban', desc: 'Unban a user (mod)', usage: '/unban <user>' },
{ name: 'timeout', desc: 'Timeout a user (mod)', usage: '/timeout <user> [s]' },
-- { name: 'clear', desc: 'Clear chat messages (mod)' },
++ { name: 'clear', desc: 'Clear chat screens; history stays (mod)' },
];
/* ── State ────────────────────────────────────────────────────── */
ddiff --git a/public/js/dashboard-moderation.js b/public/js/dashboard-moderation.js
iindex b2fde81e..b70c6883 100644
---- a/public/js/dashboard-moderation.js
++++ b/public/js/dashboard-moderation.js
@@@ -98,7 +98,7 @@ function renderDashModerationChannels(channels) {
<div class="dash-mod-section">
<h5><i class="fa-solid fa-sliders"></i> Chat Settings</h5>
<div class="dash-mod-settings">
-- <label><span>Slowmode Seconds</span><input type="number" id="dash-mod-slow-${channel.id}" class="form-input" value="${Number(settings.slowmode_seconds || 0)}"></label>
++ <label><span>Slowmode Seconds</span><input type="number" id="dash-mod-slow-${channel.id}" class="form-input" value="${Number(settings.slow_mode_seconds || 0)}"></label>
<label><span>Max Message Length</span><input type="number" min="1" max="${(currentUser?.role === 'admin') ? 6000 : 4000}" id="dash-mod-maxlen-${channel.id}" class="form-input" value="${Number(settings.max_message_length || 500)}"></label>
<label><span>Max TTS Length</span><input type="number" min="10" max="1200" id="dash-mod-ttslen-${channel.id}" class="form-input" value="${Number(settings.tts_max_length || 200)}"></label>
<label><span>Account Age Gate (hours)</span><input type="number" id="dash-mod-age-${channel.id}" class="form-input" value="${Number(settings.account_age_gate_hours || 0)}"></label>
@@@ -128,6 +128,7 @@ function renderDashModerationChannels(channels) {
<textarea id="dash-mod-slur-msg-${channel.id}" class="form-input" rows="2" placeholder="Friendly/funny message shown when blocked">${esc(String(settings.slur_filter_nudge_message || ''))}</textarea>
</label>
<label class="staff-inline-toggle"><input type="checkbox" id="dash-mod-followers-${channel.id}" ${Number(settings.followers_only || 0) ? 'checked' : ''}> Followers Only</label>
++ <label class="staff-inline-toggle"><input type="checkbox" id="dash-mod-subonly-${channel.id}" ${Number(settings.sub_only || 0) ? 'checked' : ''}> <span>Sub-only chat (subscribers and mods; also <code>/subonly</code>)</span></label>
<label class="staff-inline-toggle"><input type="checkbox" id="dash-mod-soundboard-${channel.id}" ${Number(settings.soundboard_enabled ?? 1) ? 'checked' : ''}> Allow 101soundboards</label>
<label class="staff-inline-toggle"><input type="checkbox" id="dash-mod-soundboard-pitch-${channel.id}" ${Number(settings.soundboard_allow_pitch ?? 1) ? 'checked' : ''}> Allow soundboard pitch changes</label>
<label class="staff-inline-toggle"><input type="checkbox" id="dash-mod-soundboard-speed-${channel.id}" ${Number(settings.soundboard_allow_speed ?? 1) ? 'checked' : ''}> Allow soundboard speed changes</label>
@@@ -291,7 +292,7 @@ window.dashSaveChannelModerationSettings = async function dashSaveChannelModerat
await api(`/channels/${channelId}/moderation`, {
method: 'PUT',
body: {
-- slowmode_seconds: Number(document.getElementById(`dash-mod-slow-${channelId}`)?.value || 0),
++ slow_mode_seconds: Number(document.getElementById(`dash-mod-slow-${channelId}`)?.value || 0),
max_message_length: Number(document.getElementById(`dash-mod-maxlen-${channelId}`)?.value || 500),
tts_max_length: Number(document.getElementById(`dash-mod-ttslen-${channelId}`)?.value || 200),
account_age_gate_hours: Number(document.getElementById(`dash-mod-age-${channelId}`)?.value || 0),
@@@ -311,6 +312,7 @@ window.dashSaveChannelModerationSettings = async function dashSaveChannelModerat
slur_filter_regexes: String(document.getElementById(`dash-mod-slur-regex-${channelId}`)?.value || ''),
slur_filter_nudge_message: String(document.getElementById(`dash-mod-slur-msg-${channelId}`)?.value || ''),
followers_only: !!document.getElementById(`dash-mod-followers-${channelId}`)?.checked,
++ sub_only: !!document.getElementById(`dash-mod-subonly-${channelId}`)?.checked,
soundboard_enabled: !!document.getElementById(`dash-mod-soundboard-${channelId}`)?.checked,
soundboard_allow_pitch: !!document.getElementById(`dash-mod-soundboard-pitch-${channelId}`)?.checked,
soundboard_allow_speed: !!document.getElementById(`dash-mod-soundboard-speed-${channelId}`)?.checked,
ddiff --git a/server/admin/channel-mod-routes.js b/server/admin/channel-mod-routes.js
iindex c8d49382..df97b2fa 100644
---- a/server/admin/channel-mod-routes.js
++++ b/server/admin/channel-mod-routes.js
@@@ -175,12 +175,15 @@ router.put('/:channelId/moderation', requireAuth, requireChannelAccess, async (r
// The dashboard form sends every field, so for a moderator these are ignored rather than refused.
if (!isOwnerOrStaff) for (const k of OWNER_POLICY_KEYS) delete req.body[k];
const settings = await chatTables.write('upsertChannelModerationSettings', req.channel.id, {
-- slow_mode_seconds: req.body.slow_mode_seconds !== undefined
-- ? Math.max(0, parseInt(req.body.slow_mode_seconds) || 0) : undefined,
-- slowmode_seconds: req.body.slowmode_seconds !== undefined
-- ? Math.max(0, parseInt(req.body.slowmode_seconds) || 0) : undefined,
++ // The column is slow_mode_seconds. The dashboard used to send (and read) slowmode_seconds,
++ // which no writer knows, so its slow mode was never saved; a cached dashboard still sends it.
++ slow_mode_seconds: (req.body.slow_mode_seconds ?? req.body.slowmode_seconds) !== undefined
++ ? Math.max(0, parseInt(req.body.slow_mode_seconds ?? req.body.slowmode_seconds) || 0) : undefined,
followers_only: req.body.followers_only !== undefined
? parseBoolean(req.body.followers_only, false) : undefined,
++ // Sub-only chat (enforced by OpenVibe.Chat): active subscribers and the channel's moderators.
++ sub_only: req.body.sub_only !== undefined
++ ? parseBoolean(req.body.sub_only, false) : undefined,
emote_only: req.body.emote_only,
allow_anonymous: req.body.allow_anonymous !== undefined
? parseBoolean(req.body.allow_anonymous, true) : undefined,
@@@ -341,13 +344,14 @@ router.post('/:channelId/moderation/messages/:messageId/delete', requireAuth, re
db.deleteChatMessage(messageId, req.user.id);
-- // Broadcast deletion to connected clients
-- if (message.stream_id) {
-- chatServer.broadcastToStream(message.stream_id, { type: 'delete-messages', ids: [messageId] });
-- chatServer.forwardToGlobal(message.stream_id, { type: 'delete-messages', ids: [messageId] });
-- } else {
-- chatServer.broadcastGlobal({ type: 'delete-messages', ids: [messageId] });
-- }
++ // Broadcast the deletion to every surface that shows the line: the streamer's whole channel
++ // room (every live slot, the offline room, a channel popout) and the global feed.
++ const delPayload = { type: 'delete-messages', ids: [messageId] };
++ const chanUid = message.channel_user_id || (message.stream_id ? (db.getStreamById(message.stream_id)?.user_id || null) : null);
++ if (chanUid || message.stream_id) chatServer.broadcastToChannelRoom(chanUid, message.stream_id || null, delPayload);
++ if (message.stream_id) chatServer.forwardToGlobal(message.stream_id, delPayload);
++ else if (chanUid) chatServer.forwardToGlobalByChannel(chanUid, delPayload);
++ else chatServer.broadcastGlobal(delPayload);
db.logModerationAction({
scope_type: 'channel',
ddiff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
iindex 422e8e4e..3bd80cfc 100644
---- a/server/chat/live-context-routes.js
++++ b/server/chat/live-context-routes.js
@@@ -10,6 +10,7 @@
* GET /users?after_id&limit → { rows } users projection (paged by id)
* POST /users/lookup {ids?, usernames?} → { users }
* GET /users/:id/follows → { streamer_ids }
++ * GET /subscriber?user_id|subject&streamer_id → { subscriber } (an active channel subscription: sub-only chat)
* GET /users/profile?username&viewer_id → the /api/chat/user/:username/profile card
* GET /streams?after_id&limit, /streams/active, /streams/:id
* GET /managed-streams?after_id&limit, /channels?after_id&limit, /channels/by-user/:userId
@@@ -135,6 +136,29 @@ contextRouter.get('/users/:id/follows', (req, res) => {
res.json({ streamer_ids: db.all('SELECT streamer_id FROM follows WHERE follower_id = ?', [int(req.params.id)]).map((r) => r.streamer_id) });
});
++// Sub-only chat (OpenVibe.Chat): does this person hold an ACTIVE subscription to this streamer's
++// channel? Live's own rule (db.isActiveSubscriber: status active, current period not over; Billing's
++// entitlement under BILLING_AUTHORITY=billing, read fresh here). Network VIP is not a subscription.
++// The person by Live user id or by Network subject. Chat caches the answer briefly and treats a
++// failed read as "no" for everyone but the room's moderators.
++contextRouter.get('/subscriber', async (req, res) => {
++ const streamerId = int(req.query.streamer_id);
++ let userId = int(req.query.user_id);
++ if (!userId && req.query.subject) {
++ userId = db.get("SELECT user_id FROM linked_accounts WHERE service = 'network' AND subject_id = ? ORDER BY id DESC LIMIT 1", [String(req.query.subject)])?.user_id || 0;
++ if (!userId) return res.json({ subscriber: false, user_id: null, streamer_id: streamerId || null });
++ }
++ if (!userId || !streamerId) return fail(res, 400, 'user_id (or subject) and streamer_id required');
++ try {
++ if (process.env.BILLING_AUTHORITY && require('../monetization/money-authority').onBilling()) {
++ await require('../monetization/billing-actions').refreshEntitlement(userId, streamerId).catch(() => {});
++ }
++ res.json({ subscriber: !!db.isActiveSubscriber(userId, streamerId), user_id: userId, streamer_id: streamerId });
++ } catch (err) {
++ fail(res, 500, 'subscription lookup failed');
++ }
++});
++
contextRouter.get('/streams', (req, res) => {
const { after, limit } = pageArgs(req);
res.json({ rows: db.all(`${STREAM_SQL} WHERE id > ? ORDER BY id LIMIT ?`, [after, limit]) });
@@@ -341,13 +365,14 @@ effectsRouter.post('/approve-ip', (req, res) => {
res.json({ ok: true });
});
--// /slow persists the channel's slow mode.
++// /slow and /subonly persist the channel's slow mode and sub-only mode (Chat enforces the saved values).
effectsRouter.post('/channel-settings', (req, res) => {
const channelId = int(req.body?.channel_id);
const actor = actorOf(int(req.body?.actor_user_id));
if (!channelId || !actor || !permissions.canModerateChannel(actor, channelId)) return fail(res, 403, 'You do not have permission.');
const fields = {};
if (req.body.fields && req.body.fields.slow_mode_seconds !== undefined) fields.slow_mode_seconds = Math.max(0, int(req.body.fields.slow_mode_seconds));
++ if (req.body.fields && req.body.fields.sub_only !== undefined) fields.sub_only = [true, 1, '1', 'true', 'on'].includes(req.body.fields.sub_only) ? 1 : 0;
if (!Object.keys(fields).length) return fail(res, 400, 'no chat-settable fields');
chatTables.write('upsertChannelModerationSettings', channelId, fields)
.then(() => res.json({ ok: true }), (err) => fail(res, err.status || 500, err.message));
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 671965e7..78a9f47d 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -1687,6 +1687,8 @@ function initDb() {
if (!cols.includes('sounds_mods_only')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN sounds_mods_only INTEGER DEFAULT 0');
// Allow channel mods to edit the streamer's About/panels (off by default).
if (!cols.includes('mods_can_edit_about')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN mods_can_edit_about INTEGER DEFAULT 0');
++ // Sub-only chat: only active subscribers of the channel (and its moderators) may chat (OpenVibe.Chat enforces it).
++ if (!cols.includes('sub_only')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN sub_only INTEGER DEFAULT 0');
} catch (e) { console.warn('[DB] channel_moderation_settings columns migration:', e.message); }
// Migrate: add channel_owner_id to emotes (viewer uploads targeting a channel) + channel_sounds table
@@@ -6475,6 +6477,7 @@ function getChannelModerationSettings(channelId) {
sound_max_speed: 3.0,
sound_min_pitch_cents: -1200,
sound_max_pitch_cents: 1200,
++ sub_only: 0,
};
}
@@@ -6578,6 +6581,8 @@ function upsertChannelModerationSettings(channelId, fields) {
if (fields.tts_max_length !== undefined) {
try { run('UPDATE channel_moderation_settings SET tts_max_length = ? WHERE channel_id = ?', [Math.min(1000, Math.max(10, Number(fields.tts_max_length) || 200)), channelId]); } catch { /* */ }
}
++ // sub_only the same way (OpenVibe.Chat's database.js writes it identically once it owns the table).
++ if (fields.sub_only !== undefined) run('UPDATE channel_moderation_settings SET sub_only = ? WHERE channel_id = ?', [fields.sub_only ? 1 : 0, channelId]);
return getChannelModerationSettings(channelId);
}
ddiff --git a/server/db/schema.sql b/server/db/schema.sql
iindex c0560269..c5f0cfd3 100644
---- a/server/db/schema.sql
++++ b/server/db/schema.sql
@@@ -546,6 +546,7 @@ CREATE TABLE IF NOT EXISTS channel_moderation_settings (
goal_sound_url TEXT, -- optional override sound for goal-reached
goal_sound_mime TEXT,
emote_scale INTEGER DEFAULT 100, -- emote display size in chat, percent (50-300)
++ sub_only INTEGER DEFAULT 0, -- only active subscribers (and the channel's moderators) may chat
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
);
ddiff --git a/test/channel-moderation-routes.test.js b/test/channel-moderation-routes.test.js
nnew file mode 100644
iindex 00000000..bef9d24c
---- /dev/null
++++ b/test/channel-moderation-routes.test.js
@@@ -0,0 +1,133 @@
++/**
++ * The dashboard's channel moderation routes (server/admin/channel-mod-routes.js), for the chat
++ * parity gaps of roadmap WS-I task 6:
++ * - the dashboard's slow mode is saved in slow_mode_seconds (it sent slowmode_seconds, which no
++ * writer knows, so it was never saved) and sub-only mode is a saved setting like the others;
++ * - deleting one message reaches every surface that shows it: the streamer's whole channel room
++ * (every live slot, the offline room, a channel popout) and the global feed.
++ *
++ * The real router on a temp database; only sign-in is stubbed (x-test-user) and the chat server's
++ * broadcasts are recorded.
++ *
++ * node test/channel-moderation-routes.test.js
++ */
++'use strict';
++const assert = require('assert');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++process.env.DB_PATH = path.join(os.tmpdir(), `ov-chanmod-${process.pid}.db`);
++process.env.NODE_ENV = 'test';
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++
++const db = require('../server/db/database');
++db.initDb();
++const raw = db.getDb();
++
++const auth = require('../server/auth/auth');
++auth.requireAuth = (req, res, next) => {
++ const u = db.getUserById(Number(req.headers['x-test-user'] || 0));
++ if (!u) return res.status(401).json({ error: 'Authentication required' });
++ req.user = u; req.authSource = 'network';
++ next();
++};
++
++// What the routes push to browsers.
++const chatServer = require('../server/chat/chat-server');
++const sent = [];
++for (const fn of ['broadcastToStream', 'broadcastToChannelRoom', 'forwardToGlobal', 'forwardToGlobalByChannel', 'broadcastGlobal']) {
++ chatServer[fn] = (...args) => { sent.push([fn, ...args.slice(0, -1), args[args.length - 1].type]); };
++}
++
++const addUser = (id, username, role) => raw.prepare("INSERT INTO users (id, username, display_name, email, password_hash, role) VALUES (?, ?, ?, ?, 'x', ?)").run(id, username, username, `${username}@x`, role);
++addUser(1, 'streamer', 'streamer');
++addUser(2, 'moddy', 'user');
++addUser(3, 'viewer', 'user');
++db.ensureChannel(1);
++const channel = db.getChannelByUserId(1);
++raw.prepare('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?)').run(channel.id, 2, 1);
++const streamId = Number(db.createStream({ user_id: 1, channel_id: channel.id, title: 'Live', protocol: 'webrtc' }).lastInsertRowid);
++const liveLine = Number(db.saveChatMessage({ stream_id: streamId, channel_user_id: 1, user_id: 3, username: 'viewer', message: 'while live' }).lastInsertRowid);
++const offlineLine = Number(db.saveChatMessage({ stream_id: null, channel_user_id: 1, user_id: 3, username: 'viewer', message: 'while offline' }).lastInsertRowid);
++
++const express = require('express');
++const app = express();
++app.use(express.json());
++app.use('/api/channels', require('../server/admin/channel-mod-routes'));
++const server = http.createServer(app).listen(0);
++
++function call(method, p, user, body) {
++ return new Promise((resolve, reject) => {
++ const data = body ? JSON.stringify(body) : null;
++ const req = http.request({ port: server.address().port, path: p, method, headers: { 'content-type': 'application/json', ...(user ? { 'x-test-user': String(user) } : {}) } }, (res) => {
++ let text = '';
++ res.on('data', (c) => { text += c; });
++ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text }); });
++ });
++ req.on('error', reject);
++ if (data) req.write(data);
++ req.end();
++ });
++}
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack || e.message); }
++}
++
++(async () => {
++ await new Promise((r) => server.once('listening', r));
++ const settings = () => db.getChannelModerationSettings(channel.id);
++
++ await check('the dashboard saves slow mode in slow_mode_seconds (the old slowmode_seconds too)', async () => {
++ let r = await call('PUT', `/api/channels/${channel.id}/moderation`, 1, { slow_mode_seconds: 7 });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(settings().slow_mode_seconds, 7);
++ assert.strictEqual(r.json.settings.slow_mode_seconds, 7);
++ r = await call('PUT', `/api/channels/${channel.id}/moderation`, 1, { slowmode_seconds: 4 });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(settings().slow_mode_seconds, 4, 'a cached dashboard that still sends slowmode_seconds');
++ });
++
++ await check('sub-only mode is a saved channel setting: the owner and a channel mod may set it, a viewer may not', async () => {
++ assert.strictEqual(settings().sub_only, 0, 'off by default');
++ assert.strictEqual((await call('PUT', `/api/channels/${channel.id}/moderation`, 3, { sub_only: true })).status, 403);
++ assert.strictEqual(settings().sub_only, 0);
++ let r = await call('PUT', `/api/channels/${channel.id}/moderation`, 2, { sub_only: true });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([settings().sub_only, settings().slow_mode_seconds], [1, 4], 'set; the rest kept');
++ r = await call('PUT', `/api/channels/${channel.id}/moderation`, 1, { sub_only: false, followers_only: true });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([settings().sub_only, settings().followers_only], [0, 1]);
++ const other = db.ensureChannel(3) || db.getChannelByUserId(3);
++ r = await call('PUT', `/api/channels/${other.id}/moderation`, 3, { sub_only: 1 });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(db.getChannelModerationSettings(other.id).sub_only, 1, 'a channel with no settings row yet');
++ });
++
++ await check('deleting a line reaches the whole channel room and the global feed', async () => {
++ sent.length = 0;
++ let r = await call('POST', `/api/channels/${channel.id}/moderation/messages/${liveLine}/delete`, 2);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(sent, [
++ ['broadcastToChannelRoom', 1, streamId, 'delete-messages'],
++ ['forwardToGlobal', streamId, 'delete-messages'],
++ ]);
++ sent.length = 0;
++ r = await call('POST', `/api/channels/${channel.id}/moderation/messages/${offlineLine}/delete`, 2);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(sent, [
++ ['broadcastToChannelRoom', 1, null, 'delete-messages'],
++ ['forwardToGlobalByChannel', 1, 'delete-messages'],
++ ], 'an offline line: the channel room (popout, channel page) and the global feed');
++ assert.strictEqual(db.getChatMessageById(offlineLine).is_deleted, 1);
++ });
++
++ server.close();
++ try { db.getDb().close(); } catch { /* */ }
++ for (const f of ['', '-wal', '-shm']) { try { require('fs').rmSync(process.env.DB_PATH + f, { force: true }); } catch { /* */ } }
++ quiet(failures ? `channel moderation routes: ${failures} failed` : 'channel moderation routes: all checks passed');
++ process.exit(failures ? 1 : 0);
++})();
ddiff --git a/test/chat-context.test.js b/test/chat-context.test.js
iindex c4ec7a21..5bc75cb0 100644
---- a/test/chat-context.test.js
++++ b/test/chat-context.test.js
@@@ -202,6 +202,30 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: viewer, fields: { slow_mode_seconds: 9 } } })).status, 403);
assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, fields: { slow_mode_seconds: 9 } } })).status, 200);
assert.strictEqual(db.getChannelModerationSettings(channel.id).slow_mode_seconds, 9);
++ // Sub-only mode is saved the same way (Chat enforces the saved value); a viewer cannot set it.
++ assert.strictEqual(db.getChannelModerationSettings(channel.id).sub_only, 0, 'off by default');
++ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: viewer, fields: { sub_only: 1 } } })).status, 403);
++ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, fields: { sub_only: 1 } } })).status, 200);
++ assert.deepStrictEqual([db.getChannelModerationSettings(channel.id).sub_only, db.getChannelModerationSettings(channel.id).slow_mode_seconds], [1, 9], 'sub_only saved, slow mode kept');
++ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, fields: { sub_only: 0 } } })).status, 200);
++ assert.strictEqual(db.getChannelModerationSettings(channel.id).sub_only, 0);
++ assert.strictEqual((await call('GET', `/internal/chat-context/channels/${channel.id}/policy`, { token: READ })).body.settings.sub_only, 0, 'the policy read carries it');
++
++ // 8a. Sub-only chat asks whether someone holds an ACTIVE subscription to the streamer's channel:
++ // by user id or Network subject; a lapsed period or a cancelled row is not one.
++ const subOf = (q) => call('GET', `/internal/chat-context/subscriber?${q}`, { token: READ });
++ assert.strictEqual((await call('GET', `/internal/chat-context/subscriber?user_id=${viewer}&streamer_id=${streamer}`, { token: WRITE })).status, 403, 'a read capability');
++ assert.strictEqual((await subOf(`streamer_id=${streamer}`)).status, 400);
++ assert.deepStrictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body, { subscriber: false, user_id: viewer, streamer_id: streamer });
++ db.upsertSubscription({ subscriber_id: viewer, streamer_id: streamer, status: 'active', current_period_end: new Date(Date.now() + 86400e3).toISOString() });
++ assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body.subscriber, true);
++ assert.strictEqual((await subOf(`subject=usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ&streamer_id=${streamer}`)).body.subscriber, true, 'by Network subject');
++ assert.strictEqual((await subOf(`subject=usr_01J9UNKNOWNZZZZZZZZZZZZZZZ&streamer_id=${streamer}`)).body.subscriber, false, 'an unknown subject');
++ assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${mod}`)).body.subscriber, false, 'another channel');
++ db.upsertSubscription({ subscriber_id: viewer, streamer_id: streamer, status: 'active', current_period_end: new Date(Date.now() - 60e3).toISOString() });
++ assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body.subscriber, false, 'the paid period is over');
++ db.upsertSubscription({ subscriber_id: viewer, streamer_id: streamer, status: 'canceled', current_period_end: new Date(Date.now() + 86400e3).toISOString() });
++ assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body.subscriber, false, 'not active');
const snd = path.join(tmp, 'sounds', 'alert.mp3'); fs.writeFileSync(snd, 'x');
assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, kind: 'donation', url: snd } })).status, 403, 'only the channel owner');
assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: '/etc/passwd' } })).status, 400, 'files stay in the sounds dir');