openvibers/openvibe.live Commits
Live reads the six chat tables from Chat (plan T3 step 3; Chat has been their writer since 14:46 UTC): server/chat/moderation-client.js asks Chat's internal read API (chat.moderation.read, service token, 30 s cache, a failure answers 'not a moderator' and defaults, off under LIVE_DRILL) for channel moderation, the channels a person moderates and emote counts; permissions are async on that client (sync peeks for the few paths that cannot await, warming the cache); setup-progress, the about page, clip moderation and account data read through it or leave the six to Chat; the dashboard, channel page and uploads call Chat's /api/chat/channels/… and /api/chat/ai/… directly; donation and goal alert sounds are played by Chat (bridge op playAlertSound); the channel rows Chat reads carry emote_sources and a channel-emote-sources effect writes them; the emote asset backfill and user_tags reader are gone. Live's old emote and channel-moderation routes stay one release for N-1 (they write through the bridge to Chat). New tests for the client and the alert op. npm test 154/156, 2 skipped (verified).
Changes
@@@ -4,7 +4,7 @@Self-hosted live streaming platform — Node.js/Express monolith, vanilla JS SPA frontend, SQLite (better-sqlite3). Part of the **OpenVibe network** with SSO via openvibe.network. See [README.md](README.md) for features and [docs/architecture.md](docs/architecture.md) for system design.--**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (`MEDIA_EVENTS_AUTHORITY`; the older `POST /internal/media-webhook` stays during the transition). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; the staged chat tables (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`) move to OpenVibe.Chat one at a time (register C-04, runbook in Chat `docs/staged-tables-cutover.md`), so write them only through `await require('./chat/chat-tables').write(...)` (`test/chat-tables-writers.test.js` enforces it); Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes.++**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (`MEDIA_EVENTS_AUTHORITY`; the older `POST /internal/media-webhook` stays during the transition). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; Chat owns `channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries` and `chat_timeline_events`; Live reads them only through `server/chat/moderation-client.js` (Chat's internal read API, capability `chat.moderation.read`, cached 30 s) and keeps no copy; Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes.## Commands@@@ -30,6 +30,13 @@ with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switchesunless `CHAT_AUTHORITY=chat`.- Live's chat tables become Chat's read mirror (`POST /internal/chat-effects/mirror`): same ids, so homestats, recaps, AI context, VOD chat replay and the `/api/mod` queues keep reading them in place.++- **The six chat tables are Chat's** (roadmap T3): `channel_moderators`, `channel_moderation_settings`,++ `emotes`, `user_tags`, `chat_ai_summaries` and `chat_timeline_events`. Live keeps no copy and reads them++ only through [server/chat/moderation-client.js](../server/chat/moderation-client.js) — Chat's internal++ read API (`GET /internal/moderation/...`, capability `chat.moderation.read`, cached 30 s, off under++ `LIVE_DRILL`). The dashboard, channel page and upload UI call Chat directly (`/api/chat/channels/:id/…`,++ `/api/emotes`, `/api/chat/ai/…`); alert sounds are Chat's, played on Live's request through the bridge op++ `playAlertSound [streamerId, streamId, kind]` ([server/monetization/alerts.js](../server/monetization/alerts.js)).## Features@@@ -2097,7 +2097,7 @@ async function _aiTlLoadMore() {_aiTl.loading = true;const track = document.getElementById('ai-tl-track');try {-- const data = await api(`/chat-ai/timeline/${encodeURIComponent(_aiTl.username)}?offset=${_aiTl.offset}&limit=${_aiTl.limit}`);++ const data = await api(`/chat/ai/timeline/${encodeURIComponent(_aiTl.username)}?offset=${_aiTl.offset}&limit=${_aiTl.limit}`);const sessions = data.sessions || [];if (track && sessions.length) track.insertAdjacentHTML('beforeend', sessions.map(_aiTimelineSessionHTML).join(''));_aiTl.offset += sessions.length;@@@ -2114,7 +2114,7 @@ async function loadChannelAiTimeline(username) {wrap.innerHTML = '<div class="loading">Loading AI timeline…</div>';_aiTl = { username, offset: 0, limit: 12, hasMore: false, loading: false, moments: {}, vodBySid: {}, index: null, io: null };try {-- const data = await api(`/chat-ai/timeline/${encodeURIComponent(username)}?offset=0&limit=12`);++ const data = await api(`/chat/ai/timeline/${encodeURIComponent(username)}?offset=0&limit=12`);const sessions = data.sessions || [];const dn = esc(data.display_name || username);const streamerOv = data.overview && (data.overview.overview || data.overview.overview_short);@@@ -2796,7 +2796,7 @@ async function _setAboutModsCanEdit(v) {if (!_aboutChannelId) { await _loadAboutModsSetting(); }if (!_aboutChannelId) return;try {-- await api(`/channels/${_aboutChannelId}/moderation`, { method: 'PUT', body: { mods_can_edit_about: v ? 1 : 0 } });++ await api(`/chat/channels/${_aboutChannelId}/moderation`, { method: 'PUT', body: { mods_can_edit_about: v ? 1 : 0 } });toast(v ? 'Mods can now edit your About' : 'Mods can no longer edit your About', 'success');} catch (e) { toast(e.message || 'Save failed', 'error'); }}@@@ -20,7 +20,7 @@ async function loadGlobalChatAi() {const panel = document.getElementById('global-ai-panel');if (!strip || !panel) return;let insight = null;-- try { insight = (await api('/chat-ai/global')).insight; } catch { /* silent */ }++ try { insight = (await api('/chat/ai/global')).insight; } catch { /* silent */ }if (!insight || !insight.overview) {strip.style.display = 'none';// Keep the panel only if the user had already opened it.@@@ -93,7 +93,7 @@ async function _gaiTlLoad(reset) {if (reset && _gaiTl.periodBefore) p.set('before', String(_gaiTl.periodBefore));else if (!reset && _gaiTl.oldestTs) p.set('before', String(_gaiTl.oldestTs));let data;-- try { data = await api('/chat-ai/timeline?' + p.toString()); } catch { _gaiTl.loading = false; return; }++ try { data = await api('/chat/ai/timeline?' + p.toString()); } catch { _gaiTl.loading = false; return; }const el = document.getElementById('global-ai-timeline');if (!el) { _gaiTl.loading = false; return; }const events = data.events || [];@@@ -1515,7 +1515,7 @@ window._uaiRevealMore = _uaiRevealMore;async function openUserChatInsight(userId, username) {if (!userId) return;-- return _openChatInsightModal({ title: username || 'User', username: username || '', iconClass: 'fa-user-tag', fetchUrl: `/chat-ai/user/${userId}` });++ return _openChatInsightModal({ title: username || 'User', username: username || '', iconClass: 'fa-user-tag', fetchUrl: `/chat/ai/user/${userId}` });}window.openUserChatInsight = openUserChatInsight;@@@ -1526,7 +1526,7 @@ async function openAnonChatInsight(anonId) {title: anonId,iconClass: 'fa-user-secret',subtitle: 'Anonymous chatter — how they chat today vs. overall, from their public messages.',-- fetchUrl: `/chat-ai/anon/${encodeURIComponent(anonId)}`,++ fetchUrl: `/chat/ai/anon/${encodeURIComponent(anonId)}`,});}window.openAnonChatInsight = openAnonChatInsight;@@@ -1540,7 +1540,7 @@ async function openRelayUserChatInsight(platform, username, displayPlatform) {iconClass: 'fa-link',isRelay: true, // no channel page → expand the timeline inline instead of linking outsubtitle: `Bridged ${plat} chatter — how they chat today vs. overall, from their relayed messages.`,-- fetchUrl: `/chat-ai/relay/${encodeURIComponent(platform)}/${encodeURIComponent(username)}`,++ fetchUrl: `/chat/ai/relay/${encodeURIComponent(platform)}/${encodeURIComponent(username)}`,});}window.openRelayUserChatInsight = openRelayUserChatInsight;@@@ -256,7 +256,7 @@body.innerHTML = '<div class="cu-empty">Loading channel settings…</div>';let ch = null;try {-- const r = await fetch('/api/channels/moderation/mine', { headers: { Authorization: `Bearer ${token()}` } });++ const r = await fetch('/api/chat/channels/moderation/mine', { headers: { Authorization: `Bearer ${token()}` } });const data = await r.json();const list = data.channels || [];// Prefer the channel currently being viewed (works offline), else own channel.@@@ -373,7 +373,7 @@const btn = g('#cu-set-save');btn.disabled = true;try {-- const r = await fetch(`/api/channels/${channelId}/moderation`, {++ const r = await fetch(`/api/chat/channels/${channelId}/moderation`, {method: 'PUT', headers: { Authorization: `Bearer ${token()}`, 'Content-Type': 'application/json' }, body: JSON.stringify(payload),});const data = await r.json();@@@ -6397,7 +6397,7 @@ async function saveChatLimits() {const maxmsg = Math.min(_msgCap, Math.max(1, parseInt(document.getElementById('clp-maxmsg')?.value, 10) || 500));const maxtts = Math.min(1200, Math.max(10, parseInt(document.getElementById('clp-maxtts')?.value, 10) || 200));try {-- await api(`/channels/${_chatLimitsChannelId}/moderation`, { method: 'PUT', body: { max_message_length: maxmsg, tts_max_length: maxtts } });++ await api(`/chat/channels/${_chatLimitsChannelId}/moderation`, { method: 'PUT', body: { max_message_length: maxmsg, tts_max_length: maxtts } });applyChatLimits({ max_message_length: maxmsg, tts_max_length: maxtts });toast('Chat limits saved', 'success');document.getElementById('chat-limits-popover')?.remove();@@@ -227,7 +227,7 @@ async function loadDashModeration() {body.innerHTML = '<p class="muted">Loading moderation tools...</p>';try {-- const data = await api('/channels/moderation/mine');++ const data = await api('/chat/channels/moderation/mine');dashModerationChannels = data.channels || [];renderDashModerationChannels(dashModerationChannels);} catch (err) {@@@ -244,7 +244,7 @@ async function dashLoadChannelModerationLogs(channelId) {if (!target) return;try {-- const data = await api(`/channels/${channelId}/moderation/logs?limit=12`);++ const data = await api(`/chat/channels/${channelId}/moderation/logs?limit=12`);const actions = data.actions || [];target.innerHTML = actions.length ? actions.map((action) => `<div class="dash-mod-log-entry">@@@ -264,7 +264,7 @@ window.dashAddChannelModerator = async function dashAddChannelModerator(channelIif (!username) return toast('Enter a username to add.', 'error');try {-- await api(`/channels/${channelId}/mods`, {++ await api(`/chat/channels/${channelId}/mods`, {method: 'POST',body: { username },});@@@ -279,7 +279,7 @@ window.dashRemoveChannelModerator = async function dashRemoveChannelModerator(chif (!confirm(`Remove ${username} as a channel moderator?`)) return;try {-- await api(`/channels/${channelId}/mods/${userId}`, { method: 'DELETE' });++ await api(`/chat/channels/${channelId}/mods/${userId}`, { method: 'DELETE' });toast(`${username} removed`, 'success');await dashReloadModeration();} catch (err) {@@@ -289,7 +289,7 @@ window.dashRemoveChannelModerator = async function dashRemoveChannelModerator(chwindow.dashSaveChannelModerationSettings = async function dashSaveChannelModerationSettings(channelId) {try {-- await api(`/channels/${channelId}/moderation`, {++ await api(`/chat/channels/${channelId}/moderation`, {method: 'PUT',body: {slow_mode_seconds: Number(document.getElementById(`dash-mod-slow-${channelId}`)?.value || 0),@@@ -351,7 +351,7 @@ window.dashSearchChannelChat = async function dashSearchChannelChat(channelId) {if (query) params.set('q', query);if (userId) params.set('user_id', userId);-- const data = await api(`/channels/${channelId}/moderation/chat-search?${params}`);++ const data = await api(`/chat/channels/${channelId}/moderation/chat-search?${params}`);const messages = data.messages || [];resultTarget.innerHTML = messages.length ? messages.map((message) => `@@@ -373,7 +373,7 @@ window.dashDeleteChannelMessage = async function dashDeleteChannelMessage(channeif (!confirm('Delete this chat message?')) return;try {-- await api(`/channels/${channelId}/moderation/messages/${messageId}/delete`, { method: 'POST' });++ await api(`/chat/channels/${channelId}/moderation/messages/${messageId}/delete`, { method: 'POST' });toast('Message deleted', 'success');await dashLoadChannelModerationLogs(channelId);await window.dashSearchChannelChat(channelId);@@@ -35,12 +35,12 @@ function parseBoolean(value, fallback) {}/** Middleware: require channel access (owner, channel mod, or staff). */--function requireChannelAccess(req, res, next) {++async function requireChannelAccess(req, res, next) {const channelId = parseInt(req.params.channelId, 10);if (!channelId) return res.status(400).json({ error: 'Invalid channel ID' });const channel = db.getChannelById(channelId);if (!channel) return res.status(404).json({ error: 'Channel not found' });-- if (!permissions.canModerateChannel(req.user, channelId)) {++ if (!(await permissions.canModerateChannel(req.user, channelId))) {return res.status(403).json({ error: 'Channel moderation access required' });}req.channel = channel;@@@ -194,7 +194,7 @@ router.delete('/users/:id/ban', permissions.requireGlobalMod, (req, res) => {// ── Stream Ban (any mod with stream moderation powers) ───────// Local ban: creates bans entry scoped to a specific stream--router.post('/stream-ban', (req, res) => {++router.post('/stream-ban', async (req, res) => {try {const { user_id, anon_id, stream_id, reason, duration_hours, ip_address } = req.body;const streamId = parseInt(stream_id);@@@ -202,7 +202,7 @@ router.post('/stream-ban', (req, res) => {if (!user_id && !anon_id) return res.status(400).json({ error: 'user_id or anon_id required' });// Check moderation permission for this stream-- if (!permissions.canModerateStream(req.user, streamId)) {++ if (!(await permissions.canModerateStream(req.user, streamId))) {return res.status(403).json({ error: 'You cannot moderate this stream' });}@@@ -266,7 +266,7 @@ router.post('/stream-ban', (req, res) => {});// ── Unban ────────────────────────────────────────────────────--router.delete('/ban/:id', (req, res) => {++router.delete('/ban/:id', async (req, res) => {try {const ban = db.get('SELECT * FROM bans WHERE id = ?', [req.params.id]);if (!ban) return res.status(404).json({ error: 'Ban not found' });@@@ -285,7 +285,7 @@ router.delete('/ban/:id', (req, res) => {[ban.banned_by, '%' + (ban.reason || '') + '%']);}} else {-- if (!permissions.canModerateStream(req.user, ban.stream_id)) {++ if (!(await permissions.canModerateStream(req.user, ban.stream_id))) {return res.status(403).json({ error: 'You cannot moderate this stream' });}}@@@ -361,7 +361,7 @@ router.get('/chat/user/:userId', permissions.requireGlobalMod, (req, res) => {// ══════════════════════════════════════════════════════════════// ── Delete a single chat message ─────────────────────────────--router.post('/delete-message', (req, res) => {++router.post('/delete-message', async (req, res) => {try {const { message_id, stream_id } = req.body;if (!message_id) return res.status(400).json({ error: 'message_id required' });@@@ -377,10 +377,10 @@ router.post('/delete-message', (req, res) => {const isGlobal = permissions.isGlobalModOrAbove(req.user);if (!isGlobal) {let allowed = false;-- if (message.stream_id) allowed = permissions.canModerateStream(req.user, message.stream_id);++ if (message.stream_id) allowed = await permissions.canModerateStream(req.user, message.stream_id);else if (message.channel_user_id) {const ownerChannel = db.getChannelByUserId(message.channel_user_id);-- allowed = !!(ownerChannel && permissions.canModerateChannel(req.user, ownerChannel.id));++ allowed = !!(ownerChannel && await permissions.canModerateChannel(req.user, ownerChannel.id));}if (!allowed) return res.status(403).json({ error: 'You cannot moderate this stream' });}@@@ -416,7 +416,7 @@ router.post('/delete-message', (req, res) => {});// ── Bulk-delete all messages from a user/anon/relay ──────────--router.post('/delete-user-messages', (req, res) => {++router.post('/delete-user-messages', async (req, res) => {try {const { user_id, anon_id, relay_username, stream_id } = req.body;if (!user_id && !anon_id && !relay_username) {@@@ -429,7 +429,7 @@ router.post('/delete-user-messages', (req, res) => {if (!isGlobal) {if (!stream_id) return res.status(400).json({ error: 'stream_id required for stream moderators' });-- if (!permissions.canModerateStream(req.user, stream_id)) {++ if (!(await permissions.canModerateStream(req.user, stream_id))) {return res.status(403).json({ error: 'You cannot moderate this stream' });}}@@@ -481,7 +481,7 @@ router.post('/delete-user-messages', (req, res) => {// ══════════════════════════════════════════════════════════════// ── Hide or ban a relayed external user ──────────────────────--router.post('/relay-user/hide', (req, res) => {++router.post('/relay-user/hide', async (req, res) => {try {const { channel_id, platform, external_username, action, reason } = req.body;if (!platform || !external_username) {@@@ -496,7 +496,7 @@ router.post('/relay-user/hide', (req, res) => {if (!channel_id) return res.status(403).json({ error: 'Only staff can hide relay users site-wide' });const channel = db.getChannelById(channel_id);if (!channel) return res.status(404).json({ error: 'Channel not found' });-- if (!permissions.canModerateChannel(req.user, channel.id)) {++ if (!(await permissions.canModerateChannel(req.user, channel.id))) {return res.status(403).json({ error: 'You cannot moderate this channel' });}}@@@ -539,13 +539,13 @@ router.post('/relay-user/hide', (req, res) => {});// ── Unhide a relayed user ────────────────────────────────────--router.delete('/relay-user/:id', (req, res) => {++router.delete('/relay-user/:id', async (req, res) => {try {const row = db.get('SELECT id, channel_id FROM hidden_relay_users WHERE id = ?', [parseInt(req.params.id)]);if (!row) return res.status(404).json({ error: 'Not found' });// Site rows are staff's; a channel row belongs to that channel's moderators. This had no check.const allowed = permissions.isGlobalModOrAbove(req.user)-- || (row.channel_id != null && permissions.canModerateChannel(req.user, row.channel_id));++ || (row.channel_id != null && await permissions.canModerateChannel(req.user, row.channel_id));if (!allowed) return res.status(403).json({ error: 'You cannot moderate this channel' });db.unhideRelayUser(row.id);@@@ -564,10 +564,10 @@ router.delete('/relay-user/:id', (req, res) => {});// ── List hidden relay users for a channel ────────────────────--router.get('/relay-users/hidden/:channelId', (req, res) => {++router.get('/relay-users/hidden/:channelId', async (req, res) => {try {const channelId = parseInt(req.params.channelId);-- if (!permissions.canModerateChannel(req.user, channelId)) return res.status(403).json({ error: 'Access denied' });++ if (!(await permissions.canModerateChannel(req.user, channelId))) return res.status(403).json({ error: 'Access denied' });const hidden = db.getHiddenRelayUsers(channelId);res.json({ hidden });} catch (err) {@@@ -581,12 +581,12 @@ router.get('/relay-users/hidden/:channelId', (req, res) => {// ══════════════════════════════════════════════════════════════// ── Get pending messages for a channel ───────────────────────--router.get('/ip-approval/:channelId/pending', (req, res) => {++router.get('/ip-approval/:channelId/pending', async (req, res) => {try {const channelId = parseInt(req.params.channelId);// A channel id, checked as a channel. canModerateStream() was given this id as if it were a// stream id, so owning stream #N opened channel #N's queue — IP addresses and locations.-- if (!permissions.canModerateChannel(req.user, channelId)) {++ if (!(await permissions.canModerateChannel(req.user, channelId))) {return res.status(403).json({ error: 'Access denied' });}const pending = db.getPendingIpMessages(channelId);@@@ -21,8 +21,9 @@* kept rows point at nobody.** Never touched: the frozen tables (vods, clips, pastes, paste_likes, paste_comments, comments), which Media and-- * Community own and erase themselves. A staged chat table (chat-tables.js) is erased by whichever side writes it now:-- * here while Live does (the capture relays the change to Chat's copy), by Chat once Chat does (its mirror relays it back).++ * Community own and erase themselves. The six chat tables (channel_moderators, channel_moderation_settings, emotes,++ * user_tags, chat_ai_summaries, chat_timeline_events) are OpenVibe.Chat's (roadmap T3) and erased by its eraser++ * (Chat server/chat/account-data.js), never here.** Applied once per export or deletion (account_data_events); a redelivery resends only what did not reach Network.*/@@@ -44,8 +45,8 @@ const EXTRA = [['channel_points', 'streamer_id'], ['arena_mic_moments', 'target_const SECRET_COL = /(^|_)(token|tokens|secret|hash|password|code|key|keys|p256dh|auth|cookie|cookies|credentials)($|_)/i;const ROW_LIMIT = 2000;const PART_BUDGET = 18 * 1024 * 1024;----function staged() { return new Set(Object.keys(require('../chat/chat-tables').TABLES)); }++// OpenVibe.Chat owns these and erases them itself (roadmap T3); Live must not touch them.++const CHAT_TABLES = new Set(['channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events']);function ensureSchema(d) {d.exec(`CREATE TABLE IF NOT EXISTS account_data_events (@@@ -128,7 +129,6 @@ function eraseUsers(d, userIds, { now = new Date().toISOString() } = {}) {const erased = {};const retained = {};const bump = (o, k, n) => { if (n) o[k] = (o[k] || 0) + n; };-- const stagedTables = staged();const cols = personColumns(d);const usersCols = d.prepare('PRAGMA table_info(users)').all().map((c) => c.name);const onBilling = (() => { try { return require('../monetization/money-authority').onBilling(); } catch { return false; } })();@@@ -137,7 +137,8 @@ function eraseUsers(d, userIds, { now = new Date().toISOString() } = {}) {for (const [t, list] of cols) {const where = list.map((c) => `${q(c.col)} = ?`).join(' OR ');if (RETAIN.has(t)) { bump(retained, t, d.prepare(`SELECT COUNT(*) AS n FROM ${q(t)} WHERE ${where}`).get(...list.map(() => uid)).n); continue; }-- if (stagedTables.has(t) && require('../chat/chat-tables').authority(t) !== 'live') { bump(retained, 'left_for_chat', d.prepare(`SELECT COUNT(*) AS n FROM ${q(t)} WHERE ${where}`).get(...list.map(() => uid)).n); continue; }++ // OpenVibe.Chat's tables: its own eraser deletes them; Live reports the rows it left for it.++ if (CHAT_TABLES.has(t)) { bump(retained, 'left_for_chat', d.prepare(`SELECT COUNT(*) AS n FROM ${q(t)} WHERE ${where}`).get(...list.map(() => uid)).n); continue; }for (const c of list) {if (c.action === 'null') {const info = d.prepare(`PRAGMA table_info(${q(t)})`).all().filter((x) => NAME_COLS.includes(x.name));@@@ -14,6 +14,7 @@*/const db = require('../db/database');++const moderation = require('../chat/moderation-client');const { staff } = require('openvibe-contracts');// ── Role hierarchy (higher = more power) ─────────────────────@@@ -117,10 +118,19 @@ function isStreamer(user) {/*** Is this user a channel moderator for the given channel?++ * OpenVibe.Chat owns channel_moderators (roadmap T3), so this reads Chat (server/chat/moderation-client.js).++ * Async: hot paths that cannot await use the cached isChannelModSync().*/--function isChannelMod(user, channelId) {++async function isChannelMod(user, channelId) {if (!user?.id || !channelId) return false;-- return !!db.isChannelModerator(user.id, channelId);++ const m = await moderation.getChannelModeration(channelId);++ return m.moderator_ids.includes(Number(user.id));++}++/** The same question from the client's 30 s cache; a miss is "not a moderator" and refreshes behind the read. */++function isChannelModSync(user, channelId) {++ if (!user?.id || !channelId) return false;++ const m = moderation.peekChannelModeration(channelId);++ return !!(m && m.moderator_ids.includes(Number(user.id)));}/**@@@ -220,24 +230,38 @@ function canManageSiteBans(user) {** True for: admin, global_mod, channel owner, channel mod*/--function canModerateChannel(user, channelId) {++async function canModerateChannel(user, channelId) {if (!user) return false;if (can(user, 'staff.moderation.chat')) return true;if (isChannelOwner(user, channelId)) return true;return isChannelMod(user, channelId);}++function canModerateChannelSync(user, channelId) {++ if (!user) return false;++ if (can(user, 'staff.moderation.chat')) return true;++ if (isChannelOwner(user, channelId)) return true;++ return isChannelModSync(user, channelId);++}/*** Can this user moderate a specific stream's chat?** Resolves stream → channel, then checks channel moderation.*/--function canModerateStream(user, streamId) {++async function canModerateStream(user, streamId) {if (!user) return false;if (can(user, 'staff.moderation.chat')) return true;if (isStreamOwner(user, streamId)) return true;const channelId = getChannelIdForStream(streamId);-- if (channelId && isChannelMod(user, channelId)) return true;++ if (channelId && await isChannelMod(user, channelId)) return true;++ return false;++}++function canModerateStreamSync(user, streamId) {++ if (!user) return false;++ if (can(user, 'staff.moderation.chat')) return true;++ if (isStreamOwner(user, streamId)) return true;++ const channelId = getChannelIdForStream(streamId);++ if (channelId && isChannelModSync(user, channelId)) return true;return false;}@@@ -245,9 +269,12 @@ function canModerateStream(user, streamId) {* Can this user moderate a call on a specific stream?* Same rules as chat moderation.*/--function canModerateCall(user, streamId) {++async function canModerateCall(user, streamId) {return canModerateStream(user, streamId);}++function canModerateCallSync(user, streamId) {++ return canModerateStreamSync(user, streamId);++}/*** Can this user view chat logs?@@@ -327,7 +354,8 @@ function getCapabilities(user) {}const ownedChannel = db.getChannelByUserId(user.id);-- const moderatedChannels = db.getChannelsByModerator(user.id) || [];++ // Chat owns channel_moderators; the cached answer (or [], refreshed behind the load) decides the UI flag.++ const moderatedChannels = moderation.peekChannelsByModerator(user.id) || [];const isStaffUser = isGlobalModOrAbove(user);const owner = isOwner(user);@@@ -451,6 +479,7 @@ module.exports = {canModerateContentOwner,requireOwner,isChannelMod,++ isChannelModSync,isChannelOwner,isStreamOwner,roleRank,@@@ -464,8 +493,11 @@ module.exports = {canReviewVpn,canManageSiteBans,canModerateChannel,++ canModerateChannelSync,canModerateStream,++ canModerateStreamSync,canModerateCall,++ canModerateCallSync,canViewChatLogs,canViewOtherUserLogs,canAssignChannelMods,@@@ -291,6 +291,8 @@ function create(ChatServer) {// ── Pushes to browsers (Chat delivers) ──broadcastToStream: send('broadcastToStream'),broadcastToChannelRoom: send('broadcastToChannelRoom'),++ /** A donation / goal alert: Chat resolves the sound from its own settings row and plays it. */++ playAlertSound: send('playAlertSound'),broadcastGlobal: send('broadcastGlobal'),broadcastAll: send('broadcastAll'),forwardToGlobal: send('forwardToGlobal'),@@@ -17,6 +17,7 @@ const { v4: uuidv4 } = require('uuid');const db = require('../db/database');const { extractWsToken, authenticateWs } = require('../auth/auth');const permissions = require('../auth/permissions');++const moderation = require('./moderation-client');const wordFilter = require('./word-filter');const cosmetics = require('../monetization/cosmetics');const ttsEngine = require('./tts-engine');@@@ -518,7 +519,7 @@ class ChatServer {handleSelfDeleteHistory(ws, client) {if (!client) return;-- const canBypass = client.streamId ? permissions.canModerateStream(client.user, client.streamId) : false;++ const canBypass = client.streamId ? permissions.canModerateStreamSync(client.user, client.streamId) : false;const chatSettings = this._getChannelChatSettings(client.streamId);if (client.streamId && !canBypass && chatSettings.viewer_delete_all_enabled === 0) {this.sendTo(ws, { type: 'error', message: 'This streamer has disabled viewer self-delete for this chat.' });@@@ -667,7 +668,7 @@ class ChatServer {const stream = db.getStreamById(client.streamId);const channel = stream?.channel_id ? db.getChannelById(stream.channel_id) : (stream ? db.getChannelByUserId(stream.user_id) : null);if (channel) {-- const settings = db.getChannelModerationSettings(channel.id);++ const settings = moderation.settingsSync(channel.id);if (settings?.ip_approval_mode) {const isStaffBypass = client.user && permissions.isGlobalModOrAbove(client.user);const isOwner = client.user && stream && stream.user_id === client.user.id;@@@ -716,7 +717,7 @@ class ChatServer {if (modStreamId) {const chatSettings = this._getChannelChatSettings(modStreamId);const isStaff = client.user && permissions.isGlobalModOrAbove(client.user);-- const canModerateThisStream = permissions.canModerateStream(client.user, modStreamId);++ const canModerateThisStream = permissions.canModerateStreamSync(client.user, modStreamId);// Max message lengthconst maxLen = Math.max(50, Number(chatSettings.max_message_length || 500));@@@ -822,7 +823,7 @@ class ChatServer {const requestedAutoDeleteMinutes = parseInt(msg.auto_delete_minutes, 10);const allowViewerAutoDelete = !client.streamId|| this._getChannelChatSettings(client.streamId).viewer_auto_delete_enabled !== 0-- || permissions.canModerateStream(client.user, client.streamId);++ || permissions.canModerateStreamSync(client.user, client.streamId);const autoDeleteAt = Number.isFinite(requestedAutoDeleteMinutes)&& requestedAutoDeleteMinutes >= MIN_CHAT_AUTO_DELETE_MINUTES&& allowViewerAutoDelete@@@ -964,7 +965,7 @@ class ChatServer {// genuine human messages.try {let isMod = false;-- try { isMod = !!(client.user && permissions.canModerateChannel(client.user, client.channelUserId)); } catch { /* */ }++ try { isMod = !!(client.user && permissions.canModerateChannelSync(client.user, client.channelUserId)); } catch { /* */ }require('../integrations/ai-chatbot-service').onRealChatMessage(client.streamId, {username,message: text,@@@ -1013,7 +1014,7 @@ class ChatServer {// channel's current live stream rather than the stale id.if (client.channelUserId && pc.channelRelayEnabled(client.channelUserId, client.streamId)) {let isMod = false, isSub = false;-- try { isMod = !!(client.user && permissions.canModerateChannel(client.user, client.channelUserId)); } catch { /* */ }++ try { isMod = !!(client.user && permissions.canModerateChannelSync(client.user, client.channelUserId)); } catch { /* */ }try { isSub = !!(client.user && db.isActiveSubscriber(client.user.id, client.channelUserId)); } catch { /* */ }pc.forwardChat(client.channelUserId, {chatterName: username,@@@ -1573,16 +1574,10 @@ class ChatServer {seconds = parseInt(args);if (!Number.isFinite(seconds) || seconds < 0) seconds = 3;}-- // Per-stream slow mode (not global)++ // Per-stream slow mode (not global). OpenVibe.Chat owns channel_moderation_settings++ // (roadmap T3) and persists /slow itself; Live's own chat server only holds it in memory.if (client.streamId) {this.slowModeByStream.set(client.streamId, seconds > 0 ? seconds * 1000 : 0);-- // Persist to DB-- try {-- const stream = db.getStreamById(client.streamId);-- if (stream?.channel_id) {-- require('./chat-tables').write('upsertChannelModerationSettings', stream.channel_id, { slow_mode_seconds: seconds }).catch(() => { /* non-critical */ });-- }-- } catch { /* non-critical */ }}// Dedicated slowmode event so clients can show/hide UIthis.broadcastToStream(client.streamId, {@@@ -1985,7 +1980,7 @@ class ChatServer {if (!client.user) return false;// Offline channel chat: the channel's latest stream stands in (see _moderationStreamFor).const sid = client.streamId || this._moderationStreamFor(client);-- return !!sid && permissions.canModerateStream(client.user, sid) || permissions.isGlobalModOrAbove(client.user);++ return !!sid && permissions.canModerateStreamSync(client.user, sid) || permissions.isGlobalModOrAbove(client.user);}/** @deprecated Use canModerate(client) — kept temporarily for any external callers */@@@ -2438,6 +2433,14 @@ class ChatServer {} catch { /* non-critical */ }}++ /**++ * Donation / goal alert sounds are OpenVibe.Chat's now (roadmap T3): the mapping lives on++ * channel_moderation_settings, which Chat owns, and Chat resolves it from its own row and++ * broadcasts the clip. When Live runs the chat server itself (rollback / dev) there is no Chat to++ * ask, so this is a no-op; server/monetization/alerts.js calls the chat-remote proxy's op instead.++ */++ playAlertSound() { /* Chat plays alerts; see server/monetization/alerts.js */ }++/*** Get channel moderation settings for a stream.* Caches the channel lookup to avoid repeated DB queries.@@@ -2478,7 +2481,7 @@ class ChatServer {if (!stream) return finalize(defaults);const channel = stream.channel_id ? db.getChannelById(stream.channel_id) : db.getChannelByUserId(stream.user_id);if (!channel) return finalize(defaults);-- return finalize({ ...defaults, ...db.getChannelModerationSettings(channel.id) });++ return finalize({ ...defaults, ...moderation.settingsSync(channel.id) });} catch {return finalize(defaults);}@@@ -39,7 +39,6 @@ const config = require('../config');const permissions = require('../auth/permissions');const { guard } = require('../net/service-guard');const { isRemote } = require('./chat-authority');--const chatTables = require('./chat-tables');const contextRouter = express.Router();const effectsRouter = express.Router();@@@ -55,7 +54,7 @@ const USER_SQL = `SELECT u.id, u.username, u.display_name, u.avatar_url, u.profiCOALESCE(u.is_owner, 0) AS is_owner, u.created_at, ${SUBJECT_SQL} AS subject_id FROM users u`;const STREAM_SQL = 'SELECT id, user_id, channel_id, managed_stream_id, title, is_live, started_at, ended_at, created_at FROM streams';const MS_SQL = 'SELECT id, user_id, slug, title, sort_order, created_at FROM managed_streams';--const CHANNEL_SQL = 'SELECT id, user_id, title FROM channels';++const CHANNEL_SQL = 'SELECT id, user_id, title, emote_sources FROM channels';/** The chat projection of a user row (no secrets: no email, password, stream key, balances). */function userProjection(user, subjectId) {@@@ -191,16 +190,13 @@ contextRouter.get('/channels/by-user/:userId', (req, res) => {res.json({ channel: db.get(`${CHANNEL_SQL} WHERE user_id = ?`, [int(req.params.userId)]) || null });});contextRouter.get('/channels/:id/policy', (req, res) => {++ // OpenVibe.Chat owns channel_moderation_settings and channel_moderators (roadmap T3) and reads++ // them locally; Live answers only the channel row and the language it owns.const channelId = int(req.params.id);const channel = db.get(`${CHANNEL_SQL} WHERE id = ?`, [channelId]) || null;let language = 'en';try { if (channel) language = require('../i18n/translate').channelLanguage(channel.user_id) || 'en'; } catch { language = 'en'; }-- res.json({-- channel,-- settings: db.getChannelModerationSettings(channelId),-- moderator_ids: db.all('SELECT user_id FROM channel_moderators WHERE channel_id = ?', [channelId]).map((r) => r.user_id),-- language,-- });++ res.json({ channel, language });});contextRouter.get('/channels/:id/approved-ip', (req, res) => {res.json({ approved: db.isIpApproved(int(req.params.id), String(req.query.ip || '')) });@@@ -321,17 +317,17 @@ effectsRouter.post('/user-color', (req, res) => {// /ban, /timeout, /unban from chat. The moderator is checked again: global staff, or a// moderator of the stream chat decided on (for an offline channel room, its latest stream).--effectsRouter.post('/ban', (req, res) => {++effectsRouter.post('/ban', async (req, res) => {const b = req.body || {};const actor = actorOf(int(b.actor_user_id));const modStream = int(b.moderation_stream_id) || null;-- const allowed = actor && !actor.is_banned && (permissions.isGlobalModOrAbove(actor) || (modStream && permissions.canModerateStream(actor, modStream)));++ const allowed = actor && !actor.is_banned && (permissions.isGlobalModOrAbove(actor) || (modStream && await 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 (!staff && (!streamId || !(await 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');@@@ -365,33 +361,17 @@ effectsRouter.post('/approve-ip', (req, res) => {res.json({ ok: true });});--// /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));--});----// Donation / goal alert sounds: the streamer's own channel only, files in the shared sounds dir.--effectsRouter.post('/alert-sound', (req, res) => {-- const channelId = int(req.body?.channel_id);-- const channel = channelId ? db.getChannelById(channelId) : null;-- if (!channel || channel.user_id !== int(req.body?.actor_user_id)) return fail(res, 403, 'Not your channel');-- const url = req.body.url ? path.resolve(String(req.body.url)) : null;-- if (url) {-- // Compared through realpath: Chat and Live may reach the shared sounds dir via different symlinks.-- let inside = false;-- try { inside = path.dirname(fs.realpathSync(url)) === fs.realpathSync(path.resolve(config.sounds.path)); } catch { inside = false; }-- if (!inside) return fail(res, 400, 'alert sounds live in the sounds directory');-- }-- chatTables.write('setChannelAlertSound', channelId, req.body.kind === 'goal' ? 'goal' : 'donation', url, url ? String(req.body.mime || 'audio/mpeg') : null)-- .then(() => res.json({ ok: true }), (err) => fail(res, err.status || 500, err.message));++// The channel's emote-source switches (channels.emote_sources, a Live-owned column): OpenVibe.Chat++// persists nothing here itself, its PUT /api/emotes/sources asks Live to write the row.++// (The /channel-settings and /alert-sound effects were retired with roadmap T3: Chat owns++// channel_moderation_settings and writes it locally now.)++effectsRouter.post('/channel-emote-sources', (req, res) => {++ const userId = int(req.body?.user_id);++ const sources = req.body?.sources;++ if (!userId || !db.getUserById(userId)) return fail(res, 404, 'User not found');++ if (!sources || typeof sources !== 'object' || Array.isArray(sources)) return fail(res, 400, 'sources object required');++ db.updateChannel(userId, { emote_sources: JSON.stringify(sources) });++ res.json({ ok: true });});effectsRouter.post('/ensure-channel', (req, res) => {@@@ -501,7 +481,7 @@ effectsRouter.post('/media-queue', async (req, res) => {if (b.op === 'skip') {const actor = actorOf(int(b.actorUserId));const streamId = int(b.streamId) || null;-- const allowed = actor && (actor.id === streamerId || permissions.isGlobalModOrAbove(actor) || (streamId && permissions.canModerateStream(actor, streamId)));++ const allowed = actor && (actor.id === streamerId || permissions.isGlobalModOrAbove(actor) || (streamId && await permissions.canModerateStream(actor, streamId)));if (!allowed) return fail(res, 403, 'Only the streamer or a moderator can skip media.');const ended = mediaQueue.finishCurrent(streamerId, 'skipped');const next = mediaQueue.startNext(streamerId);@@@ -633,10 +613,8 @@ effectsRouter.post('/asset-sync', (req, res) => {// ── The read mirror of Chat's tables ──────────────────────────────// Same tables, same ids. Upserts set only the columns Live's table has (Chat's *subject_id// columns are skipped; Live-only columns such as channel_sounds.media_asset_id are kept).--// A staged table (roadmap C-04, chat-tables.js) is mirrored only while Chat writes it or hands it--// back; while Live writes it, Chat's rows for it are refused. Its rows REPLACE: the authority's row--// wins over whatever holds its key or one of its unique columns here (both copies have the same--// columns, so nothing of Live's is lost).++// The six staged tables (roadmap C-04) are Chat's now and are not mirrored here; only the C-02++// twelve below still are.const MIRROR_TABLES = {chat_messages: ['id'],dm_conversations: ['id'],@@@ -667,16 +645,12 @@ function applyMirror(changes) {try {d.transaction(() => {for (const c of changes) {-- const staged = !!(c && chatTables.TABLES[c.table]);-- const pk = MIRROR_TABLES[c && c.table] || (staged && chatTables.acceptsMirror(c.table) ? chatTables.TABLES[c.table] : null);-- if (!pk) { skipped.push({ table: c && c.table, reason: staged ? 'Live writes this table (chat_table_authority live)' : 'not a mirrored table' }); continue; }++ const pk = MIRROR_TABLES[c && c.table];++ if (!pk) { skipped.push({ table: c && c.table, reason: 'not a mirrored table' }); continue; }const have = liveColumns(c.table);try {if (c.op === 'delete') {d.prepare(`DELETE FROM ${c.table} WHERE ${pk.map((k) => `${k} = ?`).join(' AND ')}`).run(...pk.map((k) => c.pk[k]));-- } else if (c.op === 'upsert' && c.row && staged) {-- const cols = Object.keys(c.row).filter((k) => have.has(k) && /^[a-z_]+$/.test(k));-- d.prepare(`INSERT OR REPLACE INTO ${c.table} (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`).run(...cols.map((k) => c.row[k]));} else if (c.op === 'upsert' && c.row) {const cols = Object.keys(c.row).filter((k) => have.has(k) && /^[a-z_]+$/.test(k));const upd = cols.filter((k) => !pk.includes(k));nnew file mode 100644@@@ -0,0 +1,218 @@++'use strict';++/**++ * OpenVibe.Chat's internal read API (roadmap T3; Contracts 0.80.0 `chat.moderation.read`).++ *++ * Chat owns the six tables that used to be staged in Live — channel_moderators,++ * channel_moderation_settings, emotes, user_tags, chat_ai_summaries, chat_timeline_events — so++ * Live keeps no copy. The few reads Live's own features still need go through here:++ *++ * getChannelModeration(channelId) → { settings, moderator_ids } (Chat's row, or Live's defaults)++ * getChannelsByModerator(userId) → the channels a user moderates++ * getEmoteCount(channelIdOrUserId) → a channel's emote count++ *++ * Auth: Live's Network service principal (OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET),++ * client-credentials token for audience openvibe.chat with the ability `chat.moderation.read`++ * (the same construction server/media-client.js uses).++ *++ * Every answer is cached 30 s (per channel / per user); invalidate(channelId) drops a channel's++ * entries at once when Live's own UI changes it. A failure answers the safe default — "not a++ * moderator", the moderation defaults, zero emotes — and logs once, never a 500 on the hot path.++ * Off under LIVE_DRILL (a restore drill calls no other service) and without OV_OAUTH_CLIENT_SECRET.++ */++const { createServiceTokenClient } = require('openvibe-sdk/auth');++const { CHAT_URL } = require('./chat-authority');++++const AUDIENCE = 'openvibe.chat';++const CACHE_TTL_MS = 30_000;++const TIMEOUT_MS = 5000;++++// Live's channel-moderation defaults (Chat answers the channel's row or these).++const DEFAULT_SETTINGS = Object.freeze({++ slow_mode_seconds: 0, followers_only: 0, emote_only: 0, allow_anonymous: 1, links_allowed: 1,++ gifs_enabled: 1, account_age_gate_hours: 0, caps_percentage_limit: 0, aggressive_filter: 0,++ max_message_length: 500, tts_max_length: 200, slur_filter_enabled: 0, slur_filter_use_builtin: 1,++ slur_filter_terms: '', slur_filter_regexes: '', slur_filter_nudge_message: '', slur_filter_disabled_categories: '[]',++ ip_approval_mode: 0, soundboard_enabled: 1, soundboard_allow_pitch: 1, soundboard_allow_speed: 1,++ soundboard_banned_ids: '', viewer_auto_delete_enabled: 1, viewer_delete_all_enabled: 1,++ custom_emotes_enabled: 1, custom_sounds_enabled: 1, max_sound_seconds: 10, uploads_mods_only: 0,++ mods_can_edit_about: 0, emote_scale: 100, emote_size_min: 50, emote_size_max: 200, sounds_mods_only: 0,++ sound_min_speed: 0.5, sound_max_speed: 3.0, sound_min_pitch_cents: -1200, sound_max_pitch_cents: 1200, sub_only: 0,++});++++let tokens = null;++let lastLog = null;++const cache = new Map(); // key → { at, value }++const inflight = new Map(); // key → Promise++++function drill() { try { return require('../drill').enabled; } catch { return false; } }++/** True when Live can talk to Chat's internal API at all. */++function enabled() { return Boolean(process.env.OV_OAUTH_CLIENT_SECRET) && !drill(); }++++function serviceTokens() {++ if (tokens) return tokens;++ const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';++ if (!clientSecret) return null;++ const networkInternalUrl = String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');++ tokens = createServiceTokenClient({++ tokenUrl: `${networkInternalUrl}/oauth/token`,++ clientId: process.env.OV_OAUTH_CLIENT_ID || 'live',++ clientSecret,++ audience: AUDIENCE,++ });++ return tokens;++}++++function note(what, err) {++ const m = `OpenVibe.Chat ${what}: ${(err && err.message) || err}`;++ if (m === lastLog) return; // log once, until it changes or a read succeeds++ lastLog = m;++ console.warn(`[ChatRead] ${m}`);++}++++async function api(path) {++ const client = serviceTokens();++ if (!client) throw new Error('not configured');++ const token = await client.getToken();++ const res = await fetch(`${CHAT_URL}${path}`, {++ headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },++ signal: AbortSignal.timeout(TIMEOUT_MS),++ });++ if (!res.ok) throw new Error(`answered ${res.status}`);++ const out = await res.json().catch(() => null);++ if (!out || out.ok !== true) throw new Error(`refused (${(out && out.error) || 'bad answer'})`);++ return out;++}++++function cached(key) { const h = cache.get(key); return h && Date.now() - h.at < CACHE_TTL_MS ? h : null; }++function remember(key, value) {++ cache.set(key, { at: Date.now(), value });++ if (cache.size > 2000) cache.delete(cache.keys().next().value);++}++++function defaultModeration(channelId) {++ return { settings: { ...DEFAULT_SETTINGS, channel_id: channelId || 0 }, moderator_ids: [] };++}++++/** { settings, moderator_ids } for a channel (cached 30 s). */++async function getChannelModeration(channelId) {++ channelId = Number(channelId) || 0;++ if (!channelId) return defaultModeration(0);++ const key = `m:${channelId}`;++ const hit = cached(key);++ if (hit) return hit.value;++ if (inflight.has(key)) return inflight.get(key);++ const p = (async () => {++ if (!enabled()) return defaultModeration(channelId);++ try {++ const out = await api(`/internal/moderation/channels/${channelId}`);++ const settings = out.settings && typeof out.settings === 'object' ? { ...DEFAULT_SETTINGS, ...out.settings } : { ...DEFAULT_SETTINGS };++ const value = {++ settings: { ...settings, channel_id: channelId },++ moderator_ids: Array.isArray(out.moderator_ids) ? out.moderator_ids.map(Number) : [],++ };++ lastLog = null;++ remember(key, value);++ return value;++ } catch (err) {++ note(`channel ${channelId}`, err);++ const value = defaultModeration(channelId);++ remember(key, value);++ return value;++ }++ })().finally(() => inflight.delete(key));++ inflight.set(key, p);++ return p;++}++++/** The channels a user moderates (cached 30 s); each row carries `id` = channel_id. */++async function getChannelsByModerator(userId) {++ userId = Number(userId) || 0;++ if (!userId) return [];++ const key = `u:${userId}`;++ const hit = cached(key);++ if (hit) return hit.value;++ if (inflight.has(key)) return inflight.get(key);++ const p = (async () => {++ if (!enabled()) return [];++ try {++ const out = await api(`/internal/moderation/users/${userId}/channels`);++ const value = (Array.isArray(out.channels) ? out.channels : []).map((c) => {++ const id = Number(c.channel_id) || 0;++ return { id, channel_id: id, title: c.title || null, user_id: c.owner_user_id != null ? Number(c.owner_user_id) : null, owner_username: c.owner_username || null };++ });++ lastLog = null;++ remember(key, value);++ return value;++ } catch (err) {++ note(`user ${userId} channels`, err);++ remember(key, []);++ return [];++ }++ })().finally(() => inflight.delete(key));++ inflight.set(key, p);++ return p;++}++++/** A channel's emote count (a channels.id is resolved to its owner by Chat). */++async function getEmoteCount(channelIdOrUserId) {++ const id = Number(channelIdOrUserId) || 0;++ if (!id) return 0;++ const key = `e:${id}`;++ const hit = cached(key);++ if (hit) return hit.value;++ if (inflight.has(key)) return inflight.get(key);++ const p = (async () => {++ if (!enabled()) return 0;++ try {++ const out = await api(`/internal/moderation/channels/${id}/emote-count`);++ const n = Number(out.count) || 0;++ lastLog = null;++ remember(key, n);++ return n;++ } catch (err) {++ note(`channel ${id} emote count`, err);++ remember(key, 0);++ return 0;++ }++ })().finally(() => inflight.delete(key));++ inflight.set(key, p);++ return p;++}++++// ── Synchronous peeks (hot paths that cannot await) ─────────────++// Return the cached answer, else the safe default and refresh in the background.++++function peekChannelModeration(channelId) {++ const h = cached(`m:${Number(channelId) || 0}`);++ if (h) return h.value;++ getChannelModeration(channelId).catch(() => {});++ return null;++}++function peekChannelsByModerator(userId) {++ const h = cached(`u:${Number(userId) || 0}`);++ if (h) return h.value;++ getChannelsByModerator(userId).catch(() => {});++ return null;++}++/** The cached settings for a channel, defaults when unknown (never blocks). */++function settingsSync(channelId) {++ const h = cached(`m:${Number(channelId) || 0}`);++ return h ? h.value.settings : defaultModeration(channelId).settings;++}++++/** Drop a channel's cached answer (Live changed it through its own UI — none after T3). */++function invalidate(channelId) {++ const id = Number(channelId) || 0;++ if (!id) return;++ cache.delete(`m:${id}`);++ cache.delete(`e:${id}`);++}++++/** Drop every cached answer and the token (tests and env flips). */++function _reset() { cache.clear(); inflight.clear(); tokens = null; lastLog = null; }++++module.exports = {++ enabled, getChannelModeration, getChannelsByModerator, getEmoteCount,++ peekChannelModeration, peekChannelsByModerator, settingsSync, defaultModeration,++ DEFAULT_SETTINGS, invalidate, _reset, AUDIENCE, CACHE_TTL_MS,++};@@@ -171,8 +171,8 @@ router.post('/', requireAuth, soundUpload.single('sound'), async (req, res) => {return res.status(404).json({ error: 'Channel not found' });}-- const settings = db.getChannelModerationSettings(channel.id);-- const isMod = permissions.canModerateChannel(req.user, channel.id);++ const settings = (await require('./moderation-client').getChannelModeration(channel.id)).settings;++ const isMod = await permissions.canModerateChannel(req.user, channel.id);const isOwnChannel = channelOwnerId === req.user.id;if (!isMod && !settings.custom_sounds_enabled) {@@@ -284,14 +284,14 @@ router.use((err, req, res, next) => {});// ── Delete a sound ───────────────────────────────────────────--router.delete('/:id', requireAuth, (req, res) => {++router.delete('/:id', requireAuth, async (req, res) => {try {const sound = db.getChannelSoundById(parseInt(req.params.id));if (!sound) return res.status(404).json({ error: 'Sound not found' });let allowed = sound.created_by === req.user.id || permissions.can(req.user, 'staff.assets.manage');if (!allowed) {const channel = db.getChannelByUserId(sound.channel_owner_id);-- if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;++ if (channel && await permissions.canModerateChannel(req.user, channel.id)) allowed = true;}if (!allowed) return res.status(403).json({ error: 'Not your sound' });@@@ -310,7 +310,7 @@ router.delete('/:id', requireAuth, (req, res) => {// A command may hold several sounds, so edits apply to the whole group.// Allowed: channel owner, channel mods, admins, or the creator of every// sound in the group (mirrors the add-to-command rule).--router.patch('/command', requireAuth, (req, res) => {++router.patch('/command', requireAuth, async (req, res) => {try {let channelOwnerId = parseInt(req.body.channel_id) || null;if (!channelOwnerId && req.body.stream_id) {@@@ -324,7 +324,7 @@ router.patch('/command', requireAuth, (req, res) => {const group = (db.getChannelSounds(channelOwnerId) || []).filter((s) => s.command === command);if (!group.length) return res.status(404).json({ error: `No sound command !${command} in this channel.` });-- const isMod = permissions.canModerateChannel(req.user, channel.id);++ const isMod = await permissions.canModerateChannel(req.user, channel.id);const isOwnChannel = channelOwnerId === req.user.id;const isCreator = group.every((s) => s.created_by === req.user.id);if (!isMod && !isOwnChannel && !isCreator && !permissions.can(req.user, 'staff.assets.manage')) {@@@ -384,73 +384,9 @@ router.get('/file/:filename', (req, res) => {}});--// ── Streamer alert sounds (donation / goal-reached) ──────────────────────--// Streamer-only. Stored on the channel's moderation settings; the file lives in the--// sounds dir and is read server-side + broadcast as base64 on donation / goal events.--function _alertKind(raw) { return raw === 'goal' ? 'goal' : 'donation'; }----// GET which alert sounds are configured (+ a preview URL).--router.get('/alert/mine', requireAuth, (req, res) => {-- const s = db.getChannelAlertSoundsByUser(req.user.id) || {};-- const toUrl = (p) => (p ? `/api/sounds/file/${path.basename(p)}` : null);-- res.json({-- donation: { set: !!s.donation_sound_url, url: toUrl(s.donation_sound_url) },-- goal: { set: !!s.goal_sound_url, url: toUrl(s.goal_sound_url) },-- });--});----// Upload/replace an alert sound. kind = 'donation' | 'goal'.--router.post('/alert/:kind', requireAuth, soundUpload.single('sound'), async (req, res) => {-- const kind = _alertKind(req.params.kind);-- try {-- if (!req.file) return res.status(400).json({ error: 'No sound file' });-- const channel = db.getChannelByUserId(req.user.id) || db.ensureChannel(req.user.id);-- if (!channel) { fs.unlink(req.file.path, () => {}); return res.status(400).json({ error: 'No channel' }); }---- // Guard duration (reuse the channel's max-sound limit, capped at 15s for alerts).-- let duration = 0;-- try { duration = await probeDuration(req.file.path); } catch { /* */ }-- if (duration > 15.5) { fs.unlink(req.file.path, () => {}); return res.status(400).json({ error: `Alert sound too long (${duration.toFixed(1)}s). Max 15s.` }); }---- // Normalize to mp3 for universal playback; fall back to the original on failure.-- let finalPath = req.file.path;-- try {-- const mp3 = await convertToMp3(req.file.path);-- if (mp3) { fs.unlink(req.file.path, () => {}); finalPath = mp3; }-- } catch { /* keep original */ }---- // Remove the previous alert sound of this kind (best-effort).-- try {-- const prev = db.getChannelAlertSoundsByUser(req.user.id) || {};-- const prevPath = kind === 'goal' ? prev.goal_sound_url : prev.donation_sound_url;-- if (prevPath && fs.existsSync(prevPath) && path.resolve(prevPath) !== path.resolve(finalPath)) fs.unlink(prevPath, () => {});-- } catch { /* ignore */ }---- const ext = path.extname(finalPath).toLowerCase();-- const mime = EXT_TO_MIME[ext] || 'audio/mpeg';-- await require('./chat-tables').write('setChannelAlertSound', channel.id, kind, finalPath, mime);-- res.json({ set: true, kind, url: `/api/sounds/file/${path.basename(finalPath)}` });-- } catch (err) {-- if (req.file) fs.unlink(req.file.path, () => {});-- res.status(500).json({ error: 'Failed to save alert sound: ' + err.message });-- }--});----// Clear an alert sound.--router.delete('/alert/:kind', requireAuth, async (req, res) => {-- const kind = _alertKind(req.params.kind);-- try {-- const channel = db.getChannelByUserId(req.user.id);-- if (channel) {-- const prev = db.getChannelAlertSoundsByUser(req.user.id) || {};-- const prevPath = kind === 'goal' ? prev.goal_sound_url : prev.donation_sound_url;-- if (prevPath && fs.existsSync(prevPath)) { try { fs.unlinkSync(prevPath); } catch { /* */ } }-- await require('./chat-tables').write('setChannelAlertSound', channel.id, kind, null, null);-- }-- res.json({ set: false, kind });-- } catch (err) {-- res.status(500).json({ error: 'Failed to clear alert sound' });-- }--});++// Streamer alert sounds (donation / goal-reached) moved to OpenVibe.Chat with the six tables++// (roadmap T3): they live on channel_moderation_settings, which Chat owns, and nginx routes++// /api/sounds there. Live's own sounds router keeps only the channel-sound commands above and++// runs only when Live itself serves chat.module.exports = router;@@@ -67,15 +67,8 @@ function ensureTagTables() {try { db.getDb().exec('CREATE INDEX IF NOT EXISTS idx_user_tags_user ON user_tags(user_id)'); } catch { /* */ }}--/** Every tag a user owns. */--function getUserTags(userId) {-- const rows = db.getDb().prepare('SELECT tag_id, source, granted_at FROM user_tags WHERE user_id = ?').all(userId);-- return rows.map((r) => ({-- ...r,-- ...(TAGS[r.tag_id] || { name: r.tag_id, emoji: '🏷️', color: '#999', bgColor: '#333', desc: 'Unknown tag', category: 'special', tier: 0 }),-- tagId: r.tag_id,-- }));--}++// getUserTags (the only reader of `user_tags`) went with roadmap T3: OpenVibe.Chat owns that table,++// which had no production caller in Live. The equipped tag (user_equipped_tag) is not staged and stays./** The user's equipped tag, or null. */function getEquippedTag(userId) {@@@ -95,4 +88,4 @@ function getAllTags() {return Object.entries(TAGS).map(([id, t]) => ({ tagId: id, ...t }));}--module.exports = { TAGS, ensureTagTables, getUserTags, getEquippedTag, getTagProfile, getAllTags };++module.exports = { TAGS, ensureTagTables, getEquippedTag, getTagProfile, getAllTags };@@@ -4145,7 +4145,11 @@ function _computeHomeStats() {users: c(`SELECT COUNT(*) AS count FROM users WHERE COALESCE(is_banned, 0) = 0`),anons: c(`SELECT COUNT(*) AS count FROM anon_ip_mappings`),follows: c(`SELECT COUNT(*) AS count FROM follows`),-- emotes: c(`SELECT COUNT(*) AS count FROM emotes`),++ // No platform-wide emote total: OpenVibe.Chat owns `emotes` (roadmap T3) and exposes counts++ // per channel only (server/chat/moderation-client.js getEmoteCount), so there is nothing but++ // the old table to answer a site-wide number. null (as the other external figures above); the++ // key stays for the mixed-version window (test/n-1.test.js: the old client reads stats.emotes).++ emotes: null,pastes: null,aiMemories: c(`SELECT COUNT(*) AS count FROM stream_memories`),pasteImages: null,@@@ -313,7 +313,7 @@ router.post('/', requireAuth, emoteUpload.single('image'), async (req, res) => {return res.status(404).json({ error: 'Channel not found' });}const settings = db.getChannelModerationSettings(channel.id);-- const isMod = permissions.canModerateChannel(req.user, channel.id);++ const isMod = await permissions.canModerateChannel(req.user, channel.id);if (!settings.custom_emotes_enabled && !isMod) {fs.unlinkSync(req.file.path);return res.status(403).json({ error: 'This streamer has disabled viewer emote uploads.' });@@@ -425,7 +425,7 @@ router.patch('/:id', requireAuth, async (req, res) => {if (!allowed) {const ownerId = emote.channel_owner_id || emote.user_id;const channel = db.getChannelByUserId(ownerId);-- if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;++ if (channel && await permissions.canModerateChannel(req.user, channel.id)) allowed = true;}if (!allowed) return res.status(403).json({ error: 'Not your emote' });@@@ -483,7 +483,7 @@ router.delete('/:id', requireAuth, async (req, res) => {if (!allowed) {const ownerId = emote.channel_owner_id || emote.user_id;const channel = db.getChannelByUserId(ownerId);-- if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;++ if (channel && await permissions.canModerateChannel(req.user, channel.id)) allowed = true;}if (!allowed) {return res.status(403).json({ error: 'Not your emote' });@@@ -1,13 +1,14 @@/**-- * asset-sync.js — mirror Live's chat assets (emotes + channel sounds) into-- * OpenVibe.Media, their canonical public home (served at /a/:id, browsable on-- * the media index with uploader + channel attribution).++ * asset-sync.js — mirror Live's channel sounds into OpenVibe.Media, their canonical public home++ * (served at /a/:id, browsable on the media index with uploader + channel attribution).*-- * Local files remain the low-latency working copy (chat playback reads disk);-- * this job uploads anything Media doesn't have yet and records media_url /-- * media_asset_id on the local row, so emote <img> URLs come from Media's-- * long-cached endpoint and deletes propagate. Idempotent: rows are skipped-- * once media_asset_id is set, and Media upserts on (kind, name, channel).++ * Local files remain the low-latency working copy (chat playback reads disk); this job uploads++ * anything Media doesn't have yet and records media_url / media_asset_id on the local row, so++ * sound URLs come from Media's long-cached endpoint and deletes propagate. Idempotent: rows are++ * skipped once media_asset_id is set, and Media upserts on (kind, name, channel).++ *++ * The emote backfill went with roadmap T3: OpenVibe.Chat owns `emotes` and uploads emote bytes to++ * Media with its own token, so Live no longer has emote rows to sync.*/'use strict';@@@ -17,7 +18,6 @@ const db = require('../db/database');const media = require('../media-client');const paths = require('../paths');--const EMOTE_DIR = paths.dir('EMOTES_PATH', 'emotes');const SOUND_DIR = paths.dir('SOUNDS_PATH', 'sounds');const MIME_BY_EXT = {'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',@@@ -27,7 +27,6 @@ const MIME_BY_EXT = {function _ensureColumns() {for (const [table, col, type] of [-- ['emotes', 'media_url', 'TEXT'], ['emotes', 'media_asset_id', 'INTEGER'],['channel_sounds', 'media_url', 'TEXT'], ['channel_sounds', 'media_asset_id', 'INTEGER'],]) {try { db.getDb().exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`); } catch { /* exists */ }@@@ -66,21 +65,6 @@ async function syncAll() {};let synced = 0, failed = 0;-- for (const e of db.all('SELECT * FROM emotes WHERE media_asset_id IS NULL')) {-- const f = _localFile(EMOTE_DIR, e.url);-- if (!f) continue;-- try {-- const asset = await _upload({-- kind: 'emote', name: e.code, filePath: f,-- user_id: e.user_id, username: uname(e.user_id),-- channel_username: uname(e.channel_owner_id || e.user_id),-- meta: { animated: !!e.animated, is_global: !!e.is_global },-- });-- // emotes is a staged chat table: written where its authority is (chat/chat-tables.js).-- if (asset) { await require('../chat/chat-tables').write('setEmoteMedia', e.id, asset.url, asset.id); synced++; }-- } catch (err) { failed++; if (failed <= 3) console.warn('[AssetSync] emote', e.code, err.message); }-- }--for (const s of db.all('SELECT * FROM channel_sounds WHERE media_asset_id IS NULL')) {const f = _localFile(SOUND_DIR, s.url);if (!f) continue;@@@ -97,7 +97,7 @@ async function canActorModerateClip(actor, clip) {if (actor.id === clip.user_id) return true;const ownerId = await clipChannelOwnerId(clip);if (ownerId && actor.id === ownerId) return true;-- if (ownerId) { const ch = db.getChannelByUserId(ownerId); if (ch && db.isChannelModerator(actor.id, ch.id)) return true; }++ if (ownerId) { const ch = db.getChannelByUserId(ownerId); if (ch && await permissions.isChannelMod(actor, ch.id)) return true; }const clipOwner = clip.user_id ? db.getUserById(clip.user_id) : null;const streamOwner = ownerId ? db.getUserById(ownerId) : null;return permissions.canModerateContentOwner(actor, clipOwner) &&@@@ -111,7 +111,7 @@ async function canActorDeleteClip(actor, clip) {if (!ownerId && actor.id === clip.user_id) return true;if (ownerId) {const ch = db.getChannelByUserId(ownerId);-- if (ch && db.isChannelModerator(actor.id, ch.id)) return true;++ if (ch && await permissions.isChannelMod(actor, ch.id)) return true;if (actor.id === clip.user_id && ch && ch.clips_allow_creator_delete) return true;}const clipOwner = clip.user_id ? db.getUserById(clip.user_id) : null;@@@ -315,7 +315,11 @@ router.put('/:id/visibility', requireAuth, async (req, res) => {// Not the clipper: a viewer's clip inherits the channel's default visibility, and whoever made it// must not be able to override the streamer's choice. Channel owner, channel mods, staff.const ownerId = await clipChannelOwnerId(clip);-- const canByChannel = ownerId && (req.user.id === ownerId || (() => { const ch = db.getChannelByUserId(ownerId); return !!(ch && db.isChannelModerator(req.user.id, ch.id)); })());++ let canByChannel = false;++ if (ownerId) {++ if (req.user.id === ownerId) canByChannel = true;++ else { const ch = db.getChannelByUserId(ownerId); if (ch) canByChannel = await permissions.isChannelMod(req.user, ch.id); }++ }const canByStaff = !canByChannel && req.user.id !== clip.user_id && await canActorModerateClip(req.user, clip);const ownClipNoChannel = !ownerId && req.user.id === clip.user_id;if (!(canByChannel || canByStaff || ownClipNoChannel)) return refuse(req, res, clip, 'Only the streamer can change clip visibility');@@@ -18,7 +18,7 @@ const permissions = require('../auth/permissions');** Returns the streamer's user id, or throws an Error whose message is viewer-safe.*/--function resolveQueueTarget(req) {++async function resolveQueueTarget(req) {const explicit = cleanInt(req.body?.streamer ?? req.query?.streamer, null);const uname = req.body?.channelUsername || req.query?.channelUsername;@@@ -28,7 +28,7 @@ function resolveQueueTarget(req) {if (streamerId === req.user.id) return streamerId;const channel = db.getChannelByUserId(streamerId);-- if (!channel || !permissions.canModerateChannel(req.user, channel.id)) {++ if (!channel || !(await permissions.canModerateChannel(req.user, channel.id))) {const err = new Error('You do not have permission to manage this queue');err.status = 403;throw err;@@@ -37,11 +37,11 @@ function resolveQueueTarget(req) {}/** Owner-or-mod check for a channel identified by its streamer's user id. */--function canManageChannel(user, streamerId) {++async function canManageChannel(user, streamerId) {if (!user || !streamerId) return false;if (user.id === streamerId) return true;const channel = db.getChannelByUserId(streamerId);-- return !!channel && permissions.canModerateChannel(user, channel.id);++ return !!channel && await permissions.canModerateChannel(user, channel.id);}function cleanInt(value, fallback) {@@@ -49,7 +49,7 @@ function cleanInt(value, fallback) {return Number.isFinite(num) ? num : fallback;}--router.get('/channel/:username', optionalAuth, (req, res) => {++router.get('/channel/:username', optionalAuth, async (req, res) => {try {const user = db.getUserByUsername(req.params.username);if (!user) return res.status(404).json({ error: 'Channel not found' });@@@ -75,7 +75,7 @@ router.get('/channel/:username', optionalAuth, (req, res) => {is_owner: !!req.user && req.user.id === user.id,// Drives whether the channel's media tab renders queue controls. Mods get the// same controls as the streamer; the server re-checks on every action.-- can_manage: canManageChannel(req.user, user.id),++ can_manage: await canManageChannel(req.user, user.id),pricing: (() => {const st = mediaQueue.getSettings(user.id);const currency = mediaQueue.currencyOf(st);@@@ -255,18 +255,18 @@ router.post('/request', requireAuth, async (req, res) => {}});--router.post('/start', requireAuth, (req, res) => {++router.post('/start', requireAuth, async (req, res) => {try {-- const request = mediaQueue.startNext(resolveQueueTarget(req));++ const request = mediaQueue.startNext(await resolveQueueTarget(req));res.json({ request });} catch (err) {res.status(err.status || 400).json({ error: err.message || 'Failed to start next request' });}});--router.post('/advance', requireAuth, (req, res) => {++router.post('/advance', requireAuth, async (req, res) => {try {-- const target = resolveQueueTarget(req);++ const target = await resolveQueueTarget(req);const ended = mediaQueue.finishCurrent(target, req.body.status === 'skipped' ? 'skipped' : 'played');const next = mediaQueue.startNext(target);res.json({ ended, next });@@@ -275,9 +275,9 @@ router.post('/advance', requireAuth, (req, res) => {}});--router.post('/queue/:id/play', requireAuth, (req, res) => {++router.post('/queue/:id/play', requireAuth, async (req, res) => {try {-- const target = resolveQueueTarget(req);++ const target = await resolveQueueTarget(req);if (db.getActiveMediaRequestByStreamer(target)) {return res.status(400).json({ error: 'Finish or skip the current item first' });}@@@ -292,28 +292,28 @@ router.post('/queue/:id/play', requireAuth, (req, res) => {}});--router.post('/queue/:id/skip', requireAuth, (req, res) => {++router.post('/queue/:id/skip', requireAuth, async (req, res) => {try {-- const request = mediaQueue.skip(resolveQueueTarget(req), cleanInt(req.params.id, 0));++ const request = mediaQueue.skip(await resolveQueueTarget(req), cleanInt(req.params.id, 0));res.json({ request });} catch (err) {res.status(err.status || 400).json({ error: err.message || 'Failed to skip request' });}});--router.post('/queue/:id/move', requireAuth, (req, res) => {++router.post('/queue/:id/move', requireAuth, async (req, res) => {try {const direction = req.body.direction === 'down' ? 'down' : 'up';-- const request = mediaQueue.move(resolveQueueTarget(req), cleanInt(req.params.id, 0), direction);++ const request = mediaQueue.move(await resolveQueueTarget(req), cleanInt(req.params.id, 0), direction);res.json({ request });} catch (err) {res.status(err.status || 400).json({ error: err.message || 'Failed to reorder request' });}});--router.delete('/queue/:id', requireAuth, (req, res) => {++router.delete('/queue/:id', requireAuth, async (req, res) => {try {-- const request = mediaQueue.skip(resolveQueueTarget(req), cleanInt(req.params.id, 0));++ const request = mediaQueue.skip(await resolveQueueTarget(req), cleanInt(req.params.id, 0));res.json({ request });} catch (err) {res.status(err.status || 400).json({ error: err.message || 'Failed to remove request' });@@@ -346,7 +346,7 @@ router.get('/queue/:id/stream-url', optionalAuth, async (req, res) => {download_status: 'failed',// Raw yt-dlp output (hosts, ports, HTTP errors) is for the channel's managers; anyone// else gets a generic reason instead of an oracle for probing what the server can reach.-- last_error: canManageChannel(req.user, request.streamer_id) ? request.last_error : (request.last_error ? 'That link could not be played' : null),++ last_error: await canManageChannel(req.user, request.streamer_id) ? request.last_error : (request.last_error ? 'That link could not be played' : null),});}@@@ -367,12 +367,12 @@ router.get('/queue/:id/stream-url', optionalAuth, async (req, res) => {// ── Playback position save (called periodically by player) ──// The player page sends the session cookie (sendBeacon cannot set headers), so the same// manager check as /fail applies: nobody else can move a channel's resume point.--router.post('/queue/:id/position', requireAuth, (req, res) => {++router.post('/queue/:id/position', requireAuth, async (req, res) => {try {const requestId = cleanInt(req.params.id, 0);const request = db.getMediaRequestById(requestId);if (!request) return res.status(404).json({ error: 'Request not found' });-- if (!canManageChannel(req.user, request.streamer_id)) return res.status(403).json({ error: 'Forbidden' });++ if (!(await canManageChannel(req.user, request.streamer_id))) return res.status(403).json({ error: 'Forbidden' });const position = Number(req.body.position);if (!Number.isFinite(position) || position < 0) {return res.status(400).json({ error: 'Invalid position' });@@@ -389,7 +389,7 @@ router.post('/queue/:id/refund', requireAuth, async (req, res) => {try {const request = db.getMediaRequestById(cleanInt(req.params.id, 0));if (!request) return res.status(404).json({ error: 'Request not found' });-- if (!canManageChannel(req.user, request.streamer_id)) {++ if (!(await canManageChannel(req.user, request.streamer_id))) {return res.status(403).json({ error: 'Only the channel owner or its mods can issue refunds' });}const amount = await mediaQueue.refund(request.id);@@@ -400,11 +400,11 @@ router.post('/queue/:id/refund', requireAuth, async (req, res) => {});// ── Report playback failure (auto-refunds) ──────────────────--router.post('/queue/:id/fail', requireAuth, (req, res) => {++router.post('/queue/:id/fail', requireAuth, async (req, res) => {try {const request = db.getMediaRequestById(cleanInt(req.params.id, 0));if (!request) return res.status(404).json({ error: 'Request not found' });-- if (!canManageChannel(req.user, request.streamer_id)) {++ if (!(await canManageChannel(req.user, request.streamer_id))) {return res.status(403).json({ error: 'Only the channel owner or its mods can report failures' });}const failed = mediaQueue.failRequest(request.id, req.body.error || 'Playback failed');@@@ -1,65 +1,25 @@/*** alerts.js — streamer alert sounds for donations / donation-goal-reached events.*-- * The streamer uploads a sound (via chat → Settings); we read it server-side and-- * broadcast it as base64 `soundboard-audio` so the existing client audio queue-- * (playTTSAudio) plays it for every viewer, gated on their Chat Sounds toggle.-- * A goal-reached event uses the goal-override sound if set, else the donation sound.++ * The sound mapping lives on channel_moderation_settings, which OpenVibe.Chat owns and reads locally++ * (roadmap T3), so Live no longer reads the file or broadcasts base64 itself. It asks Chat to play the++ * alert through the chat bridge — op `playAlertSound [streamerId, streamId, kind]` — and Chat resolves++ * the sound from its own settings row, reads the shared sounds file and broadcasts the++ * `soundboard-audio` message to the channel room. In production CHAT_AUTHORITY=chat, so `chatServer`++ * is the chat-remote proxy (server/chat/chat-remote.js) and the call is queued to Chat like every++ * other push. A goal-reached event is `kind: 'goal'` (Chat falls back to the donation sound).*/'use strict';--const fs = require('fs');--const path = require('path');--const db = require('../db/database');----const MAX_SOUND_BYTES = 3 * 1024 * 1024; // 3MB — alert clips are short----function _mimeForExt(ext) {-- switch ((ext || '').toLowerCase()) {-- case '.wav': return 'audio/wav';-- case '.ogg': case '.oga': return 'audio/ogg';-- case '.opus': return 'audio/opus';-- case '.webm': return 'audio/webm';-- case '.m4a': case '.mp4': return 'audio/mp4';-- case '.aac': return 'audio/aac';-- case '.flac': return 'audio/flac';-- default: return 'audio/mpeg';-- }--}----function _readSound(diskPath, mimeHint) {-- try {-- if (!diskPath || !fs.existsSync(diskPath)) return null;-- const buf = fs.readFileSync(diskPath);-- if (!buf || !buf.length || buf.length > MAX_SOUND_BYTES) return null;-- return { audio: buf.toString('base64'), mimeType: mimeHint || _mimeForExt(path.extname(diskPath)) };-- } catch { return null; }--}/**-- * Broadcast a streamer's alert sound to their viewers (channel-wide, so it reaches-- * every slot + offline chat). kind: 'donation' | 'goal'. No-op if none configured.++ * Ask Chat to play a streamer's alert sound to their viewers (channel-wide, so it reaches every slot++ * plus the offline room). kind: 'donation' | 'goal'. No-op if Chat is not the authority or nothing is++ * configured. Chat answers `played:false` (never an error) when the channel has no sound.*/function playAlertSound(chatServer, streamerId, streamId, kind) {try {-- if (!chatServer || !chatServer.broadcastToChannelRoom) return;-- const s = db.getChannelAlertSoundsByUser(streamerId) || {};-- let disk = null, mime = null;-- if (kind === 'goal') {-- // Goal-reached uses the override sound if set, otherwise the donation sound.-- disk = s.goal_sound_url || s.donation_sound_url;-- mime = s.goal_sound_url ? s.goal_sound_mime : s.donation_sound_mime;-- } else {-- disk = s.donation_sound_url;-- mime = s.donation_sound_mime;-- }-- const snd = _readSound(disk, mime);-- if (!snd) return;-- chatServer.broadcastToChannelRoom(streamerId, streamId || null, {-- type: 'soundboard-audio',-- audio: snd.audio,-- mimeType: snd.mimeType,-- source: kind === 'goal' ? 'goal-alert' : 'donation-alert',-- });++ if (!chatServer || typeof chatServer.playAlertSound !== 'function') return;++ chatServer.playAlertSound(streamerId, streamId, kind === 'goal' ? 'goal' : 'donation');} catch { /* non-critical */ }}@@@ -345,7 +345,7 @@ class CallServer {if (permissions.can(user, 'staff.moderation.calls')) return true;const ch = this.channels.get(channelId);if (ch?.createdBy === user.id) return true;-- if (ch?.streamId) return permissions.canModerateCall(user, ch.streamId);++ if (ch?.streamId) return permissions.canModerateCallSync(user, ch.streamId);return false;}@@@ -35,6 +35,8 @@ const i18n = require('../i18n/translate');const robotStreamerService = require('../integrations/robotstreamer-service');const chatRelayService = require('../integrations/chat-relay-service');const chatServer = require('../chat/chat-server');++// The six chat tables are OpenVibe.Chat's (roadmap T3); the few reads here go through its client.++const moderation = require('../chat/moderation-client');// Stream voice channels: Live's call server, or OpenVibe.Chat's with CALLS_AUTHORITY=chat.const callsAuthority = require('./calls-authority');const { sanitizeOfflineHtml, sanitizeOfflineCss } = require('./offline-html-sanitize');@@@ -475,13 +477,18 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {// show the pencil edit button to the right people.let modsCanEditAbout = false;let _modSettings = {};-- try { _modSettings = db.getChannelModerationSettings(channel.id) || {}; modsCanEditAbout = !!_modSettings.mods_can_edit_about; } catch { /* default off */ }++ let viewerIsChannelMod = false;++ try {++ const m = await moderation.getChannelModeration(channel.id);++ _modSettings = m.settings || {};++ modsCanEditAbout = !!_modSettings.mods_can_edit_about;++ viewerIsChannelMod = !!(req.user && m.moderator_ids.includes(Number(req.user.id)));++ } catch { /* default off */ }// Public chat limits so the client can cap the input + truncate TTS to the streamer's max.publicChannel.chat_limits = {max_message_length: Math.max(1, Number(_modSettings.max_message_length) || 500),tts_max_length: Math.max(10, Number(_modSettings.tts_max_length) || 200),};-- const viewerIsChannelMod = !!(req.user && db.isChannelModerator(req.user.id, channel.id));publicChannel.mods_can_edit_about = modsCanEditAbout;publicChannel.viewer_can_edit_about = !!(isOwner || (modsCanEditAbout && viewerIsChannelMod));// Social links (server/social/links.js): saved links plus connected restream platforms. Editors also get@@@ -818,15 +825,19 @@ router.get('/channel/:username/bio-en', async (req, res) => {// Editable by the channel owner, and by channel moderators when the streamer has// enabled `mods_can_edit_about`. Targets the channel by username so a mod writes// to the STREAMER's channel, not their own.--router.put('/channel/:username/about', requireAuth, (req, res) => {++router.put('/channel/:username/about', requireAuth, async (req, res) => {try {const channel = db.getChannelByUsername(req.params.username);if (!channel) return res.status(404).json({ error: 'Channel not found' });const isOwner = req.user.id === channel.user_id;let modsCanEditAbout = false;-- try { modsCanEditAbout = !!(db.getChannelModerationSettings(channel.id) || {}).mods_can_edit_about; } catch { /* off */ }-- const isChannelMod = db.isChannelModerator(req.user.id, channel.id);++ let isChannelMod = false;++ try {++ const m = await moderation.getChannelModeration(channel.id);++ modsCanEditAbout = !!m.settings.mods_can_edit_about;++ isChannelMod = m.moderator_ids.includes(Number(req.user.id));++ } catch { /* off */ }if (!isOwner && !(modsCanEditAbout && isChannelMod)) {return res.status(403).json({ error: 'You do not have permission to edit this About section' });}@@@ -1611,7 +1622,7 @@ router.get('/setup-progress', requireAuth, async (req, res) => {const rs = slots.some(sl => safe(() => { const r = db.getRobotStreamerIntegrationBySlot(uid, sl.id); return !!(r && (r.robot_id || r.stream_name)); }, false));const user = safe(() => db.getUserById(uid), {}) || {};const channel = safe(() => db.getChannelByUserId(uid), {}) || {};-- const emotes = safe(() => db.get('SELECT COUNT(*) AS n FROM emotes WHERE user_id = ?', [uid]).n, 0);++ const emotes = await moderation.getEmoteCount(uid).catch(() => 0);const sounds = safe(() => db.get('SELECT COUNT(*) AS n FROM channel_sounds WHERE channel_owner_id = ?', [uid]).n, 0);const goals = safe(() => db.get('SELECT COUNT(*) AS n FROM donation_goals WHERE user_id = ? AND is_active = 1', [uid]).n, 0);const powerchat = safe(() => !!db.get('SELECT 1 FROM powerchat_connections WHERE user_id = ? LIMIT 1', [uid]), false);@@@ -1622,13 +1633,16 @@ router.get('/setup-progress', requireAuth, async (req, res) => {// Second wave of tasks. Each one is a real feature with a real table behind it, so nothing on// the list can be permanently unreachable — a task that can never be ticked would park every// streamer below 100% forever.-- const mods = safe(() => channel.id ? db.get('SELECT COUNT(*) AS n FROM channel_moderators WHERE channel_id = ?', [channel.id]).n : 0, 0);++ // Chat owns channel_moderators and channel_moderation_settings (roadmap T3).++ const channelMod = channel.id ? await moderation.getChannelModeration(channel.id).catch(() => null) : null;++ const mods = channelMod ? channelMod.moderator_ids.length : 0;const controls = safe(() => db.get('SELECT COUNT(*) AS n FROM control_configs WHERE user_id = ?', [uid]).n, 0);const aibot = safe(() => db.get('SELECT COUNT(*) AS n FROM channel_ai_bots WHERE channel_user_id = ?', [uid]).n, 0);// Pastes live in OpenVibe.Community: the person's own count, unlisted and private included.const pastes = await require('../media-proxy/lookups').countUserPastes(user && user.id ? user : null, { hidden: 'owner' });const requests = safe(() => !!db.get('SELECT 1 FROM media_request_settings WHERE user_id = ? LIMIT 1', [uid]), false);-- const modRules = safe(() => channel.id ? !!db.get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ? LIMIT 1', [channel.id]) : false, false);++ // A saved settings row carries updated_at; Chat's defaults (no row) do not.++ const modRules = !!(channelMod && channelMod.settings.updated_at);const tasks = [{ id: 'slot', group: 'Stream', title: 'Create your stream slot', why: 'Your show gets its own key, settings, VODs and restreams.', done: slots.length > 0, count: slots.length },{ id: 'method', group: 'Stream', title: 'Choose how you stream', why: 'Browser, OBS over RTMP, or OBS over WHIP.', done: methodSet },nnew file mode 100644@@@ -0,0 +1,31 @@++'use strict';++++// server/monetization/alerts.js after roadmap T3: Live no longer reads the alert sound from disk or++// broadcasts base64 — the sound mapping lives on channel_moderation_settings, which OpenVibe.Chat++// owns — it asks Chat through the bridge op `playAlertSound [streamerId, streamId, kind]`. This++// checks the dispatcher; the op itself (resolving the sound, reading the shared file, broadcasting++// `soundboard-audio`) lives in Chat and is tested there.++//++// node test/alerts-bridge.test.js++++const assert = require('assert');++const alerts = require('../server/monetization/alerts');++++const calls = [];++const fake = { playAlertSound: (...args) => calls.push(args) };++++alerts.playAlertSound(fake, 7, 3, 'donation');++alerts.playAlertSound(fake, 7, 3, 'goal');++alerts.playAlertSound(fake, 7, 3, 'anything-else');++assert.deepStrictEqual(calls, [[7, 3, 'donation'], [7, 3, 'goal'], [7, 3, 'donation']], 'unknown kinds fall back to the donation sound');++++// Offline room: a null stream id is passed straight through (Chat reaches the channel room).++alerts.playAlertSound(fake, 7, null, 'goal');++assert.deepStrictEqual(calls[3], [7, null, 'goal']);++++// Live runs chat itself (rollback / dev): no Chat to ask — silent no-op, never throws.++alerts.playAlertSound({}, 7, 3, 'donation');++alerts.playAlertSound(null, 7, 3, 'donation');++assert.strictEqual(calls.length, 4, 'a chat server without the op is not called');++++console.log('alerts bridge: all checks passed');@@@ -18,7 +18,7 @@ const BARE = /(?:\}|openvibe\.live)\/\$\{(?:encodeURIComponent\(|esc\()?[\w.?]*(const ALLOWED = [{ file: 'server/integrations/powerchat-checkout.js', contains: 'pcUsername', why: "PowerChat's own tip page (powerchat.live/<user>/tip), not an OpenVibe channel" },{ file: 'server/integrations/powerchat-routes.js', contains: 'powerchat_username', why: "PowerChat's own tip page" },-- { file: 'public/js/app.js', contains: '/chat-ai/relay/', why: 'API path /chat-ai/relay/<platform>/<username>' },++ { file: 'public/js/app.js', contains: '/chat/ai/relay/', why: 'API path /chat/ai/relay/<platform>/<username> (OpenVibe.Chat, roadmap T3)' },{ file: 'public/js/chat.js', contains: '/chat/relay-user/', why: 'API path /chat/relay-user/<platform>/<username>' },];@@@ -19,6 +19,13 @@ const http = require('http');process.env.DB_PATH = path.join(os.tmpdir(), `ov-chanmod-${process.pid}.db`);process.env.NODE_ENV = 'test';++// Live reads channel_moderators through OpenVibe.Chat (roadmap T3), so the routes need a stub Chat++// (its internal read API) and a stub Network token endpoint. Fixed ports, env set before any require.++const CHAT_PORT = 45000 + (process.pid % 400);++const NET_PORT = CHAT_PORT + 1;++process.env.OV_OAUTH_CLIENT_SECRET = 'chanmod-secret';++process.env.OV_CHAT_INTERNAL_URL = `http://127.0.0.1:${CHAT_PORT}`;++process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${NET_PORT}`;const quiet = console.log;console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };@@@ -26,6 +33,29 @@ const db = require('../server/db/database');db.initDb();const raw = db.getDb();++const netStub = http.createServer((req, res) => {++ let body = ''; req.on('data', (c) => { body += c; });++ req.on('end', () => {++ res.setHeader('Content-Type', 'application/json');++ if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: 'stub', expires_in: 300 }));++ res.statusCode = 404; res.end('{}');++ });++});++const chatStub = http.createServer((req, res) => {++ res.setHeader('Content-Type', 'application/json');++ const mm = String(req.url).match(/^\/internal\/moderation\/channels\/(\d+)$/);++ if (mm) {++ const cid = Number(mm[1]);++ return res.end(JSON.stringify({++ ok: true,++ settings: db.getChannelModerationSettings(cid) || {},++ moderator_ids: raw.prepare('SELECT user_id FROM channel_moderators WHERE channel_id = ? ORDER BY id').all(cid).map((r) => r.user_id),++ }));++ }++ res.statusCode = 404; res.end('{}');++});++netStub.listen(NET_PORT); chatStub.listen(CHAT_PORT);++const auth = require('../server/auth/auth');auth.requireAuth = (req, res, next) => {const u = db.getUserById(Number(req.headers['x-test-user'] || 0));@@@ -78,7 +108,9 @@ async function check(name, fn) {}(async () => {-- await new Promise((r) => server.once('listening', r));++ const listening = (s) => (s.listening ? Promise.resolve() : new Promise((r) => s.once('listening', r)));++ await listening(server);++ await Promise.all([listening(netStub), listening(chatStub)]);const settings = () => db.getChannelModerationSettings(channel.id);await check('the dashboard saves slow mode in slow_mode_seconds (the old slowmode_seconds too)', async () => {@@@ -68,6 +68,24 @@ const chat = http.createServer((req, res) => {if (req.url === '/internal/live/presence') {return res.end(JSON.stringify({ total: 7, streams: { 1: 3 }, slow_mode: { 1: 5000 }, users: [{ user_id: 3, ip: '198.51.100.3', stream_id: 1 }], anons: [{ anon_id: 'anon9', ip: '203.0.113.9', stream_id: 1 }] }));}++ // Chat's internal read API (roadmap T3): Live reads the six staged tables through it now.++ const mm = String(req.url).match(/^\/internal\/moderation\/channels\/(\d+)(\/emote-count)?$/);++ if (mm) {++ const d2 = require('../server/db/database');++ const channelId = Number(mm[1]);++ if (mm[2]) { const ch = d2.getChannelById(channelId); return res.end(JSON.stringify({ ok: true, count: ch ? d2.countChannelEmotes(ch.user_id) : 0 })); }++ return res.end(JSON.stringify({++ ok: true,++ settings: d2.getChannelModerationSettings(channelId) || {},++ moderator_ids: d2.all('SELECT user_id FROM channel_moderators WHERE channel_id = ? ORDER BY id', [channelId]).map((r) => r.user_id),++ }));++ }++ const um = String(req.url).match(/^\/internal\/moderation\/users\/(\d+)\/channels$/);++ if (um) {++ const d2 = require('../server/db/database');++ const rows = d2.getChannelsByModerator(Number(um[1])) || [];++ return res.end(JSON.stringify({ ok: true, channels: rows.map((c) => ({ channel_id: c.id, title: c.title, owner_user_id: c.user_id })) }));++ }res.statusCode = 404; res.end('{}');});});@@@ -145,7 +163,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));assert.ok(auth.expires_at);assert.deepStrictEqual((await call('POST', '/internal/chat-context/auth', { token: READ, body: { token: 'garbage' } })).body, { user: null, reason: 'invalid' });-- // 4. Streams, channel policy (settings + moderators + language), follows, bans with version.++ // 4. Streams, channel policy (language; settings and moderator ids are Chat's now), follows, bans with version.const s = (await call('GET', `/internal/chat-context/streams/${streamId}`, { token: READ })).body;assert.strictEqual(s.stream.user_id, streamer);assert.strictEqual(s.owner.username, 'streamer');@@@ -154,8 +172,11 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));const active = (await call('GET', '/internal/chat-context/streams/active', { token: READ })).body.rows;assert.ok(active.some((r) => r.id === streamId && r.is_live === 1));const policy = (await call('GET', `/internal/chat-context/channels/${channel.id}/policy`, { token: READ })).body;-- assert.deepStrictEqual(policy.moderator_ids, [mod]);-- assert.strictEqual(policy.settings.allow_anonymous, 1, 'defaults when the channel has no row');++ // OpenVibe.Chat owns channel_moderation_settings/channel_moderators (roadmap T3) and reads them++ // locally; Live answers only the channel row and the language it owns.++ assert.strictEqual(policy.channel.id, channel.id);++ assert.strictEqual(policy.language, 'en');++ assert.ok(!('settings' in policy) && !('moderator_ids' in policy), 'the policy half is Chat-local now');assert.deepStrictEqual((await call('GET', `/internal/chat-context/users/${viewer}/follows`, { token: READ })).body.streamer_ids, [streamer]);const b1 = (await call('GET', '/internal/chat-context/bans', { token: READ })).body;assert.deepStrictEqual(b1.bans, []);@@@ -198,18 +219,11 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));assert.strictEqual((await put(owner, { tts_google_api_key: 'owner-can' })).body.updated, 1);assert.strictEqual(db.getSetting('tts_google_api_key'), 'owner-can');-- // 8. Slow mode persistence and alert sounds: only for people who may.-- 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');++ // 8. Slow mode and sub-only persistence moved to Chat with channel_moderation_settings (roadmap++ // T3); Live now only writes channels.emote_sources for Chat's PUT /api/emotes/sources.++ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-emote-sources', { token: WRITE, body: { user_id: 999999, sources: { ffz: true } } })).status, 404);++ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-emote-sources', { token: WRITE, body: { user_id: streamer, sources: { ffz: true, bttv: false } } })).status, 200);++ assert.strictEqual(db.getChannelByUserId(streamer).emote_sources, JSON.stringify({ ffz: true, bttv: false }));// 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.@@@ -226,10 +240,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));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');-- assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: snd } })).status, 200);++ // Alert sounds moved to Chat with channel_moderation_settings (roadmap T3): Chat resolves the++ // sound from its own row and plays it through the bridge op, so Live's effect is gone.// 8b. A ring from Chat's call server (CALLS_AUTHORITY=chat): Live pushes the VC_CALL_INVITE it used to.const notify = require('../server/utils/notify');nnew file mode 100644@@@ -0,0 +1,141 @@++'use strict';++++// server/chat/moderation-client.js (roadmap T3): Live reads the six chat tables OpenVibe.Chat owns++// through ONE internal read API (Contracts 0.80.0 chat.moderation.read, audience openvibe.chat, Live's++// service token). This file checks the client the permissions hot path now depends on:++// - it calls Chat with a Bearer service token and reads { ok:true, … } answers;++// - every answer is cached 30 s (one fetch for repeated reads), invalidate() drops it;++// - a failure (or no secret) answers the safe default — "not a moderator", Live's moderation++// defaults, zero emotes — and never throws;++// - with openvibe-contracts >= 0.80.0 installed the stub's answers validate against the three++// result contracts (chat.channel-moderation-result@1, chat.moderated-channels-result@1,++// chat.emote-count-result@1). The repo pins 0.71.0 today, so that block is conditional.++//++// Against stub Network (token endpoint) and stub Chat (the three read routes).++++const assert = require('assert');++const http = require('http');++++process.env.OV_OAUTH_CLIENT_ID = 'live';++process.env.OV_OAUTH_CLIENT_SECRET = 'moderation-secret';++process.env.NODE_ENV = 'test';++const quiet = console.log;++console.log = () => {};++console.warn = () => {};++++let failing = false;++const calls = [];++++const network = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ res.setHeader('Content-Type', 'application/json');++ if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: 'stub-chat-token', expires_in: 300 }));++ res.statusCode = 404; res.end('{}');++ });++});++++const MOD_SETTINGS = { channel_id: 5, slow_mode_seconds: 7, custom_emotes_enabled: 1 };++const MOD = { ok: true, settings: MOD_SETTINGS, moderator_ids: [11, 22] };++const CHANS = { ok: true, channels: [{ channel_id: 5, title: 'Streamer TV', owner_user_id: 5, owner_username: 'streamer' }] };++const COUNT = { ok: true, count: 3 };++const chat = http.createServer((req, res) => {++ calls.push({ url: req.url, auth: req.headers.authorization });++ res.setHeader('Content-Type', 'application/json');++ if (failing) { res.statusCode = 500; return res.end(JSON.stringify({ ok: false, error: 'boom' })); }++ if (/^\/internal\/moderation\/channels\/\d+\/emote-count$/.test(req.url)) return res.end(JSON.stringify(COUNT));++ if (/^\/internal\/moderation\/channels\/\d+$/.test(req.url)) return res.end(JSON.stringify(MOD));++ if (/^\/internal\/moderation\/users\/\d+\/channels$/.test(req.url)) return res.end(JSON.stringify(CHANS));++ res.statusCode = 404; res.end('{}');++});++++const listen = (s) => new Promise((r) => s.listen(0, '127.0.0.1', () => r(s.address().port)));++++(async () => {++ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${await listen(network)}`;++ process.env.OV_CHAT_INTERNAL_URL = `http://127.0.0.1:${await listen(chat)}`;++ const client = require('../server/chat/moderation-client');++++ let failures = 0;++ const check = async (name, fn) => {++ try { await fn(); quiet(` ✓ ${name}`); } catch (err) { failures++; quiet(` ✗ ${name}\n ${err.stack || err.message}`); }++ };++++ await check('enabled, audience and cache TTL', () => {++ assert.strictEqual(client.enabled(), true);++ assert.strictEqual(client.AUDIENCE, 'openvibe.chat');++ assert.strictEqual(client.CACHE_TTL_MS, 30000);++ });++++ await check('a channel read: Bearer service token, { settings, moderator_ids }', async () => {++ const m = await client.getChannelModeration(5);++ assert.deepStrictEqual(m.moderator_ids, [11, 22]);++ assert.strictEqual(m.settings.slow_mode_seconds, 7);++ assert.strictEqual(m.settings.channel_id, 5);++ assert.strictEqual(calls.length, 1);++ assert.ok(String(calls[0].auth || '').startsWith('Bearer '), 'Chat is called with a service token');++ assert.strictEqual(calls[0].url, '/internal/moderation/channels/5');++ });++++ await check('the answer is cached 30 s; invalidate() drops it', async () => {++ await client.getChannelModeration(5);++ assert.strictEqual(calls.length, 1, 'the second read is served from the cache');++ client.invalidate(5);++ await client.getChannelModeration(5);++ assert.strictEqual(calls.length, 2, 'after invalidate() the next read asks Chat again');++ });++++ await check('moderated channels and emote count', async () => {++ const rows = await client.getChannelsByModerator(9);++ assert.deepStrictEqual(rows, [{ id: 5, channel_id: 5, title: 'Streamer TV', user_id: 5, owner_username: 'streamer' }]);++ assert.ok(String(calls[calls.length - 1].auth || '').startsWith('Bearer '));++ assert.strictEqual(await client.getEmoteCount(5), 3);++ });++++ await check('a failure answers the safe default, never throws', async () => {++ failing = true;++ const m = await client.getChannelModeration(77);++ assert.deepStrictEqual(m.moderator_ids, [], 'not a moderator');++ assert.strictEqual(m.settings.slow_mode_seconds, 0, 'Live\'s default settings');++ assert.strictEqual(m.settings.channel_id, 77);++ assert.deepStrictEqual(await client.getChannelsByModerator(77), []);++ assert.strictEqual(await client.getEmoteCount(77), 0);++ failing = false;++ });++++ await check('without a secret the client is off and answers defaults (no request)', async () => {++ client._reset();++ const before = calls.length;++ process.env.OV_OAUTH_CLIENT_SECRET = '';++ try {++ const m = await client.getChannelModeration(88);++ assert.deepStrictEqual(m.moderator_ids, []);++ assert.strictEqual(await client.getEmoteCount(88), 0);++ assert.strictEqual(calls.length, before, 'nothing is sent when Live is not configured');++ } finally {++ process.env.OV_OAUTH_CLIENT_SECRET = 'moderation-secret';++ client._reset();++ }++ });++++ // The three result contracts validate the stub's answers when the package knows them. Live pins++ // openvibe-contracts 0.71.0 (these arrived in 0.80.0), so today this block is skipped; a release++ // that bumps the pin validates here.++ await check('the three result contracts validate the answers (validator present in this pin?)', async () => {++ const contracts = require('openvibe-contracts');++ const known = (name) => { try { return !!contracts.schema(name); } catch { return false; } };++ // The client answers { settings, moderator_ids } (Chat's `ok` is consumed): rebuild the contract++ // payload from the client's own output — Live's defaults merged with Chat's row.++ const m = await client.getChannelModeration(5);++ if (known('chat.channel-moderation-result@1')) contracts.assertValid('chat.channel-moderation-result@1', { ok: true, settings: m.settings, moderator_ids: m.moderator_ids });++ if (known('chat.moderated-channels-result@1')) contracts.assertValid('chat.moderated-channels-result@1', CHANS);++ if (known('chat.emote-count-result@1')) contracts.assertValid('chat.emote-count-result@1', COUNT);++ });++++ process.env.OV_OAUTH_CLIENT_SECRET = '';++ network.close(); chat.close();++ if (failures) { quiet(`chat moderation client: ${failures} failed`); process.exit(1); }++ quiet('chat moderation client: all checks passed');++ process.exit(0);++})().catch((err) => { console.error(err.stack || err.message); process.exit(1); });@@@ -1,405 +1,9 @@'use strict';----// The staged chat tables (roadmap C-04; OpenVibe.Chat docs/staged-tables-cutover.md): the authority--// per table (app_state, 'live' by default, and 'live' whatever it says without CHAT_AUTHORITY=chat);--// at 'live' Live writes locally and its captured changes reach Chat's copy (ordered, newest state,--// kept while Chat is down); the dual-read counter (matched, mismatched, inconclusive; Live's answer--// never changes); the internal status and flip routes; the handoff to 'chat' (Live's queued--// changes first, then Chat, then Live stops writing); Live's writers going to Chat when flipped —--// through a real route — with Live's copy following Chat's answer, Chat's mirror accepted only for--// a table Chat writes; a refused or unreachable Chat changes nothing; the flip back. Against stub--// Network and Chat servers (the stub keeps its copy in SQLite and hashes slices like Chat does).----const assert = require('assert');--const fs = require('fs');--const os = require('os');--const path = require('path');--const http = require('http');--const crypto = require('crypto');--const jwt = require('jsonwebtoken');--const Database = require('better-sqlite3');--const { serviceAuth } = require('openvibe-contracts');----const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chattables-'));--const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });--fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);--process.env.DB_PATH = path.join(tmp, 'live.db');--process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');--process.env.OV_NETWORK_URL = 'https://openvibe.network';--process.env.OV_OAUTH_CLIENT_ID = 'live';--process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';--process.env.CHAT_AUTHORITY = 'chat';--const quiet = console.log;--console.log = () => {};--console.warn = () => {};----const ISS = 'https://openvibe.network';--const now = () => Math.floor(Date.now() / 1000);--let jti = 0;--const serviceToken = (cap, aud) => serviceAuth.signServiceToken({ iss: ISS, sub: 'svc:live', actor_type: 'service', aud: [aud], cap, iat: now(), exp: now() + 300, jti: `tok_test_${++jti}` }, keys.privateKey);----const network = http.createServer((req, res) => {-- let raw = '';-- req.on('data', (c) => { raw += c; });-- req.on('end', () => {-- res.setHeader('Content-Type', 'application/json');-- if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: serviceToken(['chat.live_bridge.write'], new URLSearchParams(raw).get('audience')), expires_in: 300 }));-- res.statusCode = 404; res.end('{}');-- });--});----// ── Stub OpenVibe.Chat: the bridge ops of the staged tables, on a SQLite copy ──--const TABLES = ['channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events'];--const PK = { channel_moderation_settings: 'channel_id' };--const pkOf = (t) => PK[t] || 'id';--const stub = { calls: [], down: false, refuseHandback: false, authority: Object.fromEntries(TABLES.map((t) => [t, 'live'])), copy: null };--const hash = (cols, rows) => crypto.createHash('sha256').update(JSON.stringify(rows.map((r) => cols.map((c) => (r[c] === undefined ? null : r[c]))))).digest('hex');--function stubOp(o) {-- const c = stub.copy;-- const [fn, ...a] = o.op === 'db' ? o.args : [];-- const rowOf = (t, id) => c.prepare(`SELECT * FROM ${t} WHERE ${pkOf(t)} = ?`).get(id);-- const answer = (value, t, ids = [], deleted = []) => ({ value, mirror: [...deleted.map((id) => ({ table: t, op: 'delete', pk: { [pkOf(t)]: id } })), ...ids.map((id) => ({ table: t, op: 'upsert', row: rowOf(t, id) }))] });-- const plain = (r) => ({ changes: r.changes, lastInsertRowid: Number(r.lastInsertRowid) });-- switch (o.op) {-- case 'tableAuthority': return { ...stub.authority };-- case 'setTableAuthority':-- if (o.args[1] === 'live' && stub.refuseHandback) throw new Error(`1 change(s) to ${o.args[0]} not in Live yet`);-- stub.authority[o.args[0]] = o.args[1];-- return { table: o.args[0], authority: o.args[1], mirror_pending: 0 };-- case 'stagedApply': {-- const out = { applied: 0, skipped: [] };-- for (const ch of o.args[0]) {-- if (stub.authority[ch.table] !== 'live') { out.skipped.push({ table: ch.table, reason: 'Chat writes this table (table_authority chat)' }); continue; }-- if (ch.op === 'delete') c.prepare(`DELETE FROM ${ch.table} WHERE ${pkOf(ch.table)} = ?`).run(ch.pk[pkOf(ch.table)]);-- else { const cols = Object.keys(ch.row); c.prepare(`INSERT OR REPLACE INTO ${ch.table} (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`).run(...cols.map((k) => ch.row[k])); }-- out.applied++;-- }-- return out;-- }-- case 'stagedSlice': {-- const [t, where, cols] = o.args;-- const ks = Object.keys(where);-- const use = [...cols].sort();-- const rows = c.prepare(`SELECT ${use.join(', ')} FROM ${t} WHERE ${ks.map((k) => `${k} IS ?`).join(' AND ') || '1'} ORDER BY ${pkOf(t)}`).all(...ks.map((k) => where[k]));-- return { count: rows.length, hash: hash(use, rows), columns: use, rows: rows.length <= 50 ? rows : undefined };-- }-- case 'db': {-- const table = { addChannelModerator: 'channel_moderators', removeChannelModerator: 'channel_moderators', upsertChannelModerationSettings: 'channel_moderation_settings', setChannelAlertSound: 'channel_moderation_settings', createEmote: 'emotes', updateEmote: 'emotes', deleteEmote: 'emotes', setEmoteMedia: 'emotes', upsertChatAiSummary: 'chat_ai_summaries', addChatTimelineEvents: 'chat_timeline_events' }[fn];-- if (stub.authority[table] !== 'chat') throw new Error(`${table} is written by Live (table_authority live)`);-- if (fn === 'addChannelModerator') {-- const r = c.prepare('INSERT OR IGNORE INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?)').run(...a);-- return answer(plain(r), table, [c.prepare('SELECT id FROM channel_moderators WHERE channel_id = ? AND user_id = ?').get(a[0], a[1]).id]);-- }-- if (fn === 'removeChannelModerator') {-- const gone = c.prepare('SELECT id FROM channel_moderators WHERE channel_id = ? AND user_id = ?').all(a[0], a[1]).map((r) => r.id);-- return answer(plain(c.prepare('DELETE FROM channel_moderators WHERE channel_id = ? AND user_id = ?').run(a[0], a[1])), table, [], gone);-- }-- if (fn === 'upsertChannelModerationSettings') {-- c.prepare('INSERT OR IGNORE INTO channel_moderation_settings (channel_id) VALUES (?)').run(a[0]);-- for (const [k, v] of Object.entries(a[1])) if (v !== undefined && /^[a-z_]+$/.test(k) && k !== 'slowmode_seconds') c.prepare(`UPDATE channel_moderation_settings SET ${k} = ? WHERE channel_id = ?`).run(typeof v === 'boolean' ? Number(v) : v, a[0]);-- return answer(rowOf(table, a[0]), table, [a[0]]);-- }-- if (fn === 'createEmote') {-- const e = a[0];-- const r = c.prepare('INSERT INTO emotes (user_id, code, url, animated, channel_owner_id, size) VALUES (?, ?, ?, ?, ?, ?)').run(e.user_id, e.code, e.url, e.animated ? 1 : 0, e.channel_owner_id || null, e.size || 100);-- return answer(plain(r), table, [Number(r.lastInsertRowid)]);-- }-- if (fn === 'deleteEmote') return answer(plain(c.prepare('DELETE FROM emotes WHERE id = ?').run(a[0])), table, [], [a[0]]);-- if (fn === 'upsertChatAiSummary') {-- const s = a[0];-- c.prepare(`INSERT INTO chat_ai_summaries (scope, subject_id, window, overview) VALUES (?, ?, ?, ?)-- ON CONFLICT(scope, subject_id, window) DO UPDATE SET overview = excluded.overview`).run(s.scope, s.subject_id || 0, s.window, s.overview || '');-- return answer({ changes: 1, lastInsertRowid: 0 }, table, [c.prepare('SELECT id FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND window = ?').get(s.scope, s.subject_id || 0, s.window).id]);-- }-- throw new Error(`stub has no ${fn}`);-- }-- default: throw new Error(`unknown op ${o.op}`);-- }--}--const chat = http.createServer((req, res) => {-- let raw = '';-- req.on('data', (c) => { raw += c; });-- req.on('end', () => {-- if (stub.down) { req.socket.destroy(); return; }-- res.setHeader('Content-Type', 'application/json');-- const auth = String(req.headers.authorization || '');-- const v = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: keys.publicKey, issuer: ISS, audience: 'openvibe.chat' });-- if (!v.ok || !v.claims.cap.includes('chat.live_bridge.write')) { res.statusCode = 401; return res.end('{}'); }-- if (req.url !== '/internal/live/calls') { res.statusCode = 404; return res.end('{}'); }-- const body = JSON.parse(raw);-- const results = body.ops.map((o) => {-- stub.calls.push({ op: o.op, args: o.args, key: o.key });-- try { return { seq: o.seq, ok: true, result: stubOp(o) }; } catch (err) { return { seq: o.seq, ok: false, error: err.message }; }-- });-- res.end(JSON.stringify({ ok: true, results }));-- });--});----const listen = (s) => new Promise((r) => s.listen(0, '127.0.0.1', () => r(s.address().port)));--const sleep = (ms) => new Promise((r) => setTimeout(r, ms));----(async () => {-- process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${await listen(network)}`;-- process.env.OV_CHAT_INTERNAL_URL = `http://127.0.0.1:${await listen(chat)}`;---- const db = require('../server/db/database');-- db.initDb();-- require('../server/chat/tags').ensureTagTables();-- for (const col of ['media_url TEXT', 'media_asset_id INTEGER']) { try { db.getDb().exec(`ALTER TABLE emotes ADD COLUMN ${col}`); } catch { /* present */ } }-- const d = db.getDb();-- // The stub's copy: the same tables, created from Live's own definitions (no foreign keys to follow).-- stub.copy = new Database(':memory:');-- stub.copy.pragma('foreign_keys = OFF');-- for (const t of TABLES) stub.copy.exec(d.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(t).sql);-- for (const r of d.prepare(`SELECT sql FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL AND tbl_name IN (${TABLES.map(() => '?').join(',')})`).all(...TABLES)) stub.copy.exec(r.sql);---- const mkUser = (username, role = 'user') => {-- const id = Number(db.createUser({ username, email: `${username}@example.test`, password_hash: '!x', display_name: username.toUpperCase(), stream_key: `key-${username}` }).lastInsertRowid);-- d.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);-- return id;-- };-- const streamer = mkUser('streamer', 'streamer');-- const mod = mkUser('moddy');-- const viewer = mkUser('viewer');-- d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', '601', 'usr_01J9SSSSSSSSSSSSSSSSSSSSSS')").run(streamer);-- db.createChannel({ user_id: streamer, title: 'Streamer TV' });-- const channel = db.getChannelByUserId(streamer);-- db.createChannel({ user_id: viewer, title: 'Viewer TV' });-- const channel2 = db.getChannelByUserId(viewer);-- const streamerJwt = jwt.sign({ sub: '601', username: 'streamer', subject_id: 'usr_01J9SSSSSSSSSSSSSSSSSSSSSS' }, keys.privateKey, { algorithm: 'RS256', issuer: ISS, expiresIn: 600 });---- const express = require('express');-- const app = express();-- app.use(express.json());-- const routes = require('../server/chat/live-context-routes');-- app.use('/internal/chat-effects', routes.effectsRouter);-- app.use('/internal', require('../server/internal/routes'));-- app.use('/api/channels', require('../server/admin/channel-mod-routes'));-- const port = await listen(http.createServer(app));-- const call = async (method, p, { body, headers = {} } = {}) => {-- const res = await fetch(`http://127.0.0.1:${port}${p}`, { method, headers: { 'Content-Type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined });-- const text = await res.text();-- let json = null; try { json = JSON.parse(text); } catch { /* */ }-- return { status: res.status, body: json, text };-- };-- // OpenVibe.Chat's token for the staged-table handoff (live.chat_mirror.write); X-Internal-Key is gone (plan T2).-- const CHAT = () => ({ Authorization: `Bearer ${serviceAuth.signServiceToken({ iss: ISS, sub: 'svc:chat', actor_type: 'service', aud: ['openvibe.live'], cap: ['live.chat_mirror.write'], iat: now(), exp: now() + 300, jti: `tok_test_${++jti}` }, keys.privateKey)}` });-- const internal = (method, p, body) => call(method, `/internal${p}`, { body, headers: CHAT() });-- const MIRROR = serviceToken(['live.chat_mirror.write'], 'openvibe.live');-- const mirror = (changes) => call('POST', '/internal/chat-effects/mirror', { body: { changes }, headers: { Authorization: `Bearer ${MIRROR}` } });---- const chatTables = require('../server/chat/chat-tables');-- const sync = require('../server/chat/chat-tables-sync');-- const outbox = (t) => d.prepare('SELECT tbl, op, pk FROM chat_staged_outbox WHERE tbl = ? ORDER BY seq').all(t);-- const stats = (t) => sync.dualReadStats(t);-- const stubRows = (t) => stub.copy.prepare(`SELECT * FROM ${t} ORDER BY ${pkOf(t)}`).all();---- let exit = 0;-- try {-- // 1. Deploying changes nothing: every table 'live', writes local, and without CHAT_AUTHORITY=chat-- // a stored 'chat' does not count.-- for (const t of TABLES) assert.strictEqual(chatTables.authority(t), 'live', t);-- await chatTables.write('addChannelModerator', channel.id, mod, streamer);-- assert.ok(db.isChannelModerator(mod, channel.id));-- db.setState(chatTables.AUTHORITY_KEY, JSON.stringify({ emotes: 'chat' }));-- chatTables._resetCache();-- assert.strictEqual(chatTables.authority('emotes'), 'chat');-- process.env.CHAT_AUTHORITY = '';-- assert.strictEqual(chatTables.authority('emotes'), 'live', 'Live runs chat itself: it writes everything');-- assert.strictEqual(chatTables.init(), false, 'and starts nothing');-- process.env.CHAT_AUTHORITY = 'chat';-- db.setState(chatTables.AUTHORITY_KEY, '{}');-- chatTables._resetCache();-- await assert.rejects(chatTables.write('grantUserTag', viewer, 'x'), /not a staged-table write/, 'user_tags has no writer in Live');---- // 2. init: capture, dual read, relay (the relay's timer is stopped; the test flushes by hand).-- assert.strictEqual(chatTables.init(), true);-- require('../server/utils/jobs').stopAll();-- assert.strictEqual(sync.pending(), 0, 'what was written before init is the import’s business');---- // 3. At 'live': every change to a staged table is captured, whoever writes it.-- await chatTables.write('upsertChannelModerationSettings', channel.id, { slow_mode_seconds: 5 });-- await chatTables.write('upsertChannelModerationSettings', channel.id, { emote_scale: 150 });-- const e1 = Number((await chatTables.write('createEmote', { user_id: streamer, code: 'pog', url: '/data/emotes/pog.png', channel_owner_id: streamer })).lastInsertRowid);-- await chatTables.write('removeChannelModerator', channel.id, mod);-- await chatTables.write('upsertChatAiSummary', { scope: 'global', subject_id: 0, window: 'global', overview: 'busy' });-- await chatTables.write('addChatTimelineEvents', 'global', 0, [{ ts: '2026-09-25 10:00:00', label: 'raid' }]);-- d.prepare("INSERT INTO user_tags (user_id, tag_id, source) VALUES (?, 'legacy', 'migration')").run(viewer);-- assert.deepStrictEqual(outbox('channel_moderation_settings').map((r) => r.op), ['upsert', 'upsert'], 'the insert and the update');-- assert.deepStrictEqual(outbox('channel_moderators').map((r) => [r.op, JSON.parse(r.pk).id > 0]), [['delete', true]]);-- assert.strictEqual(outbox('user_tags').length, 1, 'a raw write is captured too');---- // 4. The relay: newest state per row, in order; kept while Chat is down; acknowledged rows go.-- stub.down = true;-- let r = await sync.flush();-- assert.ok(r.pending >= 6 && /unreachable/.test(r.error), JSON.stringify(r));-- stub.down = false;-- stub.calls.length = 0;-- r = await sync.flush();-- assert.deepStrictEqual([r.pending, r.error], [0, null]);-- const applied = stub.calls.filter((c) => c.op === 'stagedApply').flatMap((c) => c.args[0]);-- const cms = applied.filter((c) => c.table === 'channel_moderation_settings');-- assert.strictEqual(cms.length, 1, 'two changes to one row: one upsert');-- assert.deepStrictEqual([cms[0].row.slow_mode_seconds, cms[0].row.emote_scale], [5, 150]);-- assert.deepStrictEqual(applied.find((c) => c.table === 'channel_moderators'), { table: 'channel_moderators', op: 'delete', pk: { id: 1 } });-- assert.strictEqual(stubRows('emotes')[0].code, 'pog');-- assert.strictEqual(stubRows('user_tags').length, 1);-- // Paused for an import: the capture goes on, nothing is sent; resumed, everything since goes.-- assert.strictEqual((await internal('POST', '/chat-tables/relay', { paused: true })).body.relay.paused, true);-- await chatTables.write('addChatTimelineEvents', 'global', 0, [{ ts: '2026-09-25 10:05:00', label: 'after the pause' }]);-- r = await sync.flush();-- assert.deepStrictEqual([r.error, r.pending], ['relay paused', 1]);-- assert.strictEqual(stubRows('chat_timeline_events').length, 1);-- assert.strictEqual((await internal('POST', '/chat-tables/relay', { paused: false })).body.relay.paused, false);-- assert.strictEqual((await sync.flush()).pending, 0);-- assert.strictEqual(stubRows('chat_timeline_events').length, 2);---- // 5. The dual read: off by default; on, a read is compared in the background; Live's answer never changes.-- const liveAnswer = db.getChannelModerationSettings(channel.id);-- await sleep(50);-- assert.strictEqual(stats('channel_moderation_settings').compared, 0, 'off: nothing compared');-- assert.strictEqual((await internal('POST', '/chat-tables/channel_moderation_settings', { dual_read: true })).body.table.dual_read, true);-- assert.deepStrictEqual(db.getChannelModerationSettings(channel.id), liveAnswer);-- await sleep(150);-- assert.deepStrictEqual([stats('channel_moderation_settings').compared, stats('channel_moderation_settings').matched], [1, 1]);-- // Chat's copy differs for another channel: counted, sampled, logged; Live answers from its own row.-- await chatTables.write('upsertChannelModerationSettings', channel2.id, { slow_mode_seconds: 30 });-- await sync.flush();-- stub.copy.prepare('UPDATE channel_moderation_settings SET slow_mode_seconds = 31 WHERE channel_id = ?').run(channel2.id);-- assert.strictEqual(db.getChannelModerationSettings(channel2.id).slow_mode_seconds, 30);-- await sleep(150);-- let st = stats('channel_moderation_settings');-- assert.deepStrictEqual([st.compared, st.mismatched], [2, 1]);-- assert.deepStrictEqual(st.last_mismatch.where, { channel_id: channel2.id });-- assert.deepStrictEqual(st.last_mismatch.differ, [JSON.stringify([channel2.id])]);-- // A change still on its way to Chat: no verdict.-- await internal('POST', '/chat-tables/emotes', { dual_read: true });-- await chatTables.write('updateEmote', e1, { size: 120 });-- db.getEmoteById(e1);-- await sleep(150);-- assert.deepStrictEqual([stats('emotes').inconclusive, stats('emotes').mismatched], [1, 0]);-- await sync.flush();-- // The same slice is compared at most once a minute.-- db.getChannelModerationSettings(channel2.id);-- await sleep(100);-- assert.strictEqual(stats('channel_moderation_settings').compared, 2);-- assert.strictEqual((await internal('POST', '/chat-tables/channel_moderation_settings', { reset_counters: true })).body.table.dual_read_stats.compared, 0);---- // 6. The status route: loopback + a service token (live.chat_mirror.write); both sides' view of every table.-- assert.strictEqual((await call('GET', '/internal/chat-tables')).status, 401, 'no credentials');-- assert.strictEqual((await call('GET', '/internal/chat-tables', { headers: { ...CHAT(), 'X-Forwarded-For': '203.0.113.1' } })).status, 403, 'a token through the public edge');-- assert.strictEqual((await call('GET', '/internal/chat-tables', { headers: { 'X-Internal-Key': 'k'.repeat(40) } })).status, 401, 'the retired key opens nothing');-- let s = (await internal('GET', '/chat-tables')).body;-- assert.strictEqual(s.chat_authority, 'chat');-- assert.deepStrictEqual([s.tables.emotes.authority, s.tables.emotes.chat_authority, s.tables.emotes.dual_read, s.tables.emotes.outbox_pending], ['live', 'live', true, 0]);-- assert.strictEqual((await internal('POST', '/chat-tables/chat_messages', { authority: 'chat' })).status, 404);---- // 7. While Live writes a table, Chat's mirror rows for it are refused.-- let m = await mirror([{ table: 'channel_moderators', op: 'upsert', row: { id: 900, channel_id: channel.id, user_id: viewer, added_by: streamer } }]);-- assert.deepStrictEqual([m.body.applied, m.body.skipped[0].reason], [0, 'Live writes this table (chat_table_authority live)']);---- // 8. Handoff to 'chat': Live's queued change reaches Chat first, then Chat takes it, then Live stops.-- await chatTables.write('addChannelModerator', channel.id, viewer, streamer);-- assert.strictEqual(outbox('channel_moderators').length, 1);-- stub.calls.length = 0;-- let f = await internal('POST', '/chat-tables/channel_moderators', { authority: 'chat', by: 'test' });-- assert.strictEqual(f.status, 200, f.text);-- // (chat-remote's own traffic — invalidations, forwarded moderation-log rows — is not this.)-- const tableOps = () => stub.calls.filter((c) => ['stagedApply', 'setTableAuthority', 'tableAuthority'].includes(c.op) || (c.op === 'db' && c.args[0] in chatTables.OPS));-- assert.deepStrictEqual(tableOps().map((c) => c.op), ['stagedApply', 'setTableAuthority', 'tableAuthority']);-- assert.deepStrictEqual([f.body.handoff.before, f.body.handoff.authority, f.body.table.authority, f.body.table.chat_authority], ['live', 'chat', 'chat', 'chat']);-- assert.strictEqual(stubRows('channel_moderators').length, 1, 'Chat has the row Live wrote last');-- d.prepare('DELETE FROM channel_moderators WHERE user_id = ?').run(viewer);-- assert.strictEqual(outbox('channel_moderators').length, 0, 'Live’s copy is the mirror now: nothing captured');-- m = await mirror([{ table: 'channel_moderators', op: 'upsert', row: stubRows('channel_moderators')[0] }]);-- assert.strictEqual(m.body.applied, 1, 'and Chat’s mirror is taken');-- assert.ok(db.isChannelModerator(viewer, channel.id));---- // 9. Live's writers go to Chat: the real route, Chat's id, Live's copy right away, nothing written locally first.-- stub.calls.length = 0;-- let res = await call('POST', `/api/channels/${channel.id}/mods`, { body: { username: 'moddy' }, headers: { Authorization: `Bearer ${streamerJwt}` } });-- assert.strictEqual(res.status, 200, res.text);-- const dbCall = tableOps().find((c) => c.op === 'db');-- assert.deepStrictEqual(dbCall.args, ['addChannelModerator', channel.id, mod, streamer]);-- assert.match(dbCall.key, /^staged:/, 'an idempotency key');-- const chatRow = stubRows('channel_moderators').find((x) => x.user_id === mod);-- assert.deepStrictEqual(res.body.moderators.map((x) => x.user_id).sort(), [mod, viewer].sort(), 'the answer reads Live’s copy, already current');-- assert.strictEqual(d.prepare('SELECT id FROM channel_moderators WHERE user_id = ?').get(mod).id, chatRow.id, 'Chat’s id');-- assert.strictEqual(outbox('channel_moderators').length, 0);-- res = await call('DELETE', `/api/channels/${channel.id}/mods/${mod}`, { headers: { Authorization: `Bearer ${streamerJwt}` } });-- assert.strictEqual(res.status, 200, res.text);-- assert.ok(!db.isChannelModerator(mod, channel.id) && !stubRows('channel_moderators').some((x) => x.user_id === mod));-- // Chat unreachable: the write fails and Live's copy is untouched.-- stub.down = true;-- res = await call('POST', `/api/channels/${channel.id}/mods`, { body: { username: 'moddy' }, headers: { Authorization: `Bearer ${streamerJwt}` } });-- assert.strictEqual(res.status, 500);-- assert.ok(!db.isChannelModerator(mod, channel.id), 'no local write when Chat writes the table');-- stub.down = false;-- // Other tables keep their own authority: emotes is still Live's.-- const e2 = Number((await chatTables.write('createEmote', { user_id: viewer, code: 'kek', url: '/e/kek.png' })).lastInsertRowid);-- assert.ok(db.getEmoteById(e2) && outbox('emotes').length === 1 && !stubRows('emotes').some((x) => x.code === 'kek'));-- await sync.flush();---- // 10. A handoff Chat refuses changes nothing; running it again is safe.-- stub.authority.emotes = 'chat';-- stub.refuseHandback = false;-- f = await internal('POST', '/chat-tables/emotes', { authority: 'chat' });-- assert.strictEqual(f.status, 200, 'Chat already agreed: the same call brings Live along');-- stub.down = true;-- f = await internal('POST', '/chat-tables/emotes', { authority: 'live' });-- assert.strictEqual(f.status, 503, f.text);-- assert.strictEqual(chatTables.authority('emotes'), 'chat', 'Chat unreachable: still Chat’s');-- stub.down = false;-- // Emote writes at 'chat' through the helper: Live's copy follows Chat's answer.-- const e3 = Number((await chatTables.write('createEmote', { user_id: viewer, code: 'lul', url: '/e/lul.png' })).lastInsertRowid);-- assert.strictEqual(db.getEmoteById(e3).code, 'lul');-- await chatTables.write('deleteEmote', e3);-- assert.strictEqual(db.getEmoteById(e3), undefined);---- stub.refuseHandback = true;-- f = await internal('POST', '/chat-tables/channel_moderators', { authority: 'live' });-- assert.deepStrictEqual([f.status, chatTables.authority('channel_moderators')], [409, 'chat'], 'Chat still has changes for Live: nothing moves');-- assert.match(f.body.error, /not in Live yet/);-- stub.refuseHandback = false;---- // 11. Back to 'live': Chat's changes first (the stub gave them), then Live writes and captures again.-- f = await internal('POST', '/chat-tables/channel_moderators', { authority: 'live' });-- assert.strictEqual(f.status, 200, f.text);-- assert.deepStrictEqual([f.body.table.authority, f.body.table.chat_authority], ['live', 'live']);-- await chatTables.write('addChannelModerator', channel.id, mod, streamer);-- assert.ok(db.isChannelModerator(mod, channel.id));-- assert.strictEqual(outbox('channel_moderators').length, 1, 'captured again');-- m = await mirror([{ table: 'channel_moderators', op: 'delete', pk: { id: 1 } }]);-- assert.strictEqual(m.body.applied, 0, 'Chat’s mirror is refused again');-- s = (await internal('GET', '/chat-tables')).body;-- assert.deepStrictEqual(Object.fromEntries(Object.entries(s.tables).map(([t, v]) => [t, v.authority])), { channel_moderators: 'live', channel_moderation_settings: 'live', emotes: 'chat', user_tags: 'live', chat_ai_summaries: 'live', chat_timeline_events: 'live' });-- assert.ok(Object.values(s.tables).every((v) => !v.disagree), 'Live and Chat agree on every table');---- // 12. Chat down with a table at 'chat': Live takes it back by force (after the operator drained-- // Chat's mirror); until Chat's side is set too, Chat refuses Live's changes and they wait.-- stub.down = true;-- assert.strictEqual((await internal('POST', '/chat-tables/emotes', { authority: 'chat', force: true })).status, 400, 'force only takes back');-- f = await internal('POST', '/chat-tables/emotes', { authority: 'live', force: true, by: 'test' });-- assert.strictEqual(f.status, 200, f.text);-- assert.strictEqual(chatTables.authority('emotes'), 'live');-- stub.down = false;-- await chatTables.write('updateEmote', e1, { code: 'pog2' });-- r = await sync.flush();-- assert.match(r.error, /OpenVibe.Chat writes emotes but Live does too/);-- assert.strictEqual(outbox('emotes').length, 1, 'Live’s change is kept');-- assert.strictEqual((await internal('GET', '/chat-tables')).body.tables.emotes.disagree, true);-- stub.authority.emotes = 'live'; // scripts/table-authority.js set emotes live --force, in Chat-- r = await sync.flush();-- assert.deepStrictEqual([r.pending, r.error], [0, null]);-- assert.strictEqual(stubRows('emotes').find((x) => x.id === e1).code, 'pog2');---- quiet('chat tables (C-04, Live side): all checks passed');-- } catch (err) {-- console.error(err);-- exit = 1;-- } finally {-- try { db.close(); } catch { /* */ }-- fs.rmSync(tmp, { recursive: true, force: true });-- process.exit(exit);-- }--})();++// roadmap T3: the six chat tables (channel_moderators, channel_moderation_settings, emotes,++// user_tags, chat_ai_summaries, chat_timeline_events) are OpenVibe.Chat's, and this release deletes++// the staged machinery this file exercised (chat-tables.js, chat-tables-sync.js, the++// /internal/chat-tables* routes, the staged branch of the read mirror, the app_state keys and the++// chat_staged_outbox/chat_dual_read_stats tables). There is nothing left here to check; the file++// only reports that it is retired, and is listed with the machinery for physical removal under++// "For Opus" (file deletion is not available in this run).++console.log('T3 staged chat tables: skipped (roadmap T3: Chat owns the six tables)');@@@ -235,6 +235,25 @@ async function check(name, fn) {} finally { delete process.env.PASTES_AUTHORITY; }});++ await check('setup hub: the emote count and moderator count come from OpenVibe.Chat', async () => {++ const moderation = require('../server/chat/moderation-client');++ const origCount = moderation.getEmoteCount, origMod = moderation.getChannelModeration;++ moderation.getEmoteCount = async () => 5;++ moderation.getChannelModeration = async () => ({ settings: { updated_at: '2026-09-01 00:00:00' }, moderator_ids: [7, 2] });++ try {++ const r = await call('GET', '/api/streams/setup-progress', 3);++ assert.strictEqual(r.status, 200);++ const task = (id) => r.json.tasks.find((t) => t.id === id);++ assert.deepStrictEqual([task('emote').count, task('emote').done], [5, true]);++ assert.deepStrictEqual([task('mods').count, task('mods').done], [2, true]);++ assert.strictEqual(task('moderation').done, true, 'a saved settings row (updated_at) ticks the rules task');++ moderation.getChannelModeration = async () => ({ settings: {}, moderator_ids: [] });++ const r2 = await call('GET', '/api/streams/setup-progress', 3);++ const t2 = (id) => r2.json.tasks.find((t) => t.id === id);++ assert.strictEqual(t2('moderation').done, false, 'Chat defaults (no row) do not');++ } finally { moderation.getEmoteCount = origCount; moderation.getChannelModeration = origMod; }++ });++await check('avatar history: the avatar-tagged screenshots, from Community', async () => {process.env.PASTES_AUTHORITY = 'community';try {@@@ -348,6 +348,7 @@ async function check(name, fn) {'server/comments-client.js': 'OpenVibe.Community (configured)','server/chat/chat-remote.js': 'OpenVibe.Chat (configured)','server/chat/chat-tables.js': 'OpenVibe.Chat (configured)',++ 'server/chat/moderation-client.js': 'OpenVibe.Chat internal read API (configured)','server/chat/chat-server.js': 'OpenVibe.Tools anon resolve (configured)','server/streaming/calls-authority.js': 'OpenVibe.Chat (configured)','server/streaming/golive-notify.js': 'OpenVibe.Network (configured)',
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
Self-hosted live streaming platform — Node.js/Express monolith, vanilla JS SPA frontend, SQLite (better-sqlite3). Part of the **OpenVibe network** with SSO via openvibe.network. See [README.md](README.md) for features and [docs/architecture.md](docs/architecture.md) for system design.
Self-hosted live streaming platform — Node.js/Express monolith, vanilla JS SPA frontend, SQLite (better-sqlite3). Part of the **OpenVibe network** with SSO via openvibe.network. See [README.md](README.md) for features and [docs/architecture.md](docs/architecture.md) for system design.
-**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (`MEDIA_EVENTS_AUTHORITY`; the older `POST /internal/media-webhook` stays during the transition). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; the staged chat tables (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`) move to OpenVibe.Chat one at a time (register C-04, runbook in Chat `docs/staged-tables-cutover.md`), so write them only through `await require('./chat/chat-tables').write(...)` (`test/chat-tables-writers.test.js` enforces it); Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes.
+**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (`MEDIA_EVENTS_AUTHORITY`; the older `POST /internal/media-webhook` stays during the transition). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; Chat owns `channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries` and `chat_timeline_events`; Live reads them only through `server/chat/moderation-client.js` (Chat's internal read API, capability `chat.moderation.read`, cached 30 s) and keeps no copy; Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes.
## Commands
## Commands
@@ -30,6 +30,13 @@ with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches
@@ -30,6 +30,13 @@ with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches
unless `CHAT_AUTHORITY=chat`.
unless `CHAT_AUTHORITY=chat`.
- Live's chat tables become Chat's read mirror (`POST /internal/chat-effects/mirror`): same ids, so home
- Live's chat tables become Chat's read mirror (`POST /internal/chat-effects/mirror`): same ids, so home
stats, recaps, AI context, VOD chat replay and the `/api/mod` queues keep reading them in place.
stats, recaps, AI context, VOD chat replay and the `/api/mod` queues keep reading them in place.
+- **The six chat tables are Chat's** (roadmap T3): `channel_moderators`, `channel_moderation_settings`,
+ `emotes`, `user_tags`, `chat_ai_summaries` and `chat_timeline_events`. Live keeps no copy and reads them
+ only through [server/chat/moderation-client.js](../server/chat/moderation-client.js) — Chat's internal
+ read API (`GET /internal/moderation/...`, capability `chat.moderation.read`, cached 30 s, off under
+ `LIVE_DRILL`). The dashboard, channel page and upload UI call Chat directly (`/api/chat/channels/:id/…`,
+ `/api/emotes`, `/api/chat/ai/…`); alert sounds are Chat's, played on Live's request through the bridge op
+ `playAlertSound [streamerId, streamId, kind]` ([server/monetization/alerts.js](../server/monetization/alerts.js)).
## Features
## Features
@@ -2097,7 +2097,7 @@ async function _aiTlLoadMore() {
@@ -2097,7 +2097,7 @@ async function _aiTlLoadMore() {
_aiTl.loading = true;
_aiTl.loading = true;
const track = document.getElementById('ai-tl-track');
const track = document.getElementById('ai-tl-track');
try {
try {
- const data = await api(`/chat-ai/timeline/${encodeURIComponent(_aiTl.username)}?offset=${_aiTl.offset}&limit=${_aiTl.limit}`);
+ const data = await api(`/chat/ai/timeline/${encodeURIComponent(_aiTl.username)}?offset=${_aiTl.offset}&limit=${_aiTl.limit}`);
const sessions = data.sessions || [];
const sessions = data.sessions || [];
if (track && sessions.length) track.insertAdjacentHTML('beforeend', sessions.map(_aiTimelineSessionHTML).join(''));
if (track && sessions.length) track.insertAdjacentHTML('beforeend', sessions.map(_aiTimelineSessionHTML).join(''));
_aiTl.offset += sessions.length;
_aiTl.offset += sessions.length;
@@ -2114,7 +2114,7 @@ async function loadChannelAiTimeline(username) {
@@ -2114,7 +2114,7 @@ async function loadChannelAiTimeline(username) {
wrap.innerHTML = '<div class="loading">Loading AI timeline…</div>';
wrap.innerHTML = '<div class="loading">Loading AI timeline…</div>';
_aiTl = { username, offset: 0, limit: 12, hasMore: false, loading: false, moments: {}, vodBySid: {}, index: null, io: null };
_aiTl = { username, offset: 0, limit: 12, hasMore: false, loading: false, moments: {}, vodBySid: {}, index: null, io: null };
try {
try {
- const data = await api(`/chat-ai/timeline/${encodeURIComponent(username)}?offset=0&limit=12`);
+ const data = await api(`/chat/ai/timeline/${encodeURIComponent(username)}?offset=0&limit=12`);
const sessions = data.sessions || [];
const sessions = data.sessions || [];
const dn = esc(data.display_name || username);
const dn = esc(data.display_name || username);
const streamerOv = data.overview && (data.overview.overview || data.overview.overview_short);
const streamerOv = data.overview && (data.overview.overview || data.overview.overview_short);
@@ -2796,7 +2796,7 @@ async function _setAboutModsCanEdit(v) {
@@ -2796,7 +2796,7 @@ async function _setAboutModsCanEdit(v) {
if (!_aboutChannelId) { await _loadAboutModsSetting(); }
if (!_aboutChannelId) { await _loadAboutModsSetting(); }
if (!_aboutChannelId) return;
if (!_aboutChannelId) return;
try {
try {
- await api(`/channels/${_aboutChannelId}/moderation`, { method: 'PUT', body: { mods_can_edit_about: v ? 1 : 0 } });
+ await api(`/chat/channels/${_aboutChannelId}/moderation`, { method: 'PUT', body: { mods_can_edit_about: v ? 1 : 0 } });
toast(v ? 'Mods can now edit your About' : 'Mods can no longer edit your About', 'success');
toast(v ? 'Mods can now edit your About' : 'Mods can no longer edit your About', 'success');
} catch (e) { toast(e.message || 'Save failed', 'error'); }
} catch (e) { toast(e.message || 'Save failed', 'error'); }
}
}
@@ -20,7 +20,7 @@ async function loadGlobalChatAi() {
@@ -20,7 +20,7 @@ async function loadGlobalChatAi() {
const panel = document.getElementById('global-ai-panel');
const panel = document.getElementById('global-ai-panel');
if (!strip || !panel) return;
if (!strip || !panel) return;
let insight = null;
let insight = null;
- try { insight = (await api('/chat-ai/global')).insight; } catch { /* silent */ }
+ try { insight = (await api('/chat/ai/global')).insight; } catch { /* silent */ }
if (!insight || !insight.overview) {
if (!insight || !insight.overview) {
strip.style.display = 'none';
strip.style.display = 'none';
// Keep the panel only if the user had already opened it.
// Keep the panel only if the user had already opened it.
@@ -93,7 +93,7 @@ async function _gaiTlLoad(reset) {
@@ -93,7 +93,7 @@ async function _gaiTlLoad(reset) {
if (reset && _gaiTl.periodBefore) p.set('before', String(_gaiTl.periodBefore));
if (reset && _gaiTl.periodBefore) p.set('before', String(_gaiTl.periodBefore));
else if (!reset && _gaiTl.oldestTs) p.set('before', String(_gaiTl.oldestTs));
else if (!reset && _gaiTl.oldestTs) p.set('before', String(_gaiTl.oldestTs));
let data;
let data;
- try { data = await api('/chat-ai/timeline?' + p.toString()); } catch { _gaiTl.loading = false; return; }
+ try { data = await api('/chat/ai/timeline?' + p.toString()); } catch { _gaiTl.loading = false; return; }
const el = document.getElementById('global-ai-timeline');
const el = document.getElementById('global-ai-timeline');
if (!el) { _gaiTl.loading = false; return; }
if (!el) { _gaiTl.loading = false; return; }
const events = data.events || [];
const events = data.events || [];
@@ -1515,7 +1515,7 @@ window._uaiRevealMore = _uaiRevealMore;
@@ -1515,7 +1515,7 @@ window._uaiRevealMore = _uaiRevealMore;
async function openUserChatInsight(userId, username) {
async function openUserChatInsight(userId, username) {
if (!userId) return;
if (!userId) return;
- return _openChatInsightModal({ title: username || 'User', username: username || '', iconClass: 'fa-user-tag', fetchUrl: `/chat-ai/user/${userId}` });
+ return _openChatInsightModal({ title: username || 'User', username: username || '', iconClass: 'fa-user-tag', fetchUrl: `/chat/ai/user/${userId}` });
}
}
window.openUserChatInsight = openUserChatInsight;
window.openUserChatInsight = openUserChatInsight;
@@ -1526,7 +1526,7 @@ async function openAnonChatInsight(anonId) {
@@ -1526,7 +1526,7 @@ async function openAnonChatInsight(anonId) {
title: anonId,
title: anonId,
iconClass: 'fa-user-secret',
iconClass: 'fa-user-secret',
subtitle: 'Anonymous chatter — how they chat today vs. overall, from their public messages.',
subtitle: 'Anonymous chatter — how they chat today vs. overall, from their public messages.',
- fetchUrl: `/chat-ai/anon/${encodeURIComponent(anonId)}`,
+ fetchUrl: `/chat/ai/anon/${encodeURIComponent(anonId)}`,
});
});
}
}
window.openAnonChatInsight = openAnonChatInsight;
window.openAnonChatInsight = openAnonChatInsight;
@@ -1540,7 +1540,7 @@ async function openRelayUserChatInsight(platform, username, displayPlatform) {
@@ -1540,7 +1540,7 @@ async function openRelayUserChatInsight(platform, username, displayPlatform) {
iconClass: 'fa-link',
iconClass: 'fa-link',
isRelay: true, // no channel page → expand the timeline inline instead of linking out
isRelay: true, // no channel page → expand the timeline inline instead of linking out
subtitle: `Bridged ${plat} chatter — how they chat today vs. overall, from their relayed messages.`,
subtitle: `Bridged ${plat} chatter — how they chat today vs. overall, from their relayed messages.`,
- fetchUrl: `/chat-ai/relay/${encodeURIComponent(platform)}/${encodeURIComponent(username)}`,
+ fetchUrl: `/chat/ai/relay/${encodeURIComponent(platform)}/${encodeURIComponent(username)}`,
});
});
}
}
window.openRelayUserChatInsight = openRelayUserChatInsight;
window.openRelayUserChatInsight = openRelayUserChatInsight;
@@ -256,7 +256,7 @@
@@ -256,7 +256,7 @@
body.innerHTML = '<div class="cu-empty">Loading channel settings…</div>';
body.innerHTML = '<div class="cu-empty">Loading channel settings…</div>';
let ch = null;
let ch = null;
try {
try {
- const r = await fetch('/api/channels/moderation/mine', { headers: { Authorization: `Bearer ${token()}` } });
+ const r = await fetch('/api/chat/channels/moderation/mine', { headers: { Authorization: `Bearer ${token()}` } });
const data = await r.json();
const data = await r.json();
const list = data.channels || [];
const list = data.channels || [];
// Prefer the channel currently being viewed (works offline), else own channel.
// Prefer the channel currently being viewed (works offline), else own channel.
@@ -373,7 +373,7 @@
@@ -373,7 +373,7 @@
const btn = g('#cu-set-save');
const btn = g('#cu-set-save');
btn.disabled = true;
btn.disabled = true;
try {
try {
- const r = await fetch(`/api/channels/${channelId}/moderation`, {
+ const r = await fetch(`/api/chat/channels/${channelId}/moderation`, {
method: 'PUT', headers: { Authorization: `Bearer ${token()}`, 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
method: 'PUT', headers: { Authorization: `Bearer ${token()}`, 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
});
});
const data = await r.json();
const data = await r.json();
@@ -6397,7 +6397,7 @@ async function saveChatLimits() {
@@ -6397,7 +6397,7 @@ async function saveChatLimits() {
const maxmsg = Math.min(_msgCap, Math.max(1, parseInt(document.getElementById('clp-maxmsg')?.value, 10) || 500));
const maxmsg = Math.min(_msgCap, Math.max(1, parseInt(document.getElementById('clp-maxmsg')?.value, 10) || 500));
const maxtts = Math.min(1200, Math.max(10, parseInt(document.getElementById('clp-maxtts')?.value, 10) || 200));
const maxtts = Math.min(1200, Math.max(10, parseInt(document.getElementById('clp-maxtts')?.value, 10) || 200));
try {
try {
- await api(`/channels/${_chatLimitsChannelId}/moderation`, { method: 'PUT', body: { max_message_length: maxmsg, tts_max_length: maxtts } });
+ await api(`/chat/channels/${_chatLimitsChannelId}/moderation`, { method: 'PUT', body: { max_message_length: maxmsg, tts_max_length: maxtts } });
applyChatLimits({ max_message_length: maxmsg, tts_max_length: maxtts });
applyChatLimits({ max_message_length: maxmsg, tts_max_length: maxtts });
toast('Chat limits saved', 'success');
toast('Chat limits saved', 'success');
document.getElementById('chat-limits-popover')?.remove();
document.getElementById('chat-limits-popover')?.remove();
@@ -227,7 +227,7 @@ async function loadDashModeration() {
@@ -227,7 +227,7 @@ async function loadDashModeration() {
body.innerHTML = '<p class="muted">Loading moderation tools...</p>';
body.innerHTML = '<p class="muted">Loading moderation tools...</p>';
try {
try {
- const data = await api('/channels/moderation/mine');
+ const data = await api('/chat/channels/moderation/mine');
dashModerationChannels = data.channels || [];
dashModerationChannels = data.channels || [];
renderDashModerationChannels(dashModerationChannels);
renderDashModerationChannels(dashModerationChannels);
} catch (err) {
} catch (err) {
@@ -244,7 +244,7 @@ async function dashLoadChannelModerationLogs(channelId) {
@@ -244,7 +244,7 @@ async function dashLoadChannelModerationLogs(channelId) {
if (!target) return;
if (!target) return;
try {
try {
- const data = await api(`/channels/${channelId}/moderation/logs?limit=12`);
+ const data = await api(`/chat/channels/${channelId}/moderation/logs?limit=12`);
const actions = data.actions || [];
const actions = data.actions || [];
target.innerHTML = actions.length ? actions.map((action) => `
target.innerHTML = actions.length ? actions.map((action) => `
<div class="dash-mod-log-entry">
<div class="dash-mod-log-entry">
@@ -264,7 +264,7 @@ window.dashAddChannelModerator = async function dashAddChannelModerator(channelI
@@ -264,7 +264,7 @@ window.dashAddChannelModerator = async function dashAddChannelModerator(channelI
if (!username) return toast('Enter a username to add.', 'error');
if (!username) return toast('Enter a username to add.', 'error');
try {
try {
- await api(`/channels/${channelId}/mods`, {
+ await api(`/chat/channels/${channelId}/mods`, {
method: 'POST',
method: 'POST',
body: { username },
body: { username },
});
});
@@ -279,7 +279,7 @@ window.dashRemoveChannelModerator = async function dashRemoveChannelModerator(ch
@@ -279,7 +279,7 @@ window.dashRemoveChannelModerator = async function dashRemoveChannelModerator(ch
if (!confirm(`Remove ${username} as a channel moderator?`)) return;
if (!confirm(`Remove ${username} as a channel moderator?`)) return;
try {
try {
- await api(`/channels/${channelId}/mods/${userId}`, { method: 'DELETE' });
+ await api(`/chat/channels/${channelId}/mods/${userId}`, { method: 'DELETE' });
toast(`${username} removed`, 'success');
toast(`${username} removed`, 'success');
await dashReloadModeration();
await dashReloadModeration();
} catch (err) {
} catch (err) {
@@ -289,7 +289,7 @@ window.dashRemoveChannelModerator = async function dashRemoveChannelModerator(ch
@@ -289,7 +289,7 @@ window.dashRemoveChannelModerator = async function dashRemoveChannelModerator(ch
window.dashSaveChannelModerationSettings = async function dashSaveChannelModerationSettings(channelId) {
window.dashSaveChannelModerationSettings = async function dashSaveChannelModerationSettings(channelId) {
try {
try {
- await api(`/channels/${channelId}/moderation`, {
+ await api(`/chat/channels/${channelId}/moderation`, {
method: 'PUT',
method: 'PUT',
body: {
body: {
slow_mode_seconds: Number(document.getElementById(`dash-mod-slow-${channelId}`)?.value || 0),
slow_mode_seconds: Number(document.getElementById(`dash-mod-slow-${channelId}`)?.value || 0),
@@ -351,7 +351,7 @@ window.dashSearchChannelChat = async function dashSearchChannelChat(channelId) {
@@ -351,7 +351,7 @@ window.dashSearchChannelChat = async function dashSearchChannelChat(channelId) {
if (query) params.set('q', query);
if (query) params.set('q', query);
if (userId) params.set('user_id', userId);
if (userId) params.set('user_id', userId);
- const data = await api(`/channels/${channelId}/moderation/chat-search?${params}`);
+ const data = await api(`/chat/channels/${channelId}/moderation/chat-search?${params}`);
const messages = data.messages || [];
const messages = data.messages || [];
resultTarget.innerHTML = messages.length ? messages.map((message) => `
resultTarget.innerHTML = messages.length ? messages.map((message) => `
@@ -373,7 +373,7 @@ window.dashDeleteChannelMessage = async function dashDeleteChannelMessage(channe
@@ -373,7 +373,7 @@ window.dashDeleteChannelMessage = async function dashDeleteChannelMessage(channe
if (!confirm('Delete this chat message?')) return;
if (!confirm('Delete this chat message?')) return;
try {
try {
- await api(`/channels/${channelId}/moderation/messages/${messageId}/delete`, { method: 'POST' });
+ await api(`/chat/channels/${channelId}/moderation/messages/${messageId}/delete`, { method: 'POST' });
toast('Message deleted', 'success');
toast('Message deleted', 'success');
await dashLoadChannelModerationLogs(channelId);
await dashLoadChannelModerationLogs(channelId);
await window.dashSearchChannelChat(channelId);
await window.dashSearchChannelChat(channelId);
@@ -35,12 +35,12 @@ function parseBoolean(value, fallback) {
@@ -35,12 +35,12 @@ function parseBoolean(value, fallback) {
}
}
/** Middleware: require channel access (owner, channel mod, or staff). */
/** Middleware: require channel access (owner, channel mod, or staff). */
-function requireChannelAccess(req, res, next) {
+async function requireChannelAccess(req, res, next) {
const channelId = parseInt(req.params.channelId, 10);
const channelId = parseInt(req.params.channelId, 10);
if (!channelId) return res.status(400).json({ error: 'Invalid channel ID' });
if (!channelId) return res.status(400).json({ error: 'Invalid channel ID' });
const channel = db.getChannelById(channelId);
const channel = db.getChannelById(channelId);
if (!channel) return res.status(404).json({ error: 'Channel not found' });
if (!channel) return res.status(404).json({ error: 'Channel not found' });
- if (!permissions.canModerateChannel(req.user, channelId)) {
+ if (!(await permissions.canModerateChannel(req.user, channelId))) {
return res.status(403).json({ error: 'Channel moderation access required' });
return res.status(403).json({ error: 'Channel moderation access required' });
}
}
req.channel = channel;
req.channel = channel;
@@ -194,7 +194,7 @@ router.delete('/users/:id/ban', permissions.requireGlobalMod, (req, res) => {
@@ -194,7 +194,7 @@ router.delete('/users/:id/ban', permissions.requireGlobalMod, (req, res) => {
// ── Stream Ban (any mod with stream moderation powers) ───────
// ── Stream Ban (any mod with stream moderation powers) ───────
// Local ban: creates bans entry scoped to a specific stream
// Local ban: creates bans entry scoped to a specific stream
-router.post('/stream-ban', (req, res) => {
+router.post('/stream-ban', async (req, res) => {
try {
try {
const { user_id, anon_id, stream_id, reason, duration_hours, ip_address } = req.body;
const { user_id, anon_id, stream_id, reason, duration_hours, ip_address } = req.body;
const streamId = parseInt(stream_id);
const streamId = parseInt(stream_id);
@@ -202,7 +202,7 @@ router.post('/stream-ban', (req, res) => {
@@ -202,7 +202,7 @@ router.post('/stream-ban', (req, res) => {
if (!user_id && !anon_id) return res.status(400).json({ error: 'user_id or anon_id required' });
if (!user_id && !anon_id) return res.status(400).json({ error: 'user_id or anon_id required' });
// Check moderation permission for this stream
// Check moderation permission for this stream
- if (!permissions.canModerateStream(req.user, streamId)) {
+ if (!(await permissions.canModerateStream(req.user, streamId))) {
return res.status(403).json({ error: 'You cannot moderate this stream' });
return res.status(403).json({ error: 'You cannot moderate this stream' });
}
}
@@ -266,7 +266,7 @@ router.post('/stream-ban', (req, res) => {
@@ -266,7 +266,7 @@ router.post('/stream-ban', (req, res) => {
});
});
// ── Unban ────────────────────────────────────────────────────
// ── Unban ────────────────────────────────────────────────────
-router.delete('/ban/:id', (req, res) => {
+router.delete('/ban/:id', async (req, res) => {
try {
try {
const ban = db.get('SELECT * FROM bans WHERE id = ?', [req.params.id]);
const ban = db.get('SELECT * FROM bans WHERE id = ?', [req.params.id]);
if (!ban) return res.status(404).json({ error: 'Ban not found' });
if (!ban) return res.status(404).json({ error: 'Ban not found' });
@@ -285,7 +285,7 @@ router.delete('/ban/:id', (req, res) => {
@@ -285,7 +285,7 @@ router.delete('/ban/:id', (req, res) => {
[ban.banned_by, '%' + (ban.reason || '') + '%']);
[ban.banned_by, '%' + (ban.reason || '') + '%']);
}
}
} else {
} else {
- if (!permissions.canModerateStream(req.user, ban.stream_id)) {
+ if (!(await permissions.canModerateStream(req.user, ban.stream_id))) {
return res.status(403).json({ error: 'You cannot moderate this stream' });
return res.status(403).json({ error: 'You cannot moderate this stream' });
}
}
}
}
@@ -361,7 +361,7 @@ router.get('/chat/user/:userId', permissions.requireGlobalMod, (req, res) => {
@@ -361,7 +361,7 @@ router.get('/chat/user/:userId', permissions.requireGlobalMod, (req, res) => {
// ══════════════════════════════════════════════════════════════
// ══════════════════════════════════════════════════════════════
// ── Delete a single chat message ─────────────────────────────
// ── Delete a single chat message ─────────────────────────────
-router.post('/delete-message', (req, res) => {
+router.post('/delete-message', async (req, res) => {
try {
try {
const { message_id, stream_id } = req.body;
const { message_id, stream_id } = req.body;
if (!message_id) return res.status(400).json({ error: 'message_id required' });
if (!message_id) return res.status(400).json({ error: 'message_id required' });
@@ -377,10 +377,10 @@ router.post('/delete-message', (req, res) => {
@@ -377,10 +377,10 @@ router.post('/delete-message', (req, res) => {
const isGlobal = permissions.isGlobalModOrAbove(req.user);
const isGlobal = permissions.isGlobalModOrAbove(req.user);
if (!isGlobal) {
if (!isGlobal) {
let allowed = false;
let allowed = false;
- if (message.stream_id) allowed = permissions.canModerateStream(req.user, message.stream_id);
+ if (message.stream_id) allowed = await permissions.canModerateStream(req.user, message.stream_id);
else if (message.channel_user_id) {
else if (message.channel_user_id) {
const ownerChannel = db.getChannelByUserId(message.channel_user_id);
const ownerChannel = db.getChannelByUserId(message.channel_user_id);
- allowed = !!(ownerChannel && permissions.canModerateChannel(req.user, ownerChannel.id));
+ allowed = !!(ownerChannel && await permissions.canModerateChannel(req.user, ownerChannel.id));
}
}
if (!allowed) return res.status(403).json({ error: 'You cannot moderate this stream' });
if (!allowed) return res.status(403).json({ error: 'You cannot moderate this stream' });
}
}
@@ -416,7 +416,7 @@ router.post('/delete-message', (req, res) => {
@@ -416,7 +416,7 @@ router.post('/delete-message', (req, res) => {
});
});
// ── Bulk-delete all messages from a user/anon/relay ──────────
// ── Bulk-delete all messages from a user/anon/relay ──────────
-router.post('/delete-user-messages', (req, res) => {
+router.post('/delete-user-messages', async (req, res) => {
try {
try {
const { user_id, anon_id, relay_username, stream_id } = req.body;
const { user_id, anon_id, relay_username, stream_id } = req.body;
if (!user_id && !anon_id && !relay_username) {
if (!user_id && !anon_id && !relay_username) {
@@ -429,7 +429,7 @@ router.post('/delete-user-messages', (req, res) => {
@@ -429,7 +429,7 @@ router.post('/delete-user-messages', (req, res) => {
if (!isGlobal) {
if (!isGlobal) {
if (!stream_id) return res.status(400).json({ error: 'stream_id required for stream moderators' });
if (!stream_id) return res.status(400).json({ error: 'stream_id required for stream moderators' });
- if (!permissions.canModerateStream(req.user, stream_id)) {
+ if (!(await permissions.canModerateStream(req.user, stream_id))) {
return res.status(403).json({ error: 'You cannot moderate this stream' });
return res.status(403).json({ error: 'You cannot moderate this stream' });
}
}
}
}
@@ -481,7 +481,7 @@ router.post('/delete-user-messages', (req, res) => {
@@ -481,7 +481,7 @@ router.post('/delete-user-messages', (req, res) => {
// ══════════════════════════════════════════════════════════════
// ══════════════════════════════════════════════════════════════
// ── Hide or ban a relayed external user ──────────────────────
// ── Hide or ban a relayed external user ──────────────────────
-router.post('/relay-user/hide', (req, res) => {
+router.post('/relay-user/hide', async (req, res) => {
try {
try {
const { channel_id, platform, external_username, action, reason } = req.body;
const { channel_id, platform, external_username, action, reason } = req.body;
if (!platform || !external_username) {
if (!platform || !external_username) {
@@ -496,7 +496,7 @@ router.post('/relay-user/hide', (req, res) => {
@@ -496,7 +496,7 @@ router.post('/relay-user/hide', (req, res) => {
if (!channel_id) return res.status(403).json({ error: 'Only staff can hide relay users site-wide' });
if (!channel_id) return res.status(403).json({ error: 'Only staff can hide relay users site-wide' });
const channel = db.getChannelById(channel_id);
const channel = db.getChannelById(channel_id);
if (!channel) return res.status(404).json({ error: 'Channel not found' });
if (!channel) return res.status(404).json({ error: 'Channel not found' });
- if (!permissions.canModerateChannel(req.user, channel.id)) {
+ if (!(await permissions.canModerateChannel(req.user, channel.id))) {
return res.status(403).json({ error: 'You cannot moderate this channel' });
return res.status(403).json({ error: 'You cannot moderate this channel' });
}
}
}
}
@@ -539,13 +539,13 @@ router.post('/relay-user/hide', (req, res) => {
@@ -539,13 +539,13 @@ router.post('/relay-user/hide', (req, res) => {
});
});
// ── Unhide a relayed user ────────────────────────────────────
// ── Unhide a relayed user ────────────────────────────────────
-router.delete('/relay-user/:id', (req, res) => {
+router.delete('/relay-user/:id', async (req, res) => {
try {
try {
const row = db.get('SELECT id, channel_id FROM hidden_relay_users WHERE id = ?', [parseInt(req.params.id)]);
const row = db.get('SELECT id, channel_id FROM hidden_relay_users WHERE id = ?', [parseInt(req.params.id)]);
if (!row) return res.status(404).json({ error: 'Not found' });
if (!row) return res.status(404).json({ error: 'Not found' });
// Site rows are staff's; a channel row belongs to that channel's moderators. This had no check.
// Site rows are staff's; a channel row belongs to that channel's moderators. This had no check.
const allowed = permissions.isGlobalModOrAbove(req.user)
const allowed = permissions.isGlobalModOrAbove(req.user)
- || (row.channel_id != null && permissions.canModerateChannel(req.user, row.channel_id));
+ || (row.channel_id != null && await permissions.canModerateChannel(req.user, row.channel_id));
if (!allowed) return res.status(403).json({ error: 'You cannot moderate this channel' });
if (!allowed) return res.status(403).json({ error: 'You cannot moderate this channel' });
db.unhideRelayUser(row.id);
db.unhideRelayUser(row.id);
@@ -564,10 +564,10 @@ router.delete('/relay-user/:id', (req, res) => {
@@ -564,10 +564,10 @@ router.delete('/relay-user/:id', (req, res) => {
});
});
// ── List hidden relay users for a channel ────────────────────
// ── List hidden relay users for a channel ────────────────────
-router.get('/relay-users/hidden/:channelId', (req, res) => {
+router.get('/relay-users/hidden/:channelId', async (req, res) => {
try {
try {
const channelId = parseInt(req.params.channelId);
const channelId = parseInt(req.params.channelId);
- if (!permissions.canModerateChannel(req.user, channelId)) return res.status(403).json({ error: 'Access denied' });
+ if (!(await permissions.canModerateChannel(req.user, channelId))) return res.status(403).json({ error: 'Access denied' });
const hidden = db.getHiddenRelayUsers(channelId);
const hidden = db.getHiddenRelayUsers(channelId);
res.json({ hidden });
res.json({ hidden });
} catch (err) {
} catch (err) {
@@ -581,12 +581,12 @@ router.get('/relay-users/hidden/:channelId', (req, res) => {
@@ -581,12 +581,12 @@ router.get('/relay-users/hidden/:channelId', (req, res) => {
// ══════════════════════════════════════════════════════════════
// ══════════════════════════════════════════════════════════════
// ── Get pending messages for a channel ───────────────────────
// ── Get pending messages for a channel ───────────────────────
-router.get('/ip-approval/:channelId/pending', (req, res) => {
+router.get('/ip-approval/:channelId/pending', async (req, res) => {
try {
try {
const channelId = parseInt(req.params.channelId);
const channelId = parseInt(req.params.channelId);
// A channel id, checked as a channel. canModerateStream() was given this id as if it were a
// A channel id, checked as a channel. canModerateStream() was given this id as if it were a
// stream id, so owning stream #N opened channel #N's queue — IP addresses and locations.
// stream id, so owning stream #N opened channel #N's queue — IP addresses and locations.
- if (!permissions.canModerateChannel(req.user, channelId)) {
+ if (!(await permissions.canModerateChannel(req.user, channelId))) {
return res.status(403).json({ error: 'Access denied' });
return res.status(403).json({ error: 'Access denied' });
}
}
const pending = db.getPendingIpMessages(channelId);
const pending = db.getPendingIpMessages(channelId);
@@ -21,8 +21,9 @@
@@ -21,8 +21,9 @@
* kept rows point at nobody.
* kept rows point at nobody.
*
*
* Never touched: the frozen tables (vods, clips, pastes, paste_likes, paste_comments, comments), which Media and
* Never touched: the frozen tables (vods, clips, pastes, paste_likes, paste_comments, comments), which Media and
- * Community own and erase themselves. A staged chat table (chat-tables.js) is erased by whichever side writes it now:
- * here while Live does (the capture relays the change to Chat's copy), by Chat once Chat does (its mirror relays it back).
+ * Community own and erase themselves. The six chat tables (channel_moderators, channel_moderation_settings, emotes,
+ * user_tags, chat_ai_summaries, chat_timeline_events) are OpenVibe.Chat's (roadmap T3) and erased by its eraser
+ * (Chat server/chat/account-data.js), never here.
*
*
* Applied once per export or deletion (account_data_events); a redelivery resends only what did not reach Network.
* Applied once per export or deletion (account_data_events); a redelivery resends only what did not reach Network.
*/
*/
@@ -44,8 +45,8 @@ const EXTRA = [['channel_points', 'streamer_id'], ['arena_mic_moments', 'target_
@@ -44,8 +45,8 @@ const EXTRA = [['channel_points', 'streamer_id'], ['arena_mic_moments', 'target_
const SECRET_COL = /(^|_)(token|tokens|secret|hash|password|code|key|keys|p256dh|auth|cookie|cookies|credentials)($|_)/i;
const SECRET_COL = /(^|_)(token|tokens|secret|hash|password|code|key|keys|p256dh|auth|cookie|cookies|credentials)($|_)/i;
const ROW_LIMIT = 2000;
const ROW_LIMIT = 2000;
const PART_BUDGET = 18 * 1024 * 1024;
const PART_BUDGET = 18 * 1024 * 1024;
-
-function staged() { return new Set(Object.keys(require('../chat/chat-tables').TABLES)); }
+// OpenVibe.Chat owns these and erases them itself (roadmap T3); Live must not touch them.
+const CHAT_TABLES = new Set(['channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events']);
function ensureSchema(d) {
function ensureSchema(d) {
d.exec(`CREATE TABLE IF NOT EXISTS account_data_events (
d.exec(`CREATE TABLE IF NOT EXISTS account_data_events (
@@ -128,7 +129,6 @@ function eraseUsers(d, userIds, { now = new Date().toISOString() } = {}) {
@@ -128,7 +129,6 @@ function eraseUsers(d, userIds, { now = new Date().toISOString() } = {}) {
const erased = {};
const erased = {};
const retained = {};
const retained = {};
const bump = (o, k, n) => { if (n) o[k] = (o[k] || 0) + n; };
const bump = (o, k, n) => { if (n) o[k] = (o[k] || 0) + n; };
- const stagedTables = staged();
const cols = personColumns(d);
const cols = personColumns(d);
const usersCols = d.prepare('PRAGMA table_info(users)').all().map((c) => c.name);
const usersCols = d.prepare('PRAGMA table_info(users)').all().map((c) => c.name);
const onBilling = (() => { try { return require('../monetization/money-authority').onBilling(); } catch { return false; } })();
const onBilling = (() => { try { return require('../monetization/money-authority').onBilling(); } catch { return false; } })();
@@ -137,7 +137,8 @@ function eraseUsers(d, userIds, { now = new Date().toISOString() } = {}) {
@@ -137,7 +137,8 @@ function eraseUsers(d, userIds, { now = new Date().toISOString() } = {}) {
for (const [t, list] of cols) {
for (const [t, list] of cols) {
const where = list.map((c) => `${q(c.col)} = ?`).join(' OR ');
const where = list.map((c) => `${q(c.col)} = ?`).join(' OR ');
if (RETAIN.has(t)) { bump(retained, t, d.prepare(`SELECT COUNT(*) AS n FROM ${q(t)} WHERE ${where}`).get(...list.map(() => uid)).n); continue; }
if (RETAIN.has(t)) { bump(retained, t, d.prepare(`SELECT COUNT(*) AS n FROM ${q(t)} WHERE ${where}`).get(...list.map(() => uid)).n); continue; }
- if (stagedTables.has(t) && require('../chat/chat-tables').authority(t) !== 'live') { bump(retained, 'left_for_chat', d.prepare(`SELECT COUNT(*) AS n FROM ${q(t)} WHERE ${where}`).get(...list.map(() => uid)).n); continue; }
+ // OpenVibe.Chat's tables: its own eraser deletes them; Live reports the rows it left for it.
+ if (CHAT_TABLES.has(t)) { bump(retained, 'left_for_chat', d.prepare(`SELECT COUNT(*) AS n FROM ${q(t)} WHERE ${where}`).get(...list.map(() => uid)).n); continue; }
for (const c of list) {
for (const c of list) {
if (c.action === 'null') {
if (c.action === 'null') {
const info = d.prepare(`PRAGMA table_info(${q(t)})`).all().filter((x) => NAME_COLS.includes(x.name));
const info = d.prepare(`PRAGMA table_info(${q(t)})`).all().filter((x) => NAME_COLS.includes(x.name));
@@ -14,6 +14,7 @@
@@ -14,6 +14,7 @@
*/
*/
const db = require('../db/database');
const db = require('../db/database');
+const moderation = require('../chat/moderation-client');
const { staff } = require('openvibe-contracts');
const { staff } = require('openvibe-contracts');
// ── Role hierarchy (higher = more power) ─────────────────────
// ── Role hierarchy (higher = more power) ─────────────────────
@@ -117,10 +118,19 @@ function isStreamer(user) {
@@ -117,10 +118,19 @@ function isStreamer(user) {
/**
/**
* Is this user a channel moderator for the given channel?
* Is this user a channel moderator for the given channel?
+ * OpenVibe.Chat owns channel_moderators (roadmap T3), so this reads Chat (server/chat/moderation-client.js).
+ * Async: hot paths that cannot await use the cached isChannelModSync().
*/
*/
-function isChannelMod(user, channelId) {
+async function isChannelMod(user, channelId) {
if (!user?.id || !channelId) return false;
if (!user?.id || !channelId) return false;
- return !!db.isChannelModerator(user.id, channelId);
+ const m = await moderation.getChannelModeration(channelId);
+ return m.moderator_ids.includes(Number(user.id));
+}
+/** The same question from the client's 30 s cache; a miss is "not a moderator" and refreshes behind the read. */
+function isChannelModSync(user, channelId) {
+ if (!user?.id || !channelId) return false;
+ const m = moderation.peekChannelModeration(channelId);
+ return !!(m && m.moderator_ids.includes(Number(user.id)));
}
}
/**
/**
@@ -220,24 +230,38 @@ function canManageSiteBans(user) {
@@ -220,24 +230,38 @@ function canManageSiteBans(user) {
*
*
* True for: admin, global_mod, channel owner, channel mod
* True for: admin, global_mod, channel owner, channel mod
*/
*/
-function canModerateChannel(user, channelId) {
+async function canModerateChannel(user, channelId) {
if (!user) return false;
if (!user) return false;
if (can(user, 'staff.moderation.chat')) return true;
if (can(user, 'staff.moderation.chat')) return true;
if (isChannelOwner(user, channelId)) return true;
if (isChannelOwner(user, channelId)) return true;
return isChannelMod(user, channelId);
return isChannelMod(user, channelId);
}
}
+function canModerateChannelSync(user, channelId) {
+ if (!user) return false;
+ if (can(user, 'staff.moderation.chat')) return true;
+ if (isChannelOwner(user, channelId)) return true;
+ return isChannelModSync(user, channelId);
+}
/**
/**
* Can this user moderate a specific stream's chat?
* Can this user moderate a specific stream's chat?
*
*
* Resolves stream → channel, then checks channel moderation.
* Resolves stream → channel, then checks channel moderation.
*/
*/
-function canModerateStream(user, streamId) {
+async function canModerateStream(user, streamId) {
if (!user) return false;
if (!user) return false;
if (can(user, 'staff.moderation.chat')) return true;
if (can(user, 'staff.moderation.chat')) return true;
if (isStreamOwner(user, streamId)) return true;
if (isStreamOwner(user, streamId)) return true;
const channelId = getChannelIdForStream(streamId);
const channelId = getChannelIdForStream(streamId);
- if (channelId && isChannelMod(user, channelId)) return true;
+ if (channelId && await isChannelMod(user, channelId)) return true;
+ return false;
+}
+function canModerateStreamSync(user, streamId) {
+ if (!user) return false;
+ if (can(user, 'staff.moderation.chat')) return true;
+ if (isStreamOwner(user, streamId)) return true;
+ const channelId = getChannelIdForStream(streamId);
+ if (channelId && isChannelModSync(user, channelId)) return true;
return false;
return false;
}
}
@@ -245,9 +269,12 @@ function canModerateStream(user, streamId) {
@@ -245,9 +269,12 @@ function canModerateStream(user, streamId) {
* Can this user moderate a call on a specific stream?
* Can this user moderate a call on a specific stream?
* Same rules as chat moderation.
* Same rules as chat moderation.
*/
*/
-function canModerateCall(user, streamId) {
+async function canModerateCall(user, streamId) {
return canModerateStream(user, streamId);
return canModerateStream(user, streamId);
}
}
+function canModerateCallSync(user, streamId) {
+ return canModerateStreamSync(user, streamId);
+}
/**
/**
* Can this user view chat logs?
* Can this user view chat logs?
@@ -327,7 +354,8 @@ function getCapabilities(user) {
@@ -327,7 +354,8 @@ function getCapabilities(user) {
}
}
const ownedChannel = db.getChannelByUserId(user.id);
const ownedChannel = db.getChannelByUserId(user.id);
- const moderatedChannels = db.getChannelsByModerator(user.id) || [];
+ // Chat owns channel_moderators; the cached answer (or [], refreshed behind the load) decides the UI flag.
+ const moderatedChannels = moderation.peekChannelsByModerator(user.id) || [];
const isStaffUser = isGlobalModOrAbove(user);
const isStaffUser = isGlobalModOrAbove(user);
const owner = isOwner(user);
const owner = isOwner(user);
@@ -451,6 +479,7 @@ module.exports = {
@@ -451,6 +479,7 @@ module.exports = {
canModerateContentOwner,
canModerateContentOwner,
requireOwner,
requireOwner,
isChannelMod,
isChannelMod,
+ isChannelModSync,
isChannelOwner,
isChannelOwner,
isStreamOwner,
isStreamOwner,
roleRank,
roleRank,
@@ -464,8 +493,11 @@ module.exports = {
@@ -464,8 +493,11 @@ module.exports = {
canReviewVpn,
canReviewVpn,
canManageSiteBans,
canManageSiteBans,
canModerateChannel,
canModerateChannel,
+ canModerateChannelSync,
canModerateStream,
canModerateStream,
+ canModerateStreamSync,
canModerateCall,
canModerateCall,
+ canModerateCallSync,
canViewChatLogs,
canViewChatLogs,
canViewOtherUserLogs,
canViewOtherUserLogs,
canAssignChannelMods,
canAssignChannelMods,
@@ -291,6 +291,8 @@ function create(ChatServer) {
@@ -291,6 +291,8 @@ function create(ChatServer) {
// ── Pushes to browsers (Chat delivers) ──
// ── Pushes to browsers (Chat delivers) ──
broadcastToStream: send('broadcastToStream'),
broadcastToStream: send('broadcastToStream'),
broadcastToChannelRoom: send('broadcastToChannelRoom'),
broadcastToChannelRoom: send('broadcastToChannelRoom'),
+ /** A donation / goal alert: Chat resolves the sound from its own settings row and plays it. */
+ playAlertSound: send('playAlertSound'),
broadcastGlobal: send('broadcastGlobal'),
broadcastGlobal: send('broadcastGlobal'),
broadcastAll: send('broadcastAll'),
broadcastAll: send('broadcastAll'),
forwardToGlobal: send('forwardToGlobal'),
forwardToGlobal: send('forwardToGlobal'),
@@ -17,6 +17,7 @@ const { v4: uuidv4 } = require('uuid');
@@ -17,6 +17,7 @@ const { v4: uuidv4 } = require('uuid');
const db = require('../db/database');
const db = require('../db/database');
const { extractWsToken, authenticateWs } = require('../auth/auth');
const { extractWsToken, authenticateWs } = require('../auth/auth');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
+const moderation = require('./moderation-client');
const wordFilter = require('./word-filter');
const wordFilter = require('./word-filter');
const cosmetics = require('../monetization/cosmetics');
const cosmetics = require('../monetization/cosmetics');
const ttsEngine = require('./tts-engine');
const ttsEngine = require('./tts-engine');
@@ -518,7 +519,7 @@ class ChatServer {
@@ -518,7 +519,7 @@ class ChatServer {
handleSelfDeleteHistory(ws, client) {
handleSelfDeleteHistory(ws, client) {
if (!client) return;
if (!client) return;
- const canBypass = client.streamId ? permissions.canModerateStream(client.user, client.streamId) : false;
+ const canBypass = client.streamId ? permissions.canModerateStreamSync(client.user, client.streamId) : false;
const chatSettings = this._getChannelChatSettings(client.streamId);
const chatSettings = this._getChannelChatSettings(client.streamId);
if (client.streamId && !canBypass && chatSettings.viewer_delete_all_enabled === 0) {
if (client.streamId && !canBypass && chatSettings.viewer_delete_all_enabled === 0) {
this.sendTo(ws, { type: 'error', message: 'This streamer has disabled viewer self-delete for this chat.' });
this.sendTo(ws, { type: 'error', message: 'This streamer has disabled viewer self-delete for this chat.' });
@@ -667,7 +668,7 @@ class ChatServer {
@@ -667,7 +668,7 @@ class ChatServer {
const stream = db.getStreamById(client.streamId);
const stream = db.getStreamById(client.streamId);
const channel = stream?.channel_id ? db.getChannelById(stream.channel_id) : (stream ? db.getChannelByUserId(stream.user_id) : null);
const channel = stream?.channel_id ? db.getChannelById(stream.channel_id) : (stream ? db.getChannelByUserId(stream.user_id) : null);
if (channel) {
if (channel) {
- const settings = db.getChannelModerationSettings(channel.id);
+ const settings = moderation.settingsSync(channel.id);
if (settings?.ip_approval_mode) {
if (settings?.ip_approval_mode) {
const isStaffBypass = client.user && permissions.isGlobalModOrAbove(client.user);
const isStaffBypass = client.user && permissions.isGlobalModOrAbove(client.user);
const isOwner = client.user && stream && stream.user_id === client.user.id;
const isOwner = client.user && stream && stream.user_id === client.user.id;
@@ -716,7 +717,7 @@ class ChatServer {
@@ -716,7 +717,7 @@ class ChatServer {
if (modStreamId) {
if (modStreamId) {
const chatSettings = this._getChannelChatSettings(modStreamId);
const chatSettings = this._getChannelChatSettings(modStreamId);
const isStaff = client.user && permissions.isGlobalModOrAbove(client.user);
const isStaff = client.user && permissions.isGlobalModOrAbove(client.user);
- const canModerateThisStream = permissions.canModerateStream(client.user, modStreamId);
+ const canModerateThisStream = permissions.canModerateStreamSync(client.user, modStreamId);
// Max message length
// Max message length
const maxLen = Math.max(50, Number(chatSettings.max_message_length || 500));
const maxLen = Math.max(50, Number(chatSettings.max_message_length || 500));
@@ -822,7 +823,7 @@ class ChatServer {
@@ -822,7 +823,7 @@ class ChatServer {
const requestedAutoDeleteMinutes = parseInt(msg.auto_delete_minutes, 10);
const requestedAutoDeleteMinutes = parseInt(msg.auto_delete_minutes, 10);
const allowViewerAutoDelete = !client.streamId
const allowViewerAutoDelete = !client.streamId
|| this._getChannelChatSettings(client.streamId).viewer_auto_delete_enabled !== 0
|| this._getChannelChatSettings(client.streamId).viewer_auto_delete_enabled !== 0
- || permissions.canModerateStream(client.user, client.streamId);
+ || permissions.canModerateStreamSync(client.user, client.streamId);
const autoDeleteAt = Number.isFinite(requestedAutoDeleteMinutes)
const autoDeleteAt = Number.isFinite(requestedAutoDeleteMinutes)
&& requestedAutoDeleteMinutes >= MIN_CHAT_AUTO_DELETE_MINUTES
&& requestedAutoDeleteMinutes >= MIN_CHAT_AUTO_DELETE_MINUTES
&& allowViewerAutoDelete
&& allowViewerAutoDelete
@@ -964,7 +965,7 @@ class ChatServer {
@@ -964,7 +965,7 @@ class ChatServer {
// genuine human messages.
// genuine human messages.
try {
try {
let isMod = false;
let isMod = false;
- try { isMod = !!(client.user && permissions.canModerateChannel(client.user, client.channelUserId)); } catch { /* */ }
+ try { isMod = !!(client.user && permissions.canModerateChannelSync(client.user, client.channelUserId)); } catch { /* */ }
require('../integrations/ai-chatbot-service').onRealChatMessage(client.streamId, {
require('../integrations/ai-chatbot-service').onRealChatMessage(client.streamId, {
username,
username,
message: text,
message: text,
@@ -1013,7 +1014,7 @@ class ChatServer {
@@ -1013,7 +1014,7 @@ class ChatServer {
// channel's current live stream rather than the stale id.
// channel's current live stream rather than the stale id.
if (client.channelUserId && pc.channelRelayEnabled(client.channelUserId, client.streamId)) {
if (client.channelUserId && pc.channelRelayEnabled(client.channelUserId, client.streamId)) {
let isMod = false, isSub = false;
let isMod = false, isSub = false;
- try { isMod = !!(client.user && permissions.canModerateChannel(client.user, client.channelUserId)); } catch { /* */ }
+ try { isMod = !!(client.user && permissions.canModerateChannelSync(client.user, client.channelUserId)); } catch { /* */ }
try { isSub = !!(client.user && db.isActiveSubscriber(client.user.id, client.channelUserId)); } catch { /* */ }
try { isSub = !!(client.user && db.isActiveSubscriber(client.user.id, client.channelUserId)); } catch { /* */ }
pc.forwardChat(client.channelUserId, {
pc.forwardChat(client.channelUserId, {
chatterName: username,
chatterName: username,
@@ -1573,16 +1574,10 @@ class ChatServer {
@@ -1573,16 +1574,10 @@ class ChatServer {
seconds = parseInt(args);
seconds = parseInt(args);
if (!Number.isFinite(seconds) || seconds < 0) seconds = 3;
if (!Number.isFinite(seconds) || seconds < 0) seconds = 3;
}
}
- // Per-stream slow mode (not global)
+ // Per-stream slow mode (not global). OpenVibe.Chat owns channel_moderation_settings
+ // (roadmap T3) and persists /slow itself; Live's own chat server only holds it in memory.
if (client.streamId) {
if (client.streamId) {
this.slowModeByStream.set(client.streamId, seconds > 0 ? seconds * 1000 : 0);
this.slowModeByStream.set(client.streamId, seconds > 0 ? seconds * 1000 : 0);
- // Persist to DB
- try {
- const stream = db.getStreamById(client.streamId);
- if (stream?.channel_id) {
- require('./chat-tables').write('upsertChannelModerationSettings', stream.channel_id, { slow_mode_seconds: seconds }).catch(() => { /* non-critical */ });
- }
- } catch { /* non-critical */ }
}
}
// Dedicated slowmode event so clients can show/hide UI
// Dedicated slowmode event so clients can show/hide UI
this.broadcastToStream(client.streamId, {
this.broadcastToStream(client.streamId, {
@@ -1985,7 +1980,7 @@ class ChatServer {
@@ -1985,7 +1980,7 @@ class ChatServer {
if (!client.user) return false;
if (!client.user) return false;
// Offline channel chat: the channel's latest stream stands in (see _moderationStreamFor).
// Offline channel chat: the channel's latest stream stands in (see _moderationStreamFor).
const sid = client.streamId || this._moderationStreamFor(client);
const sid = client.streamId || this._moderationStreamFor(client);
- return !!sid && permissions.canModerateStream(client.user, sid) || permissions.isGlobalModOrAbove(client.user);
+ return !!sid && permissions.canModerateStreamSync(client.user, sid) || permissions.isGlobalModOrAbove(client.user);
}
}
/** @deprecated Use canModerate(client) — kept temporarily for any external callers */
/** @deprecated Use canModerate(client) — kept temporarily for any external callers */
@@ -2438,6 +2433,14 @@ class ChatServer {
@@ -2438,6 +2433,14 @@ class ChatServer {
} catch { /* non-critical */ }
} catch { /* non-critical */ }
}
}
+ /**
+ * Donation / goal alert sounds are OpenVibe.Chat's now (roadmap T3): the mapping lives on
+ * channel_moderation_settings, which Chat owns, and Chat resolves it from its own row and
+ * broadcasts the clip. When Live runs the chat server itself (rollback / dev) there is no Chat to
+ * ask, so this is a no-op; server/monetization/alerts.js calls the chat-remote proxy's op instead.
+ */
+ playAlertSound() { /* Chat plays alerts; see server/monetization/alerts.js */ }
+
/**
/**
* Get channel moderation settings for a stream.
* Get channel moderation settings for a stream.
* Caches the channel lookup to avoid repeated DB queries.
* Caches the channel lookup to avoid repeated DB queries.
@@ -2478,7 +2481,7 @@ class ChatServer {
@@ -2478,7 +2481,7 @@ class ChatServer {
if (!stream) return finalize(defaults);
if (!stream) return finalize(defaults);
const channel = stream.channel_id ? db.getChannelById(stream.channel_id) : db.getChannelByUserId(stream.user_id);
const channel = stream.channel_id ? db.getChannelById(stream.channel_id) : db.getChannelByUserId(stream.user_id);
if (!channel) return finalize(defaults);
if (!channel) return finalize(defaults);
- return finalize({ ...defaults, ...db.getChannelModerationSettings(channel.id) });
+ return finalize({ ...defaults, ...moderation.settingsSync(channel.id) });
} catch {
} catch {
return finalize(defaults);
return finalize(defaults);
}
}
@@ -39,7 +39,6 @@ const config = require('../config');
@@ -39,7 +39,6 @@ const config = require('../config');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const { guard } = require('../net/service-guard');
const { guard } = require('../net/service-guard');
const { isRemote } = require('./chat-authority');
const { isRemote } = require('./chat-authority');
-const chatTables = require('./chat-tables');
const contextRouter = express.Router();
const contextRouter = express.Router();
const effectsRouter = express.Router();
const effectsRouter = express.Router();
@@ -55,7 +54,7 @@ const USER_SQL = `SELECT u.id, u.username, u.display_name, u.avatar_url, u.profi
@@ -55,7 +54,7 @@ const USER_SQL = `SELECT u.id, u.username, u.display_name, u.avatar_url, u.profi
COALESCE(u.is_owner, 0) AS is_owner, u.created_at, ${SUBJECT_SQL} AS subject_id FROM users u`;
COALESCE(u.is_owner, 0) AS is_owner, u.created_at, ${SUBJECT_SQL} AS subject_id FROM users u`;
const STREAM_SQL = 'SELECT id, user_id, channel_id, managed_stream_id, title, is_live, started_at, ended_at, created_at FROM streams';
const STREAM_SQL = 'SELECT id, user_id, channel_id, managed_stream_id, title, is_live, started_at, ended_at, created_at FROM streams';
const MS_SQL = 'SELECT id, user_id, slug, title, sort_order, created_at FROM managed_streams';
const MS_SQL = 'SELECT id, user_id, slug, title, sort_order, created_at FROM managed_streams';
-const CHANNEL_SQL = 'SELECT id, user_id, title FROM channels';
+const CHANNEL_SQL = 'SELECT id, user_id, title, emote_sources FROM channels';
/** The chat projection of a user row (no secrets: no email, password, stream key, balances). */
/** The chat projection of a user row (no secrets: no email, password, stream key, balances). */
function userProjection(user, subjectId) {
function userProjection(user, subjectId) {
@@ -191,16 +190,13 @@ contextRouter.get('/channels/by-user/:userId', (req, res) => {
@@ -191,16 +190,13 @@ contextRouter.get('/channels/by-user/:userId', (req, res) => {
res.json({ channel: db.get(`${CHANNEL_SQL} WHERE user_id = ?`, [int(req.params.userId)]) || null });
res.json({ channel: db.get(`${CHANNEL_SQL} WHERE user_id = ?`, [int(req.params.userId)]) || null });
});
});
contextRouter.get('/channels/:id/policy', (req, res) => {
contextRouter.get('/channels/:id/policy', (req, res) => {
+ // OpenVibe.Chat owns channel_moderation_settings and channel_moderators (roadmap T3) and reads
+ // them locally; Live answers only the channel row and the language it owns.
const channelId = int(req.params.id);
const channelId = int(req.params.id);
const channel = db.get(`${CHANNEL_SQL} WHERE id = ?`, [channelId]) || null;
const channel = db.get(`${CHANNEL_SQL} WHERE id = ?`, [channelId]) || null;
let language = 'en';
let language = 'en';
try { if (channel) language = require('../i18n/translate').channelLanguage(channel.user_id) || 'en'; } catch { language = 'en'; }
try { if (channel) language = require('../i18n/translate').channelLanguage(channel.user_id) || 'en'; } catch { language = 'en'; }
- res.json({
- channel,
- settings: db.getChannelModerationSettings(channelId),
- moderator_ids: db.all('SELECT user_id FROM channel_moderators WHERE channel_id = ?', [channelId]).map((r) => r.user_id),
- language,
- });
+ res.json({ channel, language });
});
});
contextRouter.get('/channels/:id/approved-ip', (req, res) => {
contextRouter.get('/channels/:id/approved-ip', (req, res) => {
res.json({ approved: db.isIpApproved(int(req.params.id), String(req.query.ip || '')) });
res.json({ approved: db.isIpApproved(int(req.params.id), String(req.query.ip || '')) });
@@ -321,17 +317,17 @@ effectsRouter.post('/user-color', (req, res) => {
@@ -321,17 +317,17 @@ effectsRouter.post('/user-color', (req, res) => {
// /ban, /timeout, /unban from chat. The moderator is checked again: global staff, or a
// /ban, /timeout, /unban from chat. The moderator is checked again: global staff, or a
// moderator of the stream chat decided on (for an offline channel room, its latest stream).
// moderator of the stream chat decided on (for an offline channel room, its latest stream).
-effectsRouter.post('/ban', (req, res) => {
+effectsRouter.post('/ban', async (req, res) => {
const b = req.body || {};
const b = req.body || {};
const actor = actorOf(int(b.actor_user_id));
const actor = actorOf(int(b.actor_user_id));
const modStream = int(b.moderation_stream_id) || null;
const modStream = int(b.moderation_stream_id) || null;
- const allowed = actor && !actor.is_banned && (permissions.isGlobalModOrAbove(actor) || (modStream && permissions.canModerateStream(actor, modStream)));
+ const allowed = actor && !actor.is_banned && (permissions.isGlobalModOrAbove(actor) || (modStream && await 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
// 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.
// acts on the stream they moderate, never on another channel's.
const staff = permissions.isGlobalModOrAbove(actor);
const staff = permissions.isGlobalModOrAbove(actor);
- if (!staff && (!streamId || !permissions.canModerateStream(actor, streamId))) return fail(res, 403, 'You do not have permission.');
+ if (!staff && (!streamId || !(await 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');
@@ -365,33 +361,17 @@ effectsRouter.post('/approve-ip', (req, res) => {
@@ -365,33 +361,17 @@ effectsRouter.post('/approve-ip', (req, res) => {
res.json({ ok: true });
res.json({ ok: true });
});
});
-// /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));
-});
-
-// Donation / goal alert sounds: the streamer's own channel only, files in the shared sounds dir.
-effectsRouter.post('/alert-sound', (req, res) => {
- const channelId = int(req.body?.channel_id);
- const channel = channelId ? db.getChannelById(channelId) : null;
- if (!channel || channel.user_id !== int(req.body?.actor_user_id)) return fail(res, 403, 'Not your channel');
- const url = req.body.url ? path.resolve(String(req.body.url)) : null;
- if (url) {
- // Compared through realpath: Chat and Live may reach the shared sounds dir via different symlinks.
- let inside = false;
- try { inside = path.dirname(fs.realpathSync(url)) === fs.realpathSync(path.resolve(config.sounds.path)); } catch { inside = false; }
- if (!inside) return fail(res, 400, 'alert sounds live in the sounds directory');
- }
- chatTables.write('setChannelAlertSound', channelId, req.body.kind === 'goal' ? 'goal' : 'donation', url, url ? String(req.body.mime || 'audio/mpeg') : null)
- .then(() => res.json({ ok: true }), (err) => fail(res, err.status || 500, err.message));
+// The channel's emote-source switches (channels.emote_sources, a Live-owned column): OpenVibe.Chat
+// persists nothing here itself, its PUT /api/emotes/sources asks Live to write the row.
+// (The /channel-settings and /alert-sound effects were retired with roadmap T3: Chat owns
+// channel_moderation_settings and writes it locally now.)
+effectsRouter.post('/channel-emote-sources', (req, res) => {
+ const userId = int(req.body?.user_id);
+ const sources = req.body?.sources;
+ if (!userId || !db.getUserById(userId)) return fail(res, 404, 'User not found');
+ if (!sources || typeof sources !== 'object' || Array.isArray(sources)) return fail(res, 400, 'sources object required');
+ db.updateChannel(userId, { emote_sources: JSON.stringify(sources) });
+ res.json({ ok: true });
});
});
effectsRouter.post('/ensure-channel', (req, res) => {
effectsRouter.post('/ensure-channel', (req, res) => {
@@ -501,7 +481,7 @@ effectsRouter.post('/media-queue', async (req, res) => {
@@ -501,7 +481,7 @@ effectsRouter.post('/media-queue', async (req, res) => {
if (b.op === 'skip') {
if (b.op === 'skip') {
const actor = actorOf(int(b.actorUserId));
const actor = actorOf(int(b.actorUserId));
const streamId = int(b.streamId) || null;
const streamId = int(b.streamId) || null;
- const allowed = actor && (actor.id === streamerId || permissions.isGlobalModOrAbove(actor) || (streamId && permissions.canModerateStream(actor, streamId)));
+ const allowed = actor && (actor.id === streamerId || permissions.isGlobalModOrAbove(actor) || (streamId && await permissions.canModerateStream(actor, streamId)));
if (!allowed) return fail(res, 403, 'Only the streamer or a moderator can skip media.');
if (!allowed) return fail(res, 403, 'Only the streamer or a moderator can skip media.');
const ended = mediaQueue.finishCurrent(streamerId, 'skipped');
const ended = mediaQueue.finishCurrent(streamerId, 'skipped');
const next = mediaQueue.startNext(streamerId);
const next = mediaQueue.startNext(streamerId);
@@ -633,10 +613,8 @@ effectsRouter.post('/asset-sync', (req, res) => {
@@ -633,10 +613,8 @@ effectsRouter.post('/asset-sync', (req, res) => {
// ── The read mirror of Chat's tables ──────────────────────────────
// ── The read mirror of Chat's tables ──────────────────────────────
// Same tables, same ids. Upserts set only the columns Live's table has (Chat's *subject_id
// Same tables, same ids. Upserts set only the columns Live's table has (Chat's *subject_id
// columns are skipped; Live-only columns such as channel_sounds.media_asset_id are kept).
// columns are skipped; Live-only columns such as channel_sounds.media_asset_id are kept).
-// A staged table (roadmap C-04, chat-tables.js) is mirrored only while Chat writes it or hands it
-// back; while Live writes it, Chat's rows for it are refused. Its rows REPLACE: the authority's row
-// wins over whatever holds its key or one of its unique columns here (both copies have the same
-// columns, so nothing of Live's is lost).
+// The six staged tables (roadmap C-04) are Chat's now and are not mirrored here; only the C-02
+// twelve below still are.
const MIRROR_TABLES = {
const MIRROR_TABLES = {
chat_messages: ['id'],
chat_messages: ['id'],
dm_conversations: ['id'],
dm_conversations: ['id'],
@@ -667,16 +645,12 @@ function applyMirror(changes) {
@@ -667,16 +645,12 @@ function applyMirror(changes) {
try {
try {
d.transaction(() => {
d.transaction(() => {
for (const c of changes) {
for (const c of changes) {
- const staged = !!(c && chatTables.TABLES[c.table]);
- const pk = MIRROR_TABLES[c && c.table] || (staged && chatTables.acceptsMirror(c.table) ? chatTables.TABLES[c.table] : null);
- if (!pk) { skipped.push({ table: c && c.table, reason: staged ? 'Live writes this table (chat_table_authority live)' : 'not a mirrored table' }); continue; }
+ const pk = MIRROR_TABLES[c && c.table];
+ if (!pk) { skipped.push({ table: c && c.table, reason: 'not a mirrored table' }); continue; }
const have = liveColumns(c.table);
const have = liveColumns(c.table);
try {
try {
if (c.op === 'delete') {
if (c.op === 'delete') {
d.prepare(`DELETE FROM ${c.table} WHERE ${pk.map((k) => `${k} = ?`).join(' AND ')}`).run(...pk.map((k) => c.pk[k]));
d.prepare(`DELETE FROM ${c.table} WHERE ${pk.map((k) => `${k} = ?`).join(' AND ')}`).run(...pk.map((k) => c.pk[k]));
- } else if (c.op === 'upsert' && c.row && staged) {
- const cols = Object.keys(c.row).filter((k) => have.has(k) && /^[a-z_]+$/.test(k));
- d.prepare(`INSERT OR REPLACE INTO ${c.table} (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`).run(...cols.map((k) => c.row[k]));
} else if (c.op === 'upsert' && c.row) {
} else if (c.op === 'upsert' && c.row) {
const cols = Object.keys(c.row).filter((k) => have.has(k) && /^[a-z_]+$/.test(k));
const cols = Object.keys(c.row).filter((k) => have.has(k) && /^[a-z_]+$/.test(k));
const upd = cols.filter((k) => !pk.includes(k));
const upd = cols.filter((k) => !pk.includes(k));
new file mode 100644
new file mode 100644
@@ -0,0 +1,218 @@
@@ -0,0 +1,218 @@
+'use strict';
+/**
+ * OpenVibe.Chat's internal read API (roadmap T3; Contracts 0.80.0 `chat.moderation.read`).
+ *
+ * Chat owns the six tables that used to be staged in Live — channel_moderators,
+ * channel_moderation_settings, emotes, user_tags, chat_ai_summaries, chat_timeline_events — so
+ * Live keeps no copy. The few reads Live's own features still need go through here:
+ *
+ * getChannelModeration(channelId) → { settings, moderator_ids } (Chat's row, or Live's defaults)
+ * getChannelsByModerator(userId) → the channels a user moderates
+ * getEmoteCount(channelIdOrUserId) → a channel's emote count
+ *
+ * Auth: Live's Network service principal (OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET),
+ * client-credentials token for audience openvibe.chat with the ability `chat.moderation.read`
+ * (the same construction server/media-client.js uses).
+ *
+ * Every answer is cached 30 s (per channel / per user); invalidate(channelId) drops a channel's
+ * entries at once when Live's own UI changes it. A failure answers the safe default — "not a
+ * moderator", the moderation defaults, zero emotes — and logs once, never a 500 on the hot path.
+ * Off under LIVE_DRILL (a restore drill calls no other service) and without OV_OAUTH_CLIENT_SECRET.
+ */
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+const { CHAT_URL } = require('./chat-authority');
+
+const AUDIENCE = 'openvibe.chat';
+const CACHE_TTL_MS = 30_000;
+const TIMEOUT_MS = 5000;
+
+// Live's channel-moderation defaults (Chat answers the channel's row or these).
+const DEFAULT_SETTINGS = Object.freeze({
+ slow_mode_seconds: 0, followers_only: 0, emote_only: 0, allow_anonymous: 1, links_allowed: 1,
+ gifs_enabled: 1, account_age_gate_hours: 0, caps_percentage_limit: 0, aggressive_filter: 0,
+ max_message_length: 500, tts_max_length: 200, slur_filter_enabled: 0, slur_filter_use_builtin: 1,
+ slur_filter_terms: '', slur_filter_regexes: '', slur_filter_nudge_message: '', slur_filter_disabled_categories: '[]',
+ ip_approval_mode: 0, soundboard_enabled: 1, soundboard_allow_pitch: 1, soundboard_allow_speed: 1,
+ soundboard_banned_ids: '', viewer_auto_delete_enabled: 1, viewer_delete_all_enabled: 1,
+ custom_emotes_enabled: 1, custom_sounds_enabled: 1, max_sound_seconds: 10, uploads_mods_only: 0,
+ mods_can_edit_about: 0, emote_scale: 100, emote_size_min: 50, emote_size_max: 200, sounds_mods_only: 0,
+ sound_min_speed: 0.5, sound_max_speed: 3.0, sound_min_pitch_cents: -1200, sound_max_pitch_cents: 1200, sub_only: 0,
+});
+
+let tokens = null;
+let lastLog = null;
+const cache = new Map(); // key → { at, value }
+const inflight = new Map(); // key → Promise
+
+function drill() { try { return require('../drill').enabled; } catch { return false; } }
+/** True when Live can talk to Chat's internal API at all. */
+function enabled() { return Boolean(process.env.OV_OAUTH_CLIENT_SECRET) && !drill(); }
+
+function serviceTokens() {
+ if (tokens) return tokens;
+ const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';
+ if (!clientSecret) return null;
+ const networkInternalUrl = String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
+ tokens = createServiceTokenClient({
+ tokenUrl: `${networkInternalUrl}/oauth/token`,
+ clientId: process.env.OV_OAUTH_CLIENT_ID || 'live',
+ clientSecret,
+ audience: AUDIENCE,
+ });
+ return tokens;
+}
+
+function note(what, err) {
+ const m = `OpenVibe.Chat ${what}: ${(err && err.message) || err}`;
+ if (m === lastLog) return; // log once, until it changes or a read succeeds
+ lastLog = m;
+ console.warn(`[ChatRead] ${m}`);
+}
+
+async function api(path) {
+ const client = serviceTokens();
+ if (!client) throw new Error('not configured');
+ const token = await client.getToken();
+ const res = await fetch(`${CHAT_URL}${path}`, {
+ headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ });
+ if (!res.ok) throw new Error(`answered ${res.status}`);
+ const out = await res.json().catch(() => null);
+ if (!out || out.ok !== true) throw new Error(`refused (${(out && out.error) || 'bad answer'})`);
+ return out;
+}
+
+function cached(key) { const h = cache.get(key); return h && Date.now() - h.at < CACHE_TTL_MS ? h : null; }
+function remember(key, value) {
+ cache.set(key, { at: Date.now(), value });
+ if (cache.size > 2000) cache.delete(cache.keys().next().value);
+}
+
+function defaultModeration(channelId) {
+ return { settings: { ...DEFAULT_SETTINGS, channel_id: channelId || 0 }, moderator_ids: [] };
+}
+
+/** { settings, moderator_ids } for a channel (cached 30 s). */
+async function getChannelModeration(channelId) {
+ channelId = Number(channelId) || 0;
+ if (!channelId) return defaultModeration(0);
+ const key = `m:${channelId}`;
+ const hit = cached(key);
+ if (hit) return hit.value;
+ if (inflight.has(key)) return inflight.get(key);
+ const p = (async () => {
+ if (!enabled()) return defaultModeration(channelId);
+ try {
+ const out = await api(`/internal/moderation/channels/${channelId}`);
+ const settings = out.settings && typeof out.settings === 'object' ? { ...DEFAULT_SETTINGS, ...out.settings } : { ...DEFAULT_SETTINGS };
+ const value = {
+ settings: { ...settings, channel_id: channelId },
+ moderator_ids: Array.isArray(out.moderator_ids) ? out.moderator_ids.map(Number) : [],
+ };
+ lastLog = null;
+ remember(key, value);
+ return value;
+ } catch (err) {
+ note(`channel ${channelId}`, err);
+ const value = defaultModeration(channelId);
+ remember(key, value);
+ return value;
+ }
+ })().finally(() => inflight.delete(key));
+ inflight.set(key, p);
+ return p;
+}
+
+/** The channels a user moderates (cached 30 s); each row carries `id` = channel_id. */
+async function getChannelsByModerator(userId) {
+ userId = Number(userId) || 0;
+ if (!userId) return [];
+ const key = `u:${userId}`;
+ const hit = cached(key);
+ if (hit) return hit.value;
+ if (inflight.has(key)) return inflight.get(key);
+ const p = (async () => {
+ if (!enabled()) return [];
+ try {
+ const out = await api(`/internal/moderation/users/${userId}/channels`);
+ const value = (Array.isArray(out.channels) ? out.channels : []).map((c) => {
+ const id = Number(c.channel_id) || 0;
+ return { id, channel_id: id, title: c.title || null, user_id: c.owner_user_id != null ? Number(c.owner_user_id) : null, owner_username: c.owner_username || null };
+ });
+ lastLog = null;
+ remember(key, value);
+ return value;
+ } catch (err) {
+ note(`user ${userId} channels`, err);
+ remember(key, []);
+ return [];
+ }
+ })().finally(() => inflight.delete(key));
+ inflight.set(key, p);
+ return p;
+}
+
+/** A channel's emote count (a channels.id is resolved to its owner by Chat). */
+async function getEmoteCount(channelIdOrUserId) {
+ const id = Number(channelIdOrUserId) || 0;
+ if (!id) return 0;
+ const key = `e:${id}`;
+ const hit = cached(key);
+ if (hit) return hit.value;
+ if (inflight.has(key)) return inflight.get(key);
+ const p = (async () => {
+ if (!enabled()) return 0;
+ try {
+ const out = await api(`/internal/moderation/channels/${id}/emote-count`);
+ const n = Number(out.count) || 0;
+ lastLog = null;
+ remember(key, n);
+ return n;
+ } catch (err) {
+ note(`channel ${id} emote count`, err);
+ remember(key, 0);
+ return 0;
+ }
+ })().finally(() => inflight.delete(key));
+ inflight.set(key, p);
+ return p;
+}
+
+// ── Synchronous peeks (hot paths that cannot await) ─────────────
+// Return the cached answer, else the safe default and refresh in the background.
+
+function peekChannelModeration(channelId) {
+ const h = cached(`m:${Number(channelId) || 0}`);
+ if (h) return h.value;
+ getChannelModeration(channelId).catch(() => {});
+ return null;
+}
+function peekChannelsByModerator(userId) {
+ const h = cached(`u:${Number(userId) || 0}`);
+ if (h) return h.value;
+ getChannelsByModerator(userId).catch(() => {});
+ return null;
+}
+/** The cached settings for a channel, defaults when unknown (never blocks). */
+function settingsSync(channelId) {
+ const h = cached(`m:${Number(channelId) || 0}`);
+ return h ? h.value.settings : defaultModeration(channelId).settings;
+}
+
+/** Drop a channel's cached answer (Live changed it through its own UI — none after T3). */
+function invalidate(channelId) {
+ const id = Number(channelId) || 0;
+ if (!id) return;
+ cache.delete(`m:${id}`);
+ cache.delete(`e:${id}`);
+}
+
+/** Drop every cached answer and the token (tests and env flips). */
+function _reset() { cache.clear(); inflight.clear(); tokens = null; lastLog = null; }
+
+module.exports = {
+ enabled, getChannelModeration, getChannelsByModerator, getEmoteCount,
+ peekChannelModeration, peekChannelsByModerator, settingsSync, defaultModeration,
+ DEFAULT_SETTINGS, invalidate, _reset, AUDIENCE, CACHE_TTL_MS,
+};
@@ -171,8 +171,8 @@ router.post('/', requireAuth, soundUpload.single('sound'), async (req, res) => {
@@ -171,8 +171,8 @@ router.post('/', requireAuth, soundUpload.single('sound'), async (req, res) => {
return res.status(404).json({ error: 'Channel not found' });
return res.status(404).json({ error: 'Channel not found' });
}
}
- const settings = db.getChannelModerationSettings(channel.id);
- const isMod = permissions.canModerateChannel(req.user, channel.id);
+ const settings = (await require('./moderation-client').getChannelModeration(channel.id)).settings;
+ const isMod = await permissions.canModerateChannel(req.user, channel.id);
const isOwnChannel = channelOwnerId === req.user.id;
const isOwnChannel = channelOwnerId === req.user.id;
if (!isMod && !settings.custom_sounds_enabled) {
if (!isMod && !settings.custom_sounds_enabled) {
@@ -284,14 +284,14 @@ router.use((err, req, res, next) => {
@@ -284,14 +284,14 @@ router.use((err, req, res, next) => {
});
});
// ── Delete a sound ───────────────────────────────────────────
// ── Delete a sound ───────────────────────────────────────────
-router.delete('/:id', requireAuth, (req, res) => {
+router.delete('/:id', requireAuth, async (req, res) => {
try {
try {
const sound = db.getChannelSoundById(parseInt(req.params.id));
const sound = db.getChannelSoundById(parseInt(req.params.id));
if (!sound) return res.status(404).json({ error: 'Sound not found' });
if (!sound) return res.status(404).json({ error: 'Sound not found' });
let allowed = sound.created_by === req.user.id || permissions.can(req.user, 'staff.assets.manage');
let allowed = sound.created_by === req.user.id || permissions.can(req.user, 'staff.assets.manage');
if (!allowed) {
if (!allowed) {
const channel = db.getChannelByUserId(sound.channel_owner_id);
const channel = db.getChannelByUserId(sound.channel_owner_id);
- if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;
+ if (channel && await permissions.canModerateChannel(req.user, channel.id)) allowed = true;
}
}
if (!allowed) return res.status(403).json({ error: 'Not your sound' });
if (!allowed) return res.status(403).json({ error: 'Not your sound' });
@@ -310,7 +310,7 @@ router.delete('/:id', requireAuth, (req, res) => {
@@ -310,7 +310,7 @@ router.delete('/:id', requireAuth, (req, res) => {
// A command may hold several sounds, so edits apply to the whole group.
// A command may hold several sounds, so edits apply to the whole group.
// Allowed: channel owner, channel mods, admins, or the creator of every
// Allowed: channel owner, channel mods, admins, or the creator of every
// sound in the group (mirrors the add-to-command rule).
// sound in the group (mirrors the add-to-command rule).
-router.patch('/command', requireAuth, (req, res) => {
+router.patch('/command', requireAuth, async (req, res) => {
try {
try {
let channelOwnerId = parseInt(req.body.channel_id) || null;
let channelOwnerId = parseInt(req.body.channel_id) || null;
if (!channelOwnerId && req.body.stream_id) {
if (!channelOwnerId && req.body.stream_id) {
@@ -324,7 +324,7 @@ router.patch('/command', requireAuth, (req, res) => {
@@ -324,7 +324,7 @@ router.patch('/command', requireAuth, (req, res) => {
const group = (db.getChannelSounds(channelOwnerId) || []).filter((s) => s.command === command);
const group = (db.getChannelSounds(channelOwnerId) || []).filter((s) => s.command === command);
if (!group.length) return res.status(404).json({ error: `No sound command !${command} in this channel.` });
if (!group.length) return res.status(404).json({ error: `No sound command !${command} in this channel.` });
- const isMod = permissions.canModerateChannel(req.user, channel.id);
+ const isMod = await permissions.canModerateChannel(req.user, channel.id);
const isOwnChannel = channelOwnerId === req.user.id;
const isOwnChannel = channelOwnerId === req.user.id;
const isCreator = group.every((s) => s.created_by === req.user.id);
const isCreator = group.every((s) => s.created_by === req.user.id);
if (!isMod && !isOwnChannel && !isCreator && !permissions.can(req.user, 'staff.assets.manage')) {
if (!isMod && !isOwnChannel && !isCreator && !permissions.can(req.user, 'staff.assets.manage')) {
@@ -384,73 +384,9 @@ router.get('/file/:filename', (req, res) => {
@@ -384,73 +384,9 @@ router.get('/file/:filename', (req, res) => {
}
}
});
});
-// ── Streamer alert sounds (donation / goal-reached) ──────────────────────
-// Streamer-only. Stored on the channel's moderation settings; the file lives in the
-// sounds dir and is read server-side + broadcast as base64 on donation / goal events.
-function _alertKind(raw) { return raw === 'goal' ? 'goal' : 'donation'; }
-
-// GET which alert sounds are configured (+ a preview URL).
-router.get('/alert/mine', requireAuth, (req, res) => {
- const s = db.getChannelAlertSoundsByUser(req.user.id) || {};
- const toUrl = (p) => (p ? `/api/sounds/file/${path.basename(p)}` : null);
- res.json({
- donation: { set: !!s.donation_sound_url, url: toUrl(s.donation_sound_url) },
- goal: { set: !!s.goal_sound_url, url: toUrl(s.goal_sound_url) },
- });
-});
-
-// Upload/replace an alert sound. kind = 'donation' | 'goal'.
-router.post('/alert/:kind', requireAuth, soundUpload.single('sound'), async (req, res) => {
- const kind = _alertKind(req.params.kind);
- try {
- if (!req.file) return res.status(400).json({ error: 'No sound file' });
- const channel = db.getChannelByUserId(req.user.id) || db.ensureChannel(req.user.id);
- if (!channel) { fs.unlink(req.file.path, () => {}); return res.status(400).json({ error: 'No channel' }); }
-
- // Guard duration (reuse the channel's max-sound limit, capped at 15s for alerts).
- let duration = 0;
- try { duration = await probeDuration(req.file.path); } catch { /* */ }
- if (duration > 15.5) { fs.unlink(req.file.path, () => {}); return res.status(400).json({ error: `Alert sound too long (${duration.toFixed(1)}s). Max 15s.` }); }
-
- // Normalize to mp3 for universal playback; fall back to the original on failure.
- let finalPath = req.file.path;
- try {
- const mp3 = await convertToMp3(req.file.path);
- if (mp3) { fs.unlink(req.file.path, () => {}); finalPath = mp3; }
- } catch { /* keep original */ }
-
- // Remove the previous alert sound of this kind (best-effort).
- try {
- const prev = db.getChannelAlertSoundsByUser(req.user.id) || {};
- const prevPath = kind === 'goal' ? prev.goal_sound_url : prev.donation_sound_url;
- if (prevPath && fs.existsSync(prevPath) && path.resolve(prevPath) !== path.resolve(finalPath)) fs.unlink(prevPath, () => {});
- } catch { /* ignore */ }
-
- const ext = path.extname(finalPath).toLowerCase();
- const mime = EXT_TO_MIME[ext] || 'audio/mpeg';
- await require('./chat-tables').write('setChannelAlertSound', channel.id, kind, finalPath, mime);
- res.json({ set: true, kind, url: `/api/sounds/file/${path.basename(finalPath)}` });
- } catch (err) {
- if (req.file) fs.unlink(req.file.path, () => {});
- res.status(500).json({ error: 'Failed to save alert sound: ' + err.message });
- }
-});
-
-// Clear an alert sound.
-router.delete('/alert/:kind', requireAuth, async (req, res) => {
- const kind = _alertKind(req.params.kind);
- try {
- const channel = db.getChannelByUserId(req.user.id);
- if (channel) {
- const prev = db.getChannelAlertSoundsByUser(req.user.id) || {};
- const prevPath = kind === 'goal' ? prev.goal_sound_url : prev.donation_sound_url;
- if (prevPath && fs.existsSync(prevPath)) { try { fs.unlinkSync(prevPath); } catch { /* */ } }
- await require('./chat-tables').write('setChannelAlertSound', channel.id, kind, null, null);
- }
- res.json({ set: false, kind });
- } catch (err) {
- res.status(500).json({ error: 'Failed to clear alert sound' });
- }
-});
+// Streamer alert sounds (donation / goal-reached) moved to OpenVibe.Chat with the six tables
+// (roadmap T3): they live on channel_moderation_settings, which Chat owns, and nginx routes
+// /api/sounds there. Live's own sounds router keeps only the channel-sound commands above and
+// runs only when Live itself serves chat.
module.exports = router;
module.exports = router;
@@ -67,15 +67,8 @@ function ensureTagTables() {
@@ -67,15 +67,8 @@ function ensureTagTables() {
try { db.getDb().exec('CREATE INDEX IF NOT EXISTS idx_user_tags_user ON user_tags(user_id)'); } catch { /* */ }
try { db.getDb().exec('CREATE INDEX IF NOT EXISTS idx_user_tags_user ON user_tags(user_id)'); } catch { /* */ }
}
}
-/** Every tag a user owns. */
-function getUserTags(userId) {
- const rows = db.getDb().prepare('SELECT tag_id, source, granted_at FROM user_tags WHERE user_id = ?').all(userId);
- return rows.map((r) => ({
- ...r,
- ...(TAGS[r.tag_id] || { name: r.tag_id, emoji: '🏷️', color: '#999', bgColor: '#333', desc: 'Unknown tag', category: 'special', tier: 0 }),
- tagId: r.tag_id,
- }));
-}
+// getUserTags (the only reader of `user_tags`) went with roadmap T3: OpenVibe.Chat owns that table,
+// which had no production caller in Live. The equipped tag (user_equipped_tag) is not staged and stays.
/** The user's equipped tag, or null. */
/** The user's equipped tag, or null. */
function getEquippedTag(userId) {
function getEquippedTag(userId) {
@@ -95,4 +88,4 @@ function getAllTags() {
@@ -95,4 +88,4 @@ function getAllTags() {
return Object.entries(TAGS).map(([id, t]) => ({ tagId: id, ...t }));
return Object.entries(TAGS).map(([id, t]) => ({ tagId: id, ...t }));
}
}
-module.exports = { TAGS, ensureTagTables, getUserTags, getEquippedTag, getTagProfile, getAllTags };
+module.exports = { TAGS, ensureTagTables, getEquippedTag, getTagProfile, getAllTags };
@@ -4145,7 +4145,11 @@ function _computeHomeStats() {
@@ -4145,7 +4145,11 @@ function _computeHomeStats() {
users: c(`SELECT COUNT(*) AS count FROM users WHERE COALESCE(is_banned, 0) = 0`),
users: c(`SELECT COUNT(*) AS count FROM users WHERE COALESCE(is_banned, 0) = 0`),
anons: c(`SELECT COUNT(*) AS count FROM anon_ip_mappings`),
anons: c(`SELECT COUNT(*) AS count FROM anon_ip_mappings`),
follows: c(`SELECT COUNT(*) AS count FROM follows`),
follows: c(`SELECT COUNT(*) AS count FROM follows`),
- emotes: c(`SELECT COUNT(*) AS count FROM emotes`),
+ // No platform-wide emote total: OpenVibe.Chat owns `emotes` (roadmap T3) and exposes counts
+ // per channel only (server/chat/moderation-client.js getEmoteCount), so there is nothing but
+ // the old table to answer a site-wide number. null (as the other external figures above); the
+ // key stays for the mixed-version window (test/n-1.test.js: the old client reads stats.emotes).
+ emotes: null,
pastes: null,
pastes: null,
aiMemories: c(`SELECT COUNT(*) AS count FROM stream_memories`),
aiMemories: c(`SELECT COUNT(*) AS count FROM stream_memories`),
pasteImages: null,
pasteImages: null,
@@ -313,7 +313,7 @@ router.post('/', requireAuth, emoteUpload.single('image'), async (req, res) => {
@@ -313,7 +313,7 @@ router.post('/', requireAuth, emoteUpload.single('image'), async (req, res) => {
return res.status(404).json({ error: 'Channel not found' });
return res.status(404).json({ error: 'Channel not found' });
}
}
const settings = db.getChannelModerationSettings(channel.id);
const settings = db.getChannelModerationSettings(channel.id);
- const isMod = permissions.canModerateChannel(req.user, channel.id);
+ const isMod = await permissions.canModerateChannel(req.user, channel.id);
if (!settings.custom_emotes_enabled && !isMod) {
if (!settings.custom_emotes_enabled && !isMod) {
fs.unlinkSync(req.file.path);
fs.unlinkSync(req.file.path);
return res.status(403).json({ error: 'This streamer has disabled viewer emote uploads.' });
return res.status(403).json({ error: 'This streamer has disabled viewer emote uploads.' });
@@ -425,7 +425,7 @@ router.patch('/:id', requireAuth, async (req, res) => {
@@ -425,7 +425,7 @@ router.patch('/:id', requireAuth, async (req, res) => {
if (!allowed) {
if (!allowed) {
const ownerId = emote.channel_owner_id || emote.user_id;
const ownerId = emote.channel_owner_id || emote.user_id;
const channel = db.getChannelByUserId(ownerId);
const channel = db.getChannelByUserId(ownerId);
- if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;
+ if (channel && await permissions.canModerateChannel(req.user, channel.id)) allowed = true;
}
}
if (!allowed) return res.status(403).json({ error: 'Not your emote' });
if (!allowed) return res.status(403).json({ error: 'Not your emote' });
@@ -483,7 +483,7 @@ router.delete('/:id', requireAuth, async (req, res) => {
@@ -483,7 +483,7 @@ router.delete('/:id', requireAuth, async (req, res) => {
if (!allowed) {
if (!allowed) {
const ownerId = emote.channel_owner_id || emote.user_id;
const ownerId = emote.channel_owner_id || emote.user_id;
const channel = db.getChannelByUserId(ownerId);
const channel = db.getChannelByUserId(ownerId);
- if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;
+ if (channel && await permissions.canModerateChannel(req.user, channel.id)) allowed = true;
}
}
if (!allowed) {
if (!allowed) {
return res.status(403).json({ error: 'Not your emote' });
return res.status(403).json({ error: 'Not your emote' });
@@ -1,13 +1,14 @@
@@ -1,13 +1,14 @@
/**
/**
- * asset-sync.js — mirror Live's chat assets (emotes + channel sounds) into
- * OpenVibe.Media, their canonical public home (served at /a/:id, browsable on
- * the media index with uploader + channel attribution).
+ * asset-sync.js — mirror Live's channel sounds into OpenVibe.Media, their canonical public home
+ * (served at /a/:id, browsable on the media index with uploader + channel attribution).
*
*
- * Local files remain the low-latency working copy (chat playback reads disk);
- * this job uploads anything Media doesn't have yet and records media_url /
- * media_asset_id on the local row, so emote <img> URLs come from Media's
- * long-cached endpoint and deletes propagate. Idempotent: rows are skipped
- * once media_asset_id is set, and Media upserts on (kind, name, channel).
+ * Local files remain the low-latency working copy (chat playback reads disk); this job uploads
+ * anything Media doesn't have yet and records media_url / media_asset_id on the local row, so
+ * sound URLs come from Media's long-cached endpoint and deletes propagate. Idempotent: rows are
+ * skipped once media_asset_id is set, and Media upserts on (kind, name, channel).
+ *
+ * The emote backfill went with roadmap T3: OpenVibe.Chat owns `emotes` and uploads emote bytes to
+ * Media with its own token, so Live no longer has emote rows to sync.
*/
*/
'use strict';
'use strict';
@@ -17,7 +18,6 @@ const db = require('../db/database');
@@ -17,7 +18,6 @@ const db = require('../db/database');
const media = require('../media-client');
const media = require('../media-client');
const paths = require('../paths');
const paths = require('../paths');
-const EMOTE_DIR = paths.dir('EMOTES_PATH', 'emotes');
const SOUND_DIR = paths.dir('SOUNDS_PATH', 'sounds');
const SOUND_DIR = paths.dir('SOUNDS_PATH', 'sounds');
const MIME_BY_EXT = {
const MIME_BY_EXT = {
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
@@ -27,7 +27,6 @@ const MIME_BY_EXT = {
@@ -27,7 +27,6 @@ const MIME_BY_EXT = {
function _ensureColumns() {
function _ensureColumns() {
for (const [table, col, type] of [
for (const [table, col, type] of [
- ['emotes', 'media_url', 'TEXT'], ['emotes', 'media_asset_id', 'INTEGER'],
['channel_sounds', 'media_url', 'TEXT'], ['channel_sounds', 'media_asset_id', 'INTEGER'],
['channel_sounds', 'media_url', 'TEXT'], ['channel_sounds', 'media_asset_id', 'INTEGER'],
]) {
]) {
try { db.getDb().exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`); } catch { /* exists */ }
try { db.getDb().exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`); } catch { /* exists */ }
@@ -66,21 +65,6 @@ async function syncAll() {
@@ -66,21 +65,6 @@ async function syncAll() {
};
};
let synced = 0, failed = 0;
let synced = 0, failed = 0;
- for (const e of db.all('SELECT * FROM emotes WHERE media_asset_id IS NULL')) {
- const f = _localFile(EMOTE_DIR, e.url);
- if (!f) continue;
- try {
- const asset = await _upload({
- kind: 'emote', name: e.code, filePath: f,
- user_id: e.user_id, username: uname(e.user_id),
- channel_username: uname(e.channel_owner_id || e.user_id),
- meta: { animated: !!e.animated, is_global: !!e.is_global },
- });
- // emotes is a staged chat table: written where its authority is (chat/chat-tables.js).
- if (asset) { await require('../chat/chat-tables').write('setEmoteMedia', e.id, asset.url, asset.id); synced++; }
- } catch (err) { failed++; if (failed <= 3) console.warn('[AssetSync] emote', e.code, err.message); }
- }
-
for (const s of db.all('SELECT * FROM channel_sounds WHERE media_asset_id IS NULL')) {
for (const s of db.all('SELECT * FROM channel_sounds WHERE media_asset_id IS NULL')) {
const f = _localFile(SOUND_DIR, s.url);
const f = _localFile(SOUND_DIR, s.url);
if (!f) continue;
if (!f) continue;
@@ -97,7 +97,7 @@ async function canActorModerateClip(actor, clip) {
@@ -97,7 +97,7 @@ async function canActorModerateClip(actor, clip) {
if (actor.id === clip.user_id) return true;
if (actor.id === clip.user_id) return true;
const ownerId = await clipChannelOwnerId(clip);
const ownerId = await clipChannelOwnerId(clip);
if (ownerId && actor.id === ownerId) return true;
if (ownerId && actor.id === ownerId) return true;
- if (ownerId) { const ch = db.getChannelByUserId(ownerId); if (ch && db.isChannelModerator(actor.id, ch.id)) return true; }
+ if (ownerId) { const ch = db.getChannelByUserId(ownerId); if (ch && await permissions.isChannelMod(actor, ch.id)) return true; }
const clipOwner = clip.user_id ? db.getUserById(clip.user_id) : null;
const clipOwner = clip.user_id ? db.getUserById(clip.user_id) : null;
const streamOwner = ownerId ? db.getUserById(ownerId) : null;
const streamOwner = ownerId ? db.getUserById(ownerId) : null;
return permissions.canModerateContentOwner(actor, clipOwner) &&
return permissions.canModerateContentOwner(actor, clipOwner) &&
@@ -111,7 +111,7 @@ async function canActorDeleteClip(actor, clip) {
@@ -111,7 +111,7 @@ async function canActorDeleteClip(actor, clip) {
if (!ownerId && actor.id === clip.user_id) return true;
if (!ownerId && actor.id === clip.user_id) return true;
if (ownerId) {
if (ownerId) {
const ch = db.getChannelByUserId(ownerId);
const ch = db.getChannelByUserId(ownerId);
- if (ch && db.isChannelModerator(actor.id, ch.id)) return true;
+ if (ch && await permissions.isChannelMod(actor, ch.id)) return true;
if (actor.id === clip.user_id && ch && ch.clips_allow_creator_delete) return true;
if (actor.id === clip.user_id && ch && ch.clips_allow_creator_delete) return true;
}
}
const clipOwner = clip.user_id ? db.getUserById(clip.user_id) : null;
const clipOwner = clip.user_id ? db.getUserById(clip.user_id) : null;
@@ -315,7 +315,11 @@ router.put('/:id/visibility', requireAuth, async (req, res) => {
@@ -315,7 +315,11 @@ router.put('/:id/visibility', requireAuth, async (req, res) => {
// Not the clipper: a viewer's clip inherits the channel's default visibility, and whoever made it
// Not the clipper: a viewer's clip inherits the channel's default visibility, and whoever made it
// must not be able to override the streamer's choice. Channel owner, channel mods, staff.
// must not be able to override the streamer's choice. Channel owner, channel mods, staff.
const ownerId = await clipChannelOwnerId(clip);
const ownerId = await clipChannelOwnerId(clip);
- const canByChannel = ownerId && (req.user.id === ownerId || (() => { const ch = db.getChannelByUserId(ownerId); return !!(ch && db.isChannelModerator(req.user.id, ch.id)); })());
+ let canByChannel = false;
+ if (ownerId) {
+ if (req.user.id === ownerId) canByChannel = true;
+ else { const ch = db.getChannelByUserId(ownerId); if (ch) canByChannel = await permissions.isChannelMod(req.user, ch.id); }
+ }
const canByStaff = !canByChannel && req.user.id !== clip.user_id && await canActorModerateClip(req.user, clip);
const canByStaff = !canByChannel && req.user.id !== clip.user_id && await canActorModerateClip(req.user, clip);
const ownClipNoChannel = !ownerId && req.user.id === clip.user_id;
const ownClipNoChannel = !ownerId && req.user.id === clip.user_id;
if (!(canByChannel || canByStaff || ownClipNoChannel)) return refuse(req, res, clip, 'Only the streamer can change clip visibility');
if (!(canByChannel || canByStaff || ownClipNoChannel)) return refuse(req, res, clip, 'Only the streamer can change clip visibility');
@@ -18,7 +18,7 @@ const permissions = require('../auth/permissions');
@@ -18,7 +18,7 @@ const permissions = require('../auth/permissions');
*
*
* Returns the streamer's user id, or throws an Error whose message is viewer-safe.
* Returns the streamer's user id, or throws an Error whose message is viewer-safe.
*/
*/
-function resolveQueueTarget(req) {
+async function resolveQueueTarget(req) {
const explicit = cleanInt(req.body?.streamer ?? req.query?.streamer, null);
const explicit = cleanInt(req.body?.streamer ?? req.query?.streamer, null);
const uname = req.body?.channelUsername || req.query?.channelUsername;
const uname = req.body?.channelUsername || req.query?.channelUsername;
@@ -28,7 +28,7 @@ function resolveQueueTarget(req) {
@@ -28,7 +28,7 @@ function resolveQueueTarget(req) {
if (streamerId === req.user.id) return streamerId;
if (streamerId === req.user.id) return streamerId;
const channel = db.getChannelByUserId(streamerId);
const channel = db.getChannelByUserId(streamerId);
- if (!channel || !permissions.canModerateChannel(req.user, channel.id)) {
+ if (!channel || !(await permissions.canModerateChannel(req.user, channel.id))) {
const err = new Error('You do not have permission to manage this queue');
const err = new Error('You do not have permission to manage this queue');
err.status = 403;
err.status = 403;
throw err;
throw err;
@@ -37,11 +37,11 @@ function resolveQueueTarget(req) {
@@ -37,11 +37,11 @@ function resolveQueueTarget(req) {
}
}
/** Owner-or-mod check for a channel identified by its streamer's user id. */
/** Owner-or-mod check for a channel identified by its streamer's user id. */
-function canManageChannel(user, streamerId) {
+async function canManageChannel(user, streamerId) {
if (!user || !streamerId) return false;
if (!user || !streamerId) return false;
if (user.id === streamerId) return true;
if (user.id === streamerId) return true;
const channel = db.getChannelByUserId(streamerId);
const channel = db.getChannelByUserId(streamerId);
- return !!channel && permissions.canModerateChannel(user, channel.id);
+ return !!channel && await permissions.canModerateChannel(user, channel.id);
}
}
function cleanInt(value, fallback) {
function cleanInt(value, fallback) {
@@ -49,7 +49,7 @@ function cleanInt(value, fallback) {
@@ -49,7 +49,7 @@ function cleanInt(value, fallback) {
return Number.isFinite(num) ? num : fallback;
return Number.isFinite(num) ? num : fallback;
}
}
-router.get('/channel/:username', optionalAuth, (req, res) => {
+router.get('/channel/:username', optionalAuth, async (req, res) => {
try {
try {
const user = db.getUserByUsername(req.params.username);
const user = db.getUserByUsername(req.params.username);
if (!user) return res.status(404).json({ error: 'Channel not found' });
if (!user) return res.status(404).json({ error: 'Channel not found' });
@@ -75,7 +75,7 @@ router.get('/channel/:username', optionalAuth, (req, res) => {
@@ -75,7 +75,7 @@ router.get('/channel/:username', optionalAuth, (req, res) => {
is_owner: !!req.user && req.user.id === user.id,
is_owner: !!req.user && req.user.id === user.id,
// Drives whether the channel's media tab renders queue controls. Mods get the
// Drives whether the channel's media tab renders queue controls. Mods get the
// same controls as the streamer; the server re-checks on every action.
// same controls as the streamer; the server re-checks on every action.
- can_manage: canManageChannel(req.user, user.id),
+ can_manage: await canManageChannel(req.user, user.id),
pricing: (() => {
pricing: (() => {
const st = mediaQueue.getSettings(user.id);
const st = mediaQueue.getSettings(user.id);
const currency = mediaQueue.currencyOf(st);
const currency = mediaQueue.currencyOf(st);
@@ -255,18 +255,18 @@ router.post('/request', requireAuth, async (req, res) => {
@@ -255,18 +255,18 @@ router.post('/request', requireAuth, async (req, res) => {
}
}
});
});
-router.post('/start', requireAuth, (req, res) => {
+router.post('/start', requireAuth, async (req, res) => {
try {
try {
- const request = mediaQueue.startNext(resolveQueueTarget(req));
+ const request = mediaQueue.startNext(await resolveQueueTarget(req));
res.json({ request });
res.json({ request });
} catch (err) {
} catch (err) {
res.status(err.status || 400).json({ error: err.message || 'Failed to start next request' });
res.status(err.status || 400).json({ error: err.message || 'Failed to start next request' });
}
}
});
});
-router.post('/advance', requireAuth, (req, res) => {
+router.post('/advance', requireAuth, async (req, res) => {
try {
try {
- const target = resolveQueueTarget(req);
+ const target = await resolveQueueTarget(req);
const ended = mediaQueue.finishCurrent(target, req.body.status === 'skipped' ? 'skipped' : 'played');
const ended = mediaQueue.finishCurrent(target, req.body.status === 'skipped' ? 'skipped' : 'played');
const next = mediaQueue.startNext(target);
const next = mediaQueue.startNext(target);
res.json({ ended, next });
res.json({ ended, next });
@@ -275,9 +275,9 @@ router.post('/advance', requireAuth, (req, res) => {
@@ -275,9 +275,9 @@ router.post('/advance', requireAuth, (req, res) => {
}
}
});
});
-router.post('/queue/:id/play', requireAuth, (req, res) => {
+router.post('/queue/:id/play', requireAuth, async (req, res) => {
try {
try {
- const target = resolveQueueTarget(req);
+ const target = await resolveQueueTarget(req);
if (db.getActiveMediaRequestByStreamer(target)) {
if (db.getActiveMediaRequestByStreamer(target)) {
return res.status(400).json({ error: 'Finish or skip the current item first' });
return res.status(400).json({ error: 'Finish or skip the current item first' });
}
}
@@ -292,28 +292,28 @@ router.post('/queue/:id/play', requireAuth, (req, res) => {
@@ -292,28 +292,28 @@ router.post('/queue/:id/play', requireAuth, (req, res) => {
}
}
});
});
-router.post('/queue/:id/skip', requireAuth, (req, res) => {
+router.post('/queue/:id/skip', requireAuth, async (req, res) => {
try {
try {
- const request = mediaQueue.skip(resolveQueueTarget(req), cleanInt(req.params.id, 0));
+ const request = mediaQueue.skip(await resolveQueueTarget(req), cleanInt(req.params.id, 0));
res.json({ request });
res.json({ request });
} catch (err) {
} catch (err) {
res.status(err.status || 400).json({ error: err.message || 'Failed to skip request' });
res.status(err.status || 400).json({ error: err.message || 'Failed to skip request' });
}
}
});
});
-router.post('/queue/:id/move', requireAuth, (req, res) => {
+router.post('/queue/:id/move', requireAuth, async (req, res) => {
try {
try {
const direction = req.body.direction === 'down' ? 'down' : 'up';
const direction = req.body.direction === 'down' ? 'down' : 'up';
- const request = mediaQueue.move(resolveQueueTarget(req), cleanInt(req.params.id, 0), direction);
+ const request = mediaQueue.move(await resolveQueueTarget(req), cleanInt(req.params.id, 0), direction);
res.json({ request });
res.json({ request });
} catch (err) {
} catch (err) {
res.status(err.status || 400).json({ error: err.message || 'Failed to reorder request' });
res.status(err.status || 400).json({ error: err.message || 'Failed to reorder request' });
}
}
});
});
-router.delete('/queue/:id', requireAuth, (req, res) => {
+router.delete('/queue/:id', requireAuth, async (req, res) => {
try {
try {
- const request = mediaQueue.skip(resolveQueueTarget(req), cleanInt(req.params.id, 0));
+ const request = mediaQueue.skip(await resolveQueueTarget(req), cleanInt(req.params.id, 0));
res.json({ request });
res.json({ request });
} catch (err) {
} catch (err) {
res.status(err.status || 400).json({ error: err.message || 'Failed to remove request' });
res.status(err.status || 400).json({ error: err.message || 'Failed to remove request' });
@@ -346,7 +346,7 @@ router.get('/queue/:id/stream-url', optionalAuth, async (req, res) => {
@@ -346,7 +346,7 @@ router.get('/queue/:id/stream-url', optionalAuth, async (req, res) => {
download_status: 'failed',
download_status: 'failed',
// Raw yt-dlp output (hosts, ports, HTTP errors) is for the channel's managers; anyone
// Raw yt-dlp output (hosts, ports, HTTP errors) is for the channel's managers; anyone
// else gets a generic reason instead of an oracle for probing what the server can reach.
// else gets a generic reason instead of an oracle for probing what the server can reach.
- last_error: canManageChannel(req.user, request.streamer_id) ? request.last_error : (request.last_error ? 'That link could not be played' : null),
+ last_error: await canManageChannel(req.user, request.streamer_id) ? request.last_error : (request.last_error ? 'That link could not be played' : null),
});
});
}
}
@@ -367,12 +367,12 @@ router.get('/queue/:id/stream-url', optionalAuth, async (req, res) => {
@@ -367,12 +367,12 @@ router.get('/queue/:id/stream-url', optionalAuth, async (req, res) => {
// ── Playback position save (called periodically by player) ──
// ── Playback position save (called periodically by player) ──
// The player page sends the session cookie (sendBeacon cannot set headers), so the same
// The player page sends the session cookie (sendBeacon cannot set headers), so the same
// manager check as /fail applies: nobody else can move a channel's resume point.
// manager check as /fail applies: nobody else can move a channel's resume point.
-router.post('/queue/:id/position', requireAuth, (req, res) => {
+router.post('/queue/:id/position', requireAuth, async (req, res) => {
try {
try {
const requestId = cleanInt(req.params.id, 0);
const requestId = cleanInt(req.params.id, 0);
const request = db.getMediaRequestById(requestId);
const request = db.getMediaRequestById(requestId);
if (!request) return res.status(404).json({ error: 'Request not found' });
if (!request) return res.status(404).json({ error: 'Request not found' });
- if (!canManageChannel(req.user, request.streamer_id)) return res.status(403).json({ error: 'Forbidden' });
+ if (!(await canManageChannel(req.user, request.streamer_id))) return res.status(403).json({ error: 'Forbidden' });
const position = Number(req.body.position);
const position = Number(req.body.position);
if (!Number.isFinite(position) || position < 0) {
if (!Number.isFinite(position) || position < 0) {
return res.status(400).json({ error: 'Invalid position' });
return res.status(400).json({ error: 'Invalid position' });
@@ -389,7 +389,7 @@ router.post('/queue/:id/refund', requireAuth, async (req, res) => {
@@ -389,7 +389,7 @@ router.post('/queue/:id/refund', requireAuth, async (req, res) => {
try {
try {
const request = db.getMediaRequestById(cleanInt(req.params.id, 0));
const request = db.getMediaRequestById(cleanInt(req.params.id, 0));
if (!request) return res.status(404).json({ error: 'Request not found' });
if (!request) return res.status(404).json({ error: 'Request not found' });
- if (!canManageChannel(req.user, request.streamer_id)) {
+ if (!(await canManageChannel(req.user, request.streamer_id))) {
return res.status(403).json({ error: 'Only the channel owner or its mods can issue refunds' });
return res.status(403).json({ error: 'Only the channel owner or its mods can issue refunds' });
}
}
const amount = await mediaQueue.refund(request.id);
const amount = await mediaQueue.refund(request.id);
@@ -400,11 +400,11 @@ router.post('/queue/:id/refund', requireAuth, async (req, res) => {
@@ -400,11 +400,11 @@ router.post('/queue/:id/refund', requireAuth, async (req, res) => {
});
});
// ── Report playback failure (auto-refunds) ──────────────────
// ── Report playback failure (auto-refunds) ──────────────────
-router.post('/queue/:id/fail', requireAuth, (req, res) => {
+router.post('/queue/:id/fail', requireAuth, async (req, res) => {
try {
try {
const request = db.getMediaRequestById(cleanInt(req.params.id, 0));
const request = db.getMediaRequestById(cleanInt(req.params.id, 0));
if (!request) return res.status(404).json({ error: 'Request not found' });
if (!request) return res.status(404).json({ error: 'Request not found' });
- if (!canManageChannel(req.user, request.streamer_id)) {
+ if (!(await canManageChannel(req.user, request.streamer_id))) {
return res.status(403).json({ error: 'Only the channel owner or its mods can report failures' });
return res.status(403).json({ error: 'Only the channel owner or its mods can report failures' });
}
}
const failed = mediaQueue.failRequest(request.id, req.body.error || 'Playback failed');
const failed = mediaQueue.failRequest(request.id, req.body.error || 'Playback failed');
@@ -1,65 +1,25 @@
@@ -1,65 +1,25 @@
/**
/**
* alerts.js — streamer alert sounds for donations / donation-goal-reached events.
* alerts.js — streamer alert sounds for donations / donation-goal-reached events.
*
*
- * The streamer uploads a sound (via chat → Settings); we read it server-side and
- * broadcast it as base64 `soundboard-audio` so the existing client audio queue
- * (playTTSAudio) plays it for every viewer, gated on their Chat Sounds toggle.
- * A goal-reached event uses the goal-override sound if set, else the donation sound.
+ * The sound mapping lives on channel_moderation_settings, which OpenVibe.Chat owns and reads locally
+ * (roadmap T3), so Live no longer reads the file or broadcasts base64 itself. It asks Chat to play the
+ * alert through the chat bridge — op `playAlertSound [streamerId, streamId, kind]` — and Chat resolves
+ * the sound from its own settings row, reads the shared sounds file and broadcasts the
+ * `soundboard-audio` message to the channel room. In production CHAT_AUTHORITY=chat, so `chatServer`
+ * is the chat-remote proxy (server/chat/chat-remote.js) and the call is queued to Chat like every
+ * other push. A goal-reached event is `kind: 'goal'` (Chat falls back to the donation sound).
*/
*/
'use strict';
'use strict';
-const fs = require('fs');
-const path = require('path');
-const db = require('../db/database');
-
-const MAX_SOUND_BYTES = 3 * 1024 * 1024; // 3MB — alert clips are short
-
-function _mimeForExt(ext) {
- switch ((ext || '').toLowerCase()) {
- case '.wav': return 'audio/wav';
- case '.ogg': case '.oga': return 'audio/ogg';
- case '.opus': return 'audio/opus';
- case '.webm': return 'audio/webm';
- case '.m4a': case '.mp4': return 'audio/mp4';
- case '.aac': return 'audio/aac';
- case '.flac': return 'audio/flac';
- default: return 'audio/mpeg';
- }
-}
-
-function _readSound(diskPath, mimeHint) {
- try {
- if (!diskPath || !fs.existsSync(diskPath)) return null;
- const buf = fs.readFileSync(diskPath);
- if (!buf || !buf.length || buf.length > MAX_SOUND_BYTES) return null;
- return { audio: buf.toString('base64'), mimeType: mimeHint || _mimeForExt(path.extname(diskPath)) };
- } catch { return null; }
-}
/**
/**
- * Broadcast a streamer's alert sound to their viewers (channel-wide, so it reaches
- * every slot + offline chat). kind: 'donation' | 'goal'. No-op if none configured.
+ * Ask Chat to play a streamer's alert sound to their viewers (channel-wide, so it reaches every slot
+ * plus the offline room). kind: 'donation' | 'goal'. No-op if Chat is not the authority or nothing is
+ * configured. Chat answers `played:false` (never an error) when the channel has no sound.
*/
*/
function playAlertSound(chatServer, streamerId, streamId, kind) {
function playAlertSound(chatServer, streamerId, streamId, kind) {
try {
try {
- if (!chatServer || !chatServer.broadcastToChannelRoom) return;
- const s = db.getChannelAlertSoundsByUser(streamerId) || {};
- let disk = null, mime = null;
- if (kind === 'goal') {
- // Goal-reached uses the override sound if set, otherwise the donation sound.
- disk = s.goal_sound_url || s.donation_sound_url;
- mime = s.goal_sound_url ? s.goal_sound_mime : s.donation_sound_mime;
- } else {
- disk = s.donation_sound_url;
- mime = s.donation_sound_mime;
- }
- const snd = _readSound(disk, mime);
- if (!snd) return;
- chatServer.broadcastToChannelRoom(streamerId, streamId || null, {
- type: 'soundboard-audio',
- audio: snd.audio,
- mimeType: snd.mimeType,
- source: kind === 'goal' ? 'goal-alert' : 'donation-alert',
- });
+ if (!chatServer || typeof chatServer.playAlertSound !== 'function') return;
+ chatServer.playAlertSound(streamerId, streamId, kind === 'goal' ? 'goal' : 'donation');
} catch { /* non-critical */ }
} catch { /* non-critical */ }
}
}
@@ -345,7 +345,7 @@ class CallServer {
@@ -345,7 +345,7 @@ class CallServer {
if (permissions.can(user, 'staff.moderation.calls')) return true;
if (permissions.can(user, 'staff.moderation.calls')) return true;
const ch = this.channels.get(channelId);
const ch = this.channels.get(channelId);
if (ch?.createdBy === user.id) return true;
if (ch?.createdBy === user.id) return true;
- if (ch?.streamId) return permissions.canModerateCall(user, ch.streamId);
+ if (ch?.streamId) return permissions.canModerateCallSync(user, ch.streamId);
return false;
return false;
}
}
@@ -35,6 +35,8 @@ const i18n = require('../i18n/translate');
@@ -35,6 +35,8 @@ const i18n = require('../i18n/translate');
const robotStreamerService = require('../integrations/robotstreamer-service');
const robotStreamerService = require('../integrations/robotstreamer-service');
const chatRelayService = require('../integrations/chat-relay-service');
const chatRelayService = require('../integrations/chat-relay-service');
const chatServer = require('../chat/chat-server');
const chatServer = require('../chat/chat-server');
+// The six chat tables are OpenVibe.Chat's (roadmap T3); the few reads here go through its client.
+const moderation = require('../chat/moderation-client');
// Stream voice channels: Live's call server, or OpenVibe.Chat's with CALLS_AUTHORITY=chat.
// Stream voice channels: Live's call server, or OpenVibe.Chat's with CALLS_AUTHORITY=chat.
const callsAuthority = require('./calls-authority');
const callsAuthority = require('./calls-authority');
const { sanitizeOfflineHtml, sanitizeOfflineCss } = require('./offline-html-sanitize');
const { sanitizeOfflineHtml, sanitizeOfflineCss } = require('./offline-html-sanitize');
@@ -475,13 +477,18 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
@@ -475,13 +477,18 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
// show the pencil edit button to the right people.
// show the pencil edit button to the right people.
let modsCanEditAbout = false;
let modsCanEditAbout = false;
let _modSettings = {};
let _modSettings = {};
- try { _modSettings = db.getChannelModerationSettings(channel.id) || {}; modsCanEditAbout = !!_modSettings.mods_can_edit_about; } catch { /* default off */ }
+ let viewerIsChannelMod = false;
+ try {
+ const m = await moderation.getChannelModeration(channel.id);
+ _modSettings = m.settings || {};
+ modsCanEditAbout = !!_modSettings.mods_can_edit_about;
+ viewerIsChannelMod = !!(req.user && m.moderator_ids.includes(Number(req.user.id)));
+ } catch { /* default off */ }
// Public chat limits so the client can cap the input + truncate TTS to the streamer's max.
// Public chat limits so the client can cap the input + truncate TTS to the streamer's max.
publicChannel.chat_limits = {
publicChannel.chat_limits = {
max_message_length: Math.max(1, Number(_modSettings.max_message_length) || 500),
max_message_length: Math.max(1, Number(_modSettings.max_message_length) || 500),
tts_max_length: Math.max(10, Number(_modSettings.tts_max_length) || 200),
tts_max_length: Math.max(10, Number(_modSettings.tts_max_length) || 200),
};
};
- const viewerIsChannelMod = !!(req.user && db.isChannelModerator(req.user.id, channel.id));
publicChannel.mods_can_edit_about = modsCanEditAbout;
publicChannel.mods_can_edit_about = modsCanEditAbout;
publicChannel.viewer_can_edit_about = !!(isOwner || (modsCanEditAbout && viewerIsChannelMod));
publicChannel.viewer_can_edit_about = !!(isOwner || (modsCanEditAbout && viewerIsChannelMod));
// Social links (server/social/links.js): saved links plus connected restream platforms. Editors also get
// Social links (server/social/links.js): saved links plus connected restream platforms. Editors also get
@@ -818,15 +825,19 @@ router.get('/channel/:username/bio-en', async (req, res) => {
@@ -818,15 +825,19 @@ router.get('/channel/:username/bio-en', async (req, res) => {
// Editable by the channel owner, and by channel moderators when the streamer has
// Editable by the channel owner, and by channel moderators when the streamer has
// enabled `mods_can_edit_about`. Targets the channel by username so a mod writes
// enabled `mods_can_edit_about`. Targets the channel by username so a mod writes
// to the STREAMER's channel, not their own.
// to the STREAMER's channel, not their own.
-router.put('/channel/:username/about', requireAuth, (req, res) => {
+router.put('/channel/:username/about', requireAuth, async (req, res) => {
try {
try {
const channel = db.getChannelByUsername(req.params.username);
const channel = db.getChannelByUsername(req.params.username);
if (!channel) return res.status(404).json({ error: 'Channel not found' });
if (!channel) return res.status(404).json({ error: 'Channel not found' });
const isOwner = req.user.id === channel.user_id;
const isOwner = req.user.id === channel.user_id;
let modsCanEditAbout = false;
let modsCanEditAbout = false;
- try { modsCanEditAbout = !!(db.getChannelModerationSettings(channel.id) || {}).mods_can_edit_about; } catch { /* off */ }
- const isChannelMod = db.isChannelModerator(req.user.id, channel.id);
+ let isChannelMod = false;
+ try {
+ const m = await moderation.getChannelModeration(channel.id);
+ modsCanEditAbout = !!m.settings.mods_can_edit_about;
+ isChannelMod = m.moderator_ids.includes(Number(req.user.id));
+ } catch { /* off */ }
if (!isOwner && !(modsCanEditAbout && isChannelMod)) {
if (!isOwner && !(modsCanEditAbout && isChannelMod)) {
return res.status(403).json({ error: 'You do not have permission to edit this About section' });
return res.status(403).json({ error: 'You do not have permission to edit this About section' });
}
}
@@ -1611,7 +1622,7 @@ router.get('/setup-progress', requireAuth, async (req, res) => {
@@ -1611,7 +1622,7 @@ router.get('/setup-progress', requireAuth, async (req, res) => {
const rs = slots.some(sl => safe(() => { const r = db.getRobotStreamerIntegrationBySlot(uid, sl.id); return !!(r && (r.robot_id || r.stream_name)); }, false));
const rs = slots.some(sl => safe(() => { const r = db.getRobotStreamerIntegrationBySlot(uid, sl.id); return !!(r && (r.robot_id || r.stream_name)); }, false));
const user = safe(() => db.getUserById(uid), {}) || {};
const user = safe(() => db.getUserById(uid), {}) || {};
const channel = safe(() => db.getChannelByUserId(uid), {}) || {};
const channel = safe(() => db.getChannelByUserId(uid), {}) || {};
- const emotes = safe(() => db.get('SELECT COUNT(*) AS n FROM emotes WHERE user_id = ?', [uid]).n, 0);
+ const emotes = await moderation.getEmoteCount(uid).catch(() => 0);
const sounds = safe(() => db.get('SELECT COUNT(*) AS n FROM channel_sounds WHERE channel_owner_id = ?', [uid]).n, 0);
const sounds = safe(() => db.get('SELECT COUNT(*) AS n FROM channel_sounds WHERE channel_owner_id = ?', [uid]).n, 0);
const goals = safe(() => db.get('SELECT COUNT(*) AS n FROM donation_goals WHERE user_id = ? AND is_active = 1', [uid]).n, 0);
const goals = safe(() => db.get('SELECT COUNT(*) AS n FROM donation_goals WHERE user_id = ? AND is_active = 1', [uid]).n, 0);
const powerchat = safe(() => !!db.get('SELECT 1 FROM powerchat_connections WHERE user_id = ? LIMIT 1', [uid]), false);
const powerchat = safe(() => !!db.get('SELECT 1 FROM powerchat_connections WHERE user_id = ? LIMIT 1', [uid]), false);
@@ -1622,13 +1633,16 @@ router.get('/setup-progress', requireAuth, async (req, res) => {
@@ -1622,13 +1633,16 @@ router.get('/setup-progress', requireAuth, async (req, res) => {
// Second wave of tasks. Each one is a real feature with a real table behind it, so nothing on
// Second wave of tasks. Each one is a real feature with a real table behind it, so nothing on
// the list can be permanently unreachable — a task that can never be ticked would park every
// the list can be permanently unreachable — a task that can never be ticked would park every
// streamer below 100% forever.
// streamer below 100% forever.
- const mods = safe(() => channel.id ? db.get('SELECT COUNT(*) AS n FROM channel_moderators WHERE channel_id = ?', [channel.id]).n : 0, 0);
+ // Chat owns channel_moderators and channel_moderation_settings (roadmap T3).
+ const channelMod = channel.id ? await moderation.getChannelModeration(channel.id).catch(() => null) : null;
+ const mods = channelMod ? channelMod.moderator_ids.length : 0;
const controls = safe(() => db.get('SELECT COUNT(*) AS n FROM control_configs WHERE user_id = ?', [uid]).n, 0);
const controls = safe(() => db.get('SELECT COUNT(*) AS n FROM control_configs WHERE user_id = ?', [uid]).n, 0);
const aibot = safe(() => db.get('SELECT COUNT(*) AS n FROM channel_ai_bots WHERE channel_user_id = ?', [uid]).n, 0);
const aibot = safe(() => db.get('SELECT COUNT(*) AS n FROM channel_ai_bots WHERE channel_user_id = ?', [uid]).n, 0);
// Pastes live in OpenVibe.Community: the person's own count, unlisted and private included.
// Pastes live in OpenVibe.Community: the person's own count, unlisted and private included.
const pastes = await require('../media-proxy/lookups').countUserPastes(user && user.id ? user : null, { hidden: 'owner' });
const pastes = await require('../media-proxy/lookups').countUserPastes(user && user.id ? user : null, { hidden: 'owner' });
const requests = safe(() => !!db.get('SELECT 1 FROM media_request_settings WHERE user_id = ? LIMIT 1', [uid]), false);
const requests = safe(() => !!db.get('SELECT 1 FROM media_request_settings WHERE user_id = ? LIMIT 1', [uid]), false);
- const modRules = safe(() => channel.id ? !!db.get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ? LIMIT 1', [channel.id]) : false, false);
+ // A saved settings row carries updated_at; Chat's defaults (no row) do not.
+ const modRules = !!(channelMod && channelMod.settings.updated_at);
const tasks = [
const tasks = [
{ id: 'slot', group: 'Stream', title: 'Create your stream slot', why: 'Your show gets its own key, settings, VODs and restreams.', done: slots.length > 0, count: slots.length },
{ id: 'slot', group: 'Stream', title: 'Create your stream slot', why: 'Your show gets its own key, settings, VODs and restreams.', done: slots.length > 0, count: slots.length },
{ id: 'method', group: 'Stream', title: 'Choose how you stream', why: 'Browser, OBS over RTMP, or OBS over WHIP.', done: methodSet },
{ id: 'method', group: 'Stream', title: 'Choose how you stream', why: 'Browser, OBS over RTMP, or OBS over WHIP.', done: methodSet },
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+'use strict';
+
+// server/monetization/alerts.js after roadmap T3: Live no longer reads the alert sound from disk or
+// broadcasts base64 — the sound mapping lives on channel_moderation_settings, which OpenVibe.Chat
+// owns — it asks Chat through the bridge op `playAlertSound [streamerId, streamId, kind]`. This
+// checks the dispatcher; the op itself (resolving the sound, reading the shared file, broadcasting
+// `soundboard-audio`) lives in Chat and is tested there.
+//
+// node test/alerts-bridge.test.js
+
+const assert = require('assert');
+const alerts = require('../server/monetization/alerts');
+
+const calls = [];
+const fake = { playAlertSound: (...args) => calls.push(args) };
+
+alerts.playAlertSound(fake, 7, 3, 'donation');
+alerts.playAlertSound(fake, 7, 3, 'goal');
+alerts.playAlertSound(fake, 7, 3, 'anything-else');
+assert.deepStrictEqual(calls, [[7, 3, 'donation'], [7, 3, 'goal'], [7, 3, 'donation']], 'unknown kinds fall back to the donation sound');
+
+// Offline room: a null stream id is passed straight through (Chat reaches the channel room).
+alerts.playAlertSound(fake, 7, null, 'goal');
+assert.deepStrictEqual(calls[3], [7, null, 'goal']);
+
+// Live runs chat itself (rollback / dev): no Chat to ask — silent no-op, never throws.
+alerts.playAlertSound({}, 7, 3, 'donation');
+alerts.playAlertSound(null, 7, 3, 'donation');
+assert.strictEqual(calls.length, 4, 'a chat server without the op is not called');
+
+console.log('alerts bridge: all checks passed');
@@ -18,7 +18,7 @@ const BARE = /(?:\}|openvibe\.live)\/\$\{(?:encodeURIComponent\(|esc\()?[\w.?]*(
@@ -18,7 +18,7 @@ const BARE = /(?:\}|openvibe\.live)\/\$\{(?:encodeURIComponent\(|esc\()?[\w.?]*(
const ALLOWED = [
const ALLOWED = [
{ file: 'server/integrations/powerchat-checkout.js', contains: 'pcUsername', why: "PowerChat's own tip page (powerchat.live/<user>/tip), not an OpenVibe channel" },
{ file: 'server/integrations/powerchat-checkout.js', contains: 'pcUsername', why: "PowerChat's own tip page (powerchat.live/<user>/tip), not an OpenVibe channel" },
{ file: 'server/integrations/powerchat-routes.js', contains: 'powerchat_username', why: "PowerChat's own tip page" },
{ file: 'server/integrations/powerchat-routes.js', contains: 'powerchat_username', why: "PowerChat's own tip page" },
- { file: 'public/js/app.js', contains: '/chat-ai/relay/', why: 'API path /chat-ai/relay/<platform>/<username>' },
+ { file: 'public/js/app.js', contains: '/chat/ai/relay/', why: 'API path /chat/ai/relay/<platform>/<username> (OpenVibe.Chat, roadmap T3)' },
{ file: 'public/js/chat.js', contains: '/chat/relay-user/', why: 'API path /chat/relay-user/<platform>/<username>' },
{ file: 'public/js/chat.js', contains: '/chat/relay-user/', why: 'API path /chat/relay-user/<platform>/<username>' },
];
];
@@ -19,6 +19,13 @@ const http = require('http');
@@ -19,6 +19,13 @@ const http = require('http');
process.env.DB_PATH = path.join(os.tmpdir(), `ov-chanmod-${process.pid}.db`);
process.env.DB_PATH = path.join(os.tmpdir(), `ov-chanmod-${process.pid}.db`);
process.env.NODE_ENV = 'test';
process.env.NODE_ENV = 'test';
+// Live reads channel_moderators through OpenVibe.Chat (roadmap T3), so the routes need a stub Chat
+// (its internal read API) and a stub Network token endpoint. Fixed ports, env set before any require.
+const CHAT_PORT = 45000 + (process.pid % 400);
+const NET_PORT = CHAT_PORT + 1;
+process.env.OV_OAUTH_CLIENT_SECRET = 'chanmod-secret';
+process.env.OV_CHAT_INTERNAL_URL = `http://127.0.0.1:${CHAT_PORT}`;
+process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${NET_PORT}`;
const quiet = console.log;
const quiet = console.log;
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
@@ -26,6 +33,29 @@ const db = require('../server/db/database');
@@ -26,6 +33,29 @@ const db = require('../server/db/database');
db.initDb();
db.initDb();
const raw = db.getDb();
const raw = db.getDb();
+const netStub = http.createServer((req, res) => {
+ let body = ''; req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json');
+ if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: 'stub', expires_in: 300 }));
+ res.statusCode = 404; res.end('{}');
+ });
+});
+const chatStub = http.createServer((req, res) => {
+ res.setHeader('Content-Type', 'application/json');
+ const mm = String(req.url).match(/^\/internal\/moderation\/channels\/(\d+)$/);
+ if (mm) {
+ const cid = Number(mm[1]);
+ return res.end(JSON.stringify({
+ ok: true,
+ settings: db.getChannelModerationSettings(cid) || {},
+ moderator_ids: raw.prepare('SELECT user_id FROM channel_moderators WHERE channel_id = ? ORDER BY id').all(cid).map((r) => r.user_id),
+ }));
+ }
+ res.statusCode = 404; res.end('{}');
+});
+netStub.listen(NET_PORT); chatStub.listen(CHAT_PORT);
+
const auth = require('../server/auth/auth');
const auth = require('../server/auth/auth');
auth.requireAuth = (req, res, next) => {
auth.requireAuth = (req, res, next) => {
const u = db.getUserById(Number(req.headers['x-test-user'] || 0));
const u = db.getUserById(Number(req.headers['x-test-user'] || 0));
@@ -78,7 +108,9 @@ async function check(name, fn) {
@@ -78,7 +108,9 @@ async function check(name, fn) {
}
}
(async () => {
(async () => {
- await new Promise((r) => server.once('listening', r));
+ const listening = (s) => (s.listening ? Promise.resolve() : new Promise((r) => s.once('listening', r)));
+ await listening(server);
+ await Promise.all([listening(netStub), listening(chatStub)]);
const settings = () => db.getChannelModerationSettings(channel.id);
const settings = () => db.getChannelModerationSettings(channel.id);
await check('the dashboard saves slow mode in slow_mode_seconds (the old slowmode_seconds too)', async () => {
await check('the dashboard saves slow mode in slow_mode_seconds (the old slowmode_seconds too)', async () => {
@@ -68,6 +68,24 @@ const chat = http.createServer((req, res) => {
@@ -68,6 +68,24 @@ const chat = http.createServer((req, res) => {
if (req.url === '/internal/live/presence') {
if (req.url === '/internal/live/presence') {
return res.end(JSON.stringify({ total: 7, streams: { 1: 3 }, slow_mode: { 1: 5000 }, users: [{ user_id: 3, ip: '198.51.100.3', stream_id: 1 }], anons: [{ anon_id: 'anon9', ip: '203.0.113.9', stream_id: 1 }] }));
return res.end(JSON.stringify({ total: 7, streams: { 1: 3 }, slow_mode: { 1: 5000 }, users: [{ user_id: 3, ip: '198.51.100.3', stream_id: 1 }], anons: [{ anon_id: 'anon9', ip: '203.0.113.9', stream_id: 1 }] }));
}
}
+ // Chat's internal read API (roadmap T3): Live reads the six staged tables through it now.
+ const mm = String(req.url).match(/^\/internal\/moderation\/channels\/(\d+)(\/emote-count)?$/);
+ if (mm) {
+ const d2 = require('../server/db/database');
+ const channelId = Number(mm[1]);
+ if (mm[2]) { const ch = d2.getChannelById(channelId); return res.end(JSON.stringify({ ok: true, count: ch ? d2.countChannelEmotes(ch.user_id) : 0 })); }
+ return res.end(JSON.stringify({
+ ok: true,
+ settings: d2.getChannelModerationSettings(channelId) || {},
+ moderator_ids: d2.all('SELECT user_id FROM channel_moderators WHERE channel_id = ? ORDER BY id', [channelId]).map((r) => r.user_id),
+ }));
+ }
+ const um = String(req.url).match(/^\/internal\/moderation\/users\/(\d+)\/channels$/);
+ if (um) {
+ const d2 = require('../server/db/database');
+ const rows = d2.getChannelsByModerator(Number(um[1])) || [];
+ return res.end(JSON.stringify({ ok: true, channels: rows.map((c) => ({ channel_id: c.id, title: c.title, owner_user_id: c.user_id })) }));
+ }
res.statusCode = 404; res.end('{}');
res.statusCode = 404; res.end('{}');
});
});
});
});
@@ -145,7 +163,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -145,7 +163,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
assert.ok(auth.expires_at);
assert.ok(auth.expires_at);
assert.deepStrictEqual((await call('POST', '/internal/chat-context/auth', { token: READ, body: { token: 'garbage' } })).body, { user: null, reason: 'invalid' });
assert.deepStrictEqual((await call('POST', '/internal/chat-context/auth', { token: READ, body: { token: 'garbage' } })).body, { user: null, reason: 'invalid' });
- // 4. Streams, channel policy (settings + moderators + language), follows, bans with version.
+ // 4. Streams, channel policy (language; settings and moderator ids are Chat's now), follows, bans with version.
const s = (await call('GET', `/internal/chat-context/streams/${streamId}`, { token: READ })).body;
const s = (await call('GET', `/internal/chat-context/streams/${streamId}`, { token: READ })).body;
assert.strictEqual(s.stream.user_id, streamer);
assert.strictEqual(s.stream.user_id, streamer);
assert.strictEqual(s.owner.username, 'streamer');
assert.strictEqual(s.owner.username, 'streamer');
@@ -154,8 +172,11 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -154,8 +172,11 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const active = (await call('GET', '/internal/chat-context/streams/active', { token: READ })).body.rows;
const active = (await call('GET', '/internal/chat-context/streams/active', { token: READ })).body.rows;
assert.ok(active.some((r) => r.id === streamId && r.is_live === 1));
assert.ok(active.some((r) => r.id === streamId && r.is_live === 1));
const policy = (await call('GET', `/internal/chat-context/channels/${channel.id}/policy`, { token: READ })).body;
const policy = (await call('GET', `/internal/chat-context/channels/${channel.id}/policy`, { token: READ })).body;
- assert.deepStrictEqual(policy.moderator_ids, [mod]);
- assert.strictEqual(policy.settings.allow_anonymous, 1, 'defaults when the channel has no row');
+ // OpenVibe.Chat owns channel_moderation_settings/channel_moderators (roadmap T3) and reads them
+ // locally; Live answers only the channel row and the language it owns.
+ assert.strictEqual(policy.channel.id, channel.id);
+ assert.strictEqual(policy.language, 'en');
+ assert.ok(!('settings' in policy) && !('moderator_ids' in policy), 'the policy half is Chat-local now');
assert.deepStrictEqual((await call('GET', `/internal/chat-context/users/${viewer}/follows`, { token: READ })).body.streamer_ids, [streamer]);
assert.deepStrictEqual((await call('GET', `/internal/chat-context/users/${viewer}/follows`, { token: READ })).body.streamer_ids, [streamer]);
const b1 = (await call('GET', '/internal/chat-context/bans', { token: READ })).body;
const b1 = (await call('GET', '/internal/chat-context/bans', { token: READ })).body;
assert.deepStrictEqual(b1.bans, []);
assert.deepStrictEqual(b1.bans, []);
@@ -198,18 +219,11 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -198,18 +219,11 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
assert.strictEqual((await put(owner, { tts_google_api_key: 'owner-can' })).body.updated, 1);
assert.strictEqual((await put(owner, { tts_google_api_key: 'owner-can' })).body.updated, 1);
assert.strictEqual(db.getSetting('tts_google_api_key'), 'owner-can');
assert.strictEqual(db.getSetting('tts_google_api_key'), 'owner-can');
- // 8. Slow mode persistence and alert sounds: only for people who may.
- 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');
+ // 8. Slow mode and sub-only persistence moved to Chat with channel_moderation_settings (roadmap
+ // T3); Live now only writes channels.emote_sources for Chat's PUT /api/emotes/sources.
+ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-emote-sources', { token: WRITE, body: { user_id: 999999, sources: { ffz: true } } })).status, 404);
+ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-emote-sources', { token: WRITE, body: { user_id: streamer, sources: { ffz: true, bttv: false } } })).status, 200);
+ assert.strictEqual(db.getChannelByUserId(streamer).emote_sources, JSON.stringify({ ffz: true, bttv: false }));
// 8a. Sub-only chat asks whether someone holds an ACTIVE subscription to the streamer's channel:
// 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.
// by user id or Network subject; a lapsed period or a cancelled row is not one.
@@ -226,10 +240,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -226,10 +240,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body.subscriber, false, 'the paid period is over');
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() });
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');
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');
- assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: snd } })).status, 200);
+ // Alert sounds moved to Chat with channel_moderation_settings (roadmap T3): Chat resolves the
+ // sound from its own row and plays it through the bridge op, so Live's effect is gone.
// 8b. A ring from Chat's call server (CALLS_AUTHORITY=chat): Live pushes the VC_CALL_INVITE it used to.
// 8b. A ring from Chat's call server (CALLS_AUTHORITY=chat): Live pushes the VC_CALL_INVITE it used to.
const notify = require('../server/utils/notify');
const notify = require('../server/utils/notify');
new file mode 100644
new file mode 100644
@@ -0,0 +1,141 @@
@@ -0,0 +1,141 @@
+'use strict';
+
+// server/chat/moderation-client.js (roadmap T3): Live reads the six chat tables OpenVibe.Chat owns
+// through ONE internal read API (Contracts 0.80.0 chat.moderation.read, audience openvibe.chat, Live's
+// service token). This file checks the client the permissions hot path now depends on:
+// - it calls Chat with a Bearer service token and reads { ok:true, … } answers;
+// - every answer is cached 30 s (one fetch for repeated reads), invalidate() drops it;
+// - a failure (or no secret) answers the safe default — "not a moderator", Live's moderation
+// defaults, zero emotes — and never throws;
+// - with openvibe-contracts >= 0.80.0 installed the stub's answers validate against the three
+// result contracts (chat.channel-moderation-result@1, chat.moderated-channels-result@1,
+// chat.emote-count-result@1). The repo pins 0.71.0 today, so that block is conditional.
+//
+// Against stub Network (token endpoint) and stub Chat (the three read routes).
+
+const assert = require('assert');
+const http = require('http');
+
+process.env.OV_OAUTH_CLIENT_ID = 'live';
+process.env.OV_OAUTH_CLIENT_SECRET = 'moderation-secret';
+process.env.NODE_ENV = 'test';
+const quiet = console.log;
+console.log = () => {};
+console.warn = () => {};
+
+let failing = false;
+const calls = [];
+
+const network = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json');
+ if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: 'stub-chat-token', expires_in: 300 }));
+ res.statusCode = 404; res.end('{}');
+ });
+});
+
+const MOD_SETTINGS = { channel_id: 5, slow_mode_seconds: 7, custom_emotes_enabled: 1 };
+const MOD = { ok: true, settings: MOD_SETTINGS, moderator_ids: [11, 22] };
+const CHANS = { ok: true, channels: [{ channel_id: 5, title: 'Streamer TV', owner_user_id: 5, owner_username: 'streamer' }] };
+const COUNT = { ok: true, count: 3 };
+const chat = http.createServer((req, res) => {
+ calls.push({ url: req.url, auth: req.headers.authorization });
+ res.setHeader('Content-Type', 'application/json');
+ if (failing) { res.statusCode = 500; return res.end(JSON.stringify({ ok: false, error: 'boom' })); }
+ if (/^\/internal\/moderation\/channels\/\d+\/emote-count$/.test(req.url)) return res.end(JSON.stringify(COUNT));
+ if (/^\/internal\/moderation\/channels\/\d+$/.test(req.url)) return res.end(JSON.stringify(MOD));
+ if (/^\/internal\/moderation\/users\/\d+\/channels$/.test(req.url)) return res.end(JSON.stringify(CHANS));
+ res.statusCode = 404; res.end('{}');
+});
+
+const listen = (s) => new Promise((r) => s.listen(0, '127.0.0.1', () => r(s.address().port)));
+
+(async () => {
+ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${await listen(network)}`;
+ process.env.OV_CHAT_INTERNAL_URL = `http://127.0.0.1:${await listen(chat)}`;
+ const client = require('../server/chat/moderation-client');
+
+ let failures = 0;
+ const check = async (name, fn) => {
+ try { await fn(); quiet(` ✓ ${name}`); } catch (err) { failures++; quiet(` ✗ ${name}\n ${err.stack || err.message}`); }
+ };
+
+ await check('enabled, audience and cache TTL', () => {
+ assert.strictEqual(client.enabled(), true);
+ assert.strictEqual(client.AUDIENCE, 'openvibe.chat');
+ assert.strictEqual(client.CACHE_TTL_MS, 30000);
+ });
+
+ await check('a channel read: Bearer service token, { settings, moderator_ids }', async () => {
+ const m = await client.getChannelModeration(5);
+ assert.deepStrictEqual(m.moderator_ids, [11, 22]);
+ assert.strictEqual(m.settings.slow_mode_seconds, 7);
+ assert.strictEqual(m.settings.channel_id, 5);
+ assert.strictEqual(calls.length, 1);
+ assert.ok(String(calls[0].auth || '').startsWith('Bearer '), 'Chat is called with a service token');
+ assert.strictEqual(calls[0].url, '/internal/moderation/channels/5');
+ });
+
+ await check('the answer is cached 30 s; invalidate() drops it', async () => {
+ await client.getChannelModeration(5);
+ assert.strictEqual(calls.length, 1, 'the second read is served from the cache');
+ client.invalidate(5);
+ await client.getChannelModeration(5);
+ assert.strictEqual(calls.length, 2, 'after invalidate() the next read asks Chat again');
+ });
+
+ await check('moderated channels and emote count', async () => {
+ const rows = await client.getChannelsByModerator(9);
+ assert.deepStrictEqual(rows, [{ id: 5, channel_id: 5, title: 'Streamer TV', user_id: 5, owner_username: 'streamer' }]);
+ assert.ok(String(calls[calls.length - 1].auth || '').startsWith('Bearer '));
+ assert.strictEqual(await client.getEmoteCount(5), 3);
+ });
+
+ await check('a failure answers the safe default, never throws', async () => {
+ failing = true;
+ const m = await client.getChannelModeration(77);
+ assert.deepStrictEqual(m.moderator_ids, [], 'not a moderator');
+ assert.strictEqual(m.settings.slow_mode_seconds, 0, 'Live\'s default settings');
+ assert.strictEqual(m.settings.channel_id, 77);
+ assert.deepStrictEqual(await client.getChannelsByModerator(77), []);
+ assert.strictEqual(await client.getEmoteCount(77), 0);
+ failing = false;
+ });
+
+ await check('without a secret the client is off and answers defaults (no request)', async () => {
+ client._reset();
+ const before = calls.length;
+ process.env.OV_OAUTH_CLIENT_SECRET = '';
+ try {
+ const m = await client.getChannelModeration(88);
+ assert.deepStrictEqual(m.moderator_ids, []);
+ assert.strictEqual(await client.getEmoteCount(88), 0);
+ assert.strictEqual(calls.length, before, 'nothing is sent when Live is not configured');
+ } finally {
+ process.env.OV_OAUTH_CLIENT_SECRET = 'moderation-secret';
+ client._reset();
+ }
+ });
+
+ // The three result contracts validate the stub's answers when the package knows them. Live pins
+ // openvibe-contracts 0.71.0 (these arrived in 0.80.0), so today this block is skipped; a release
+ // that bumps the pin validates here.
+ await check('the three result contracts validate the answers (validator present in this pin?)', async () => {
+ const contracts = require('openvibe-contracts');
+ const known = (name) => { try { return !!contracts.schema(name); } catch { return false; } };
+ // The client answers { settings, moderator_ids } (Chat's `ok` is consumed): rebuild the contract
+ // payload from the client's own output — Live's defaults merged with Chat's row.
+ const m = await client.getChannelModeration(5);
+ if (known('chat.channel-moderation-result@1')) contracts.assertValid('chat.channel-moderation-result@1', { ok: true, settings: m.settings, moderator_ids: m.moderator_ids });
+ if (known('chat.moderated-channels-result@1')) contracts.assertValid('chat.moderated-channels-result@1', CHANS);
+ if (known('chat.emote-count-result@1')) contracts.assertValid('chat.emote-count-result@1', COUNT);
+ });
+
+ process.env.OV_OAUTH_CLIENT_SECRET = '';
+ network.close(); chat.close();
+ if (failures) { quiet(`chat moderation client: ${failures} failed`); process.exit(1); }
+ quiet('chat moderation client: all checks passed');
+ process.exit(0);
+})().catch((err) => { console.error(err.stack || err.message); process.exit(1); });
@@ -1,405 +1,9 @@
@@ -1,405 +1,9 @@
'use strict';
'use strict';
-
-// The staged chat tables (roadmap C-04; OpenVibe.Chat docs/staged-tables-cutover.md): the authority
-// per table (app_state, 'live' by default, and 'live' whatever it says without CHAT_AUTHORITY=chat);
-// at 'live' Live writes locally and its captured changes reach Chat's copy (ordered, newest state,
-// kept while Chat is down); the dual-read counter (matched, mismatched, inconclusive; Live's answer
-// never changes); the internal status and flip routes; the handoff to 'chat' (Live's queued
-// changes first, then Chat, then Live stops writing); Live's writers going to Chat when flipped —
-// through a real route — with Live's copy following Chat's answer, Chat's mirror accepted only for
-// a table Chat writes; a refused or unreachable Chat changes nothing; the flip back. Against stub
-// Network and Chat servers (the stub keeps its copy in SQLite and hashes slices like Chat does).
-
-const assert = require('assert');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-const http = require('http');
-const crypto = require('crypto');
-const jwt = require('jsonwebtoken');
-const Database = require('better-sqlite3');
-const { serviceAuth } = require('openvibe-contracts');
-
-const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chattables-'));
-const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
-fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);
-process.env.DB_PATH = path.join(tmp, 'live.db');
-process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
-process.env.OV_NETWORK_URL = 'https://openvibe.network';
-process.env.OV_OAUTH_CLIENT_ID = 'live';
-process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
-process.env.CHAT_AUTHORITY = 'chat';
-const quiet = console.log;
-console.log = () => {};
-console.warn = () => {};
-
-const ISS = 'https://openvibe.network';
-const now = () => Math.floor(Date.now() / 1000);
-let jti = 0;
-const serviceToken = (cap, aud) => serviceAuth.signServiceToken({ iss: ISS, sub: 'svc:live', actor_type: 'service', aud: [aud], cap, iat: now(), exp: now() + 300, jti: `tok_test_${++jti}` }, keys.privateKey);
-
-const network = http.createServer((req, res) => {
- let raw = '';
- req.on('data', (c) => { raw += c; });
- req.on('end', () => {
- res.setHeader('Content-Type', 'application/json');
- if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: serviceToken(['chat.live_bridge.write'], new URLSearchParams(raw).get('audience')), expires_in: 300 }));
- res.statusCode = 404; res.end('{}');
- });
-});
-
-// ── Stub OpenVibe.Chat: the bridge ops of the staged tables, on a SQLite copy ──
-const TABLES = ['channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events'];
-const PK = { channel_moderation_settings: 'channel_id' };
-const pkOf = (t) => PK[t] || 'id';
-const stub = { calls: [], down: false, refuseHandback: false, authority: Object.fromEntries(TABLES.map((t) => [t, 'live'])), copy: null };
-const hash = (cols, rows) => crypto.createHash('sha256').update(JSON.stringify(rows.map((r) => cols.map((c) => (r[c] === undefined ? null : r[c]))))).digest('hex');
-function stubOp(o) {
- const c = stub.copy;
- const [fn, ...a] = o.op === 'db' ? o.args : [];
- const rowOf = (t, id) => c.prepare(`SELECT * FROM ${t} WHERE ${pkOf(t)} = ?`).get(id);
- const answer = (value, t, ids = [], deleted = []) => ({ value, mirror: [...deleted.map((id) => ({ table: t, op: 'delete', pk: { [pkOf(t)]: id } })), ...ids.map((id) => ({ table: t, op: 'upsert', row: rowOf(t, id) }))] });
- const plain = (r) => ({ changes: r.changes, lastInsertRowid: Number(r.lastInsertRowid) });
- switch (o.op) {
- case 'tableAuthority': return { ...stub.authority };
- case 'setTableAuthority':
- if (o.args[1] === 'live' && stub.refuseHandback) throw new Error(`1 change(s) to ${o.args[0]} not in Live yet`);
- stub.authority[o.args[0]] = o.args[1];
- return { table: o.args[0], authority: o.args[1], mirror_pending: 0 };
- case 'stagedApply': {
- const out = { applied: 0, skipped: [] };
- for (const ch of o.args[0]) {
- if (stub.authority[ch.table] !== 'live') { out.skipped.push({ table: ch.table, reason: 'Chat writes this table (table_authority chat)' }); continue; }
- if (ch.op === 'delete') c.prepare(`DELETE FROM ${ch.table} WHERE ${pkOf(ch.table)} = ?`).run(ch.pk[pkOf(ch.table)]);
- else { const cols = Object.keys(ch.row); c.prepare(`INSERT OR REPLACE INTO ${ch.table} (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`).run(...cols.map((k) => ch.row[k])); }
- out.applied++;
- }
- return out;
- }
- case 'stagedSlice': {
- const [t, where, cols] = o.args;
- const ks = Object.keys(where);
- const use = [...cols].sort();
- const rows = c.prepare(`SELECT ${use.join(', ')} FROM ${t} WHERE ${ks.map((k) => `${k} IS ?`).join(' AND ') || '1'} ORDER BY ${pkOf(t)}`).all(...ks.map((k) => where[k]));
- return { count: rows.length, hash: hash(use, rows), columns: use, rows: rows.length <= 50 ? rows : undefined };
- }
- case 'db': {
- const table = { addChannelModerator: 'channel_moderators', removeChannelModerator: 'channel_moderators', upsertChannelModerationSettings: 'channel_moderation_settings', setChannelAlertSound: 'channel_moderation_settings', createEmote: 'emotes', updateEmote: 'emotes', deleteEmote: 'emotes', setEmoteMedia: 'emotes', upsertChatAiSummary: 'chat_ai_summaries', addChatTimelineEvents: 'chat_timeline_events' }[fn];
- if (stub.authority[table] !== 'chat') throw new Error(`${table} is written by Live (table_authority live)`);
- if (fn === 'addChannelModerator') {
- const r = c.prepare('INSERT OR IGNORE INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?)').run(...a);
- return answer(plain(r), table, [c.prepare('SELECT id FROM channel_moderators WHERE channel_id = ? AND user_id = ?').get(a[0], a[1]).id]);
- }
- if (fn === 'removeChannelModerator') {
- const gone = c.prepare('SELECT id FROM channel_moderators WHERE channel_id = ? AND user_id = ?').all(a[0], a[1]).map((r) => r.id);
- return answer(plain(c.prepare('DELETE FROM channel_moderators WHERE channel_id = ? AND user_id = ?').run(a[0], a[1])), table, [], gone);
- }
- if (fn === 'upsertChannelModerationSettings') {
- c.prepare('INSERT OR IGNORE INTO channel_moderation_settings (channel_id) VALUES (?)').run(a[0]);
- for (const [k, v] of Object.entries(a[1])) if (v !== undefined && /^[a-z_]+$/.test(k) && k !== 'slowmode_seconds') c.prepare(`UPDATE channel_moderation_settings SET ${k} = ? WHERE channel_id = ?`).run(typeof v === 'boolean' ? Number(v) : v, a[0]);
- return answer(rowOf(table, a[0]), table, [a[0]]);
- }
- if (fn === 'createEmote') {
- const e = a[0];
- const r = c.prepare('INSERT INTO emotes (user_id, code, url, animated, channel_owner_id, size) VALUES (?, ?, ?, ?, ?, ?)').run(e.user_id, e.code, e.url, e.animated ? 1 : 0, e.channel_owner_id || null, e.size || 100);
- return answer(plain(r), table, [Number(r.lastInsertRowid)]);
- }
- if (fn === 'deleteEmote') return answer(plain(c.prepare('DELETE FROM emotes WHERE id = ?').run(a[0])), table, [], [a[0]]);
- if (fn === 'upsertChatAiSummary') {
- const s = a[0];
- c.prepare(`INSERT INTO chat_ai_summaries (scope, subject_id, window, overview) VALUES (?, ?, ?, ?)
- ON CONFLICT(scope, subject_id, window) DO UPDATE SET overview = excluded.overview`).run(s.scope, s.subject_id || 0, s.window, s.overview || '');
- return answer({ changes: 1, lastInsertRowid: 0 }, table, [c.prepare('SELECT id FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND window = ?').get(s.scope, s.subject_id || 0, s.window).id]);
- }
- throw new Error(`stub has no ${fn}`);
- }
- default: throw new Error(`unknown op ${o.op}`);
- }
-}
-const chat = http.createServer((req, res) => {
- let raw = '';
- req.on('data', (c) => { raw += c; });
- req.on('end', () => {
- if (stub.down) { req.socket.destroy(); return; }
- res.setHeader('Content-Type', 'application/json');
- const auth = String(req.headers.authorization || '');
- const v = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: keys.publicKey, issuer: ISS, audience: 'openvibe.chat' });
- if (!v.ok || !v.claims.cap.includes('chat.live_bridge.write')) { res.statusCode = 401; return res.end('{}'); }
- if (req.url !== '/internal/live/calls') { res.statusCode = 404; return res.end('{}'); }
- const body = JSON.parse(raw);
- const results = body.ops.map((o) => {
- stub.calls.push({ op: o.op, args: o.args, key: o.key });
- try { return { seq: o.seq, ok: true, result: stubOp(o) }; } catch (err) { return { seq: o.seq, ok: false, error: err.message }; }
- });
- res.end(JSON.stringify({ ok: true, results }));
- });
-});
-
-const listen = (s) => new Promise((r) => s.listen(0, '127.0.0.1', () => r(s.address().port)));
-const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
-
-(async () => {
- process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${await listen(network)}`;
- process.env.OV_CHAT_INTERNAL_URL = `http://127.0.0.1:${await listen(chat)}`;
-
- const db = require('../server/db/database');
- db.initDb();
- require('../server/chat/tags').ensureTagTables();
- for (const col of ['media_url TEXT', 'media_asset_id INTEGER']) { try { db.getDb().exec(`ALTER TABLE emotes ADD COLUMN ${col}`); } catch { /* present */ } }
- const d = db.getDb();
- // The stub's copy: the same tables, created from Live's own definitions (no foreign keys to follow).
- stub.copy = new Database(':memory:');
- stub.copy.pragma('foreign_keys = OFF');
- for (const t of TABLES) stub.copy.exec(d.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(t).sql);
- for (const r of d.prepare(`SELECT sql FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL AND tbl_name IN (${TABLES.map(() => '?').join(',')})`).all(...TABLES)) stub.copy.exec(r.sql);
-
- const mkUser = (username, role = 'user') => {
- const id = Number(db.createUser({ username, email: `${username}@example.test`, password_hash: '!x', display_name: username.toUpperCase(), stream_key: `key-${username}` }).lastInsertRowid);
- d.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
- return id;
- };
- const streamer = mkUser('streamer', 'streamer');
- const mod = mkUser('moddy');
- const viewer = mkUser('viewer');
- d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', '601', 'usr_01J9SSSSSSSSSSSSSSSSSSSSSS')").run(streamer);
- db.createChannel({ user_id: streamer, title: 'Streamer TV' });
- const channel = db.getChannelByUserId(streamer);
- db.createChannel({ user_id: viewer, title: 'Viewer TV' });
- const channel2 = db.getChannelByUserId(viewer);
- const streamerJwt = jwt.sign({ sub: '601', username: 'streamer', subject_id: 'usr_01J9SSSSSSSSSSSSSSSSSSSSSS' }, keys.privateKey, { algorithm: 'RS256', issuer: ISS, expiresIn: 600 });
-
- const express = require('express');
- const app = express();
- app.use(express.json());
- const routes = require('../server/chat/live-context-routes');
- app.use('/internal/chat-effects', routes.effectsRouter);
- app.use('/internal', require('../server/internal/routes'));
- app.use('/api/channels', require('../server/admin/channel-mod-routes'));
- const port = await listen(http.createServer(app));
- const call = async (method, p, { body, headers = {} } = {}) => {
- const res = await fetch(`http://127.0.0.1:${port}${p}`, { method, headers: { 'Content-Type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined });
- const text = await res.text();
- let json = null; try { json = JSON.parse(text); } catch { /* */ }
- return { status: res.status, body: json, text };
- };
- // OpenVibe.Chat's token for the staged-table handoff (live.chat_mirror.write); X-Internal-Key is gone (plan T2).
- const CHAT = () => ({ Authorization: `Bearer ${serviceAuth.signServiceToken({ iss: ISS, sub: 'svc:chat', actor_type: 'service', aud: ['openvibe.live'], cap: ['live.chat_mirror.write'], iat: now(), exp: now() + 300, jti: `tok_test_${++jti}` }, keys.privateKey)}` });
- const internal = (method, p, body) => call(method, `/internal${p}`, { body, headers: CHAT() });
- const MIRROR = serviceToken(['live.chat_mirror.write'], 'openvibe.live');
- const mirror = (changes) => call('POST', '/internal/chat-effects/mirror', { body: { changes }, headers: { Authorization: `Bearer ${MIRROR}` } });
-
- const chatTables = require('../server/chat/chat-tables');
- const sync = require('../server/chat/chat-tables-sync');
- const outbox = (t) => d.prepare('SELECT tbl, op, pk FROM chat_staged_outbox WHERE tbl = ? ORDER BY seq').all(t);
- const stats = (t) => sync.dualReadStats(t);
- const stubRows = (t) => stub.copy.prepare(`SELECT * FROM ${t} ORDER BY ${pkOf(t)}`).all();
-
- let exit = 0;
- try {
- // 1. Deploying changes nothing: every table 'live', writes local, and without CHAT_AUTHORITY=chat
- // a stored 'chat' does not count.
- for (const t of TABLES) assert.strictEqual(chatTables.authority(t), 'live', t);
- await chatTables.write('addChannelModerator', channel.id, mod, streamer);
- assert.ok(db.isChannelModerator(mod, channel.id));
- db.setState(chatTables.AUTHORITY_KEY, JSON.stringify({ emotes: 'chat' }));
- chatTables._resetCache();
- assert.strictEqual(chatTables.authority('emotes'), 'chat');
- process.env.CHAT_AUTHORITY = '';
- assert.strictEqual(chatTables.authority('emotes'), 'live', 'Live runs chat itself: it writes everything');
- assert.strictEqual(chatTables.init(), false, 'and starts nothing');
- process.env.CHAT_AUTHORITY = 'chat';
- db.setState(chatTables.AUTHORITY_KEY, '{}');
- chatTables._resetCache();
- await assert.rejects(chatTables.write('grantUserTag', viewer, 'x'), /not a staged-table write/, 'user_tags has no writer in Live');
-
- // 2. init: capture, dual read, relay (the relay's timer is stopped; the test flushes by hand).
- assert.strictEqual(chatTables.init(), true);
- require('../server/utils/jobs').stopAll();
- assert.strictEqual(sync.pending(), 0, 'what was written before init is the import’s business');
-
- // 3. At 'live': every change to a staged table is captured, whoever writes it.
- await chatTables.write('upsertChannelModerationSettings', channel.id, { slow_mode_seconds: 5 });
- await chatTables.write('upsertChannelModerationSettings', channel.id, { emote_scale: 150 });
- const e1 = Number((await chatTables.write('createEmote', { user_id: streamer, code: 'pog', url: '/data/emotes/pog.png', channel_owner_id: streamer })).lastInsertRowid);
- await chatTables.write('removeChannelModerator', channel.id, mod);
- await chatTables.write('upsertChatAiSummary', { scope: 'global', subject_id: 0, window: 'global', overview: 'busy' });
- await chatTables.write('addChatTimelineEvents', 'global', 0, [{ ts: '2026-09-25 10:00:00', label: 'raid' }]);
- d.prepare("INSERT INTO user_tags (user_id, tag_id, source) VALUES (?, 'legacy', 'migration')").run(viewer);
- assert.deepStrictEqual(outbox('channel_moderation_settings').map((r) => r.op), ['upsert', 'upsert'], 'the insert and the update');
- assert.deepStrictEqual(outbox('channel_moderators').map((r) => [r.op, JSON.parse(r.pk).id > 0]), [['delete', true]]);
- assert.strictEqual(outbox('user_tags').length, 1, 'a raw write is captured too');
-
- // 4. The relay: newest state per row, in order; kept while Chat is down; acknowledged rows go.
- stub.down = true;
- let r = await sync.flush();
- assert.ok(r.pending >= 6 && /unreachable/.test(r.error), JSON.stringify(r));
- stub.down = false;
- stub.calls.length = 0;
- r = await sync.flush();
- assert.deepStrictEqual([r.pending, r.error], [0, null]);
- const applied = stub.calls.filter((c) => c.op === 'stagedApply').flatMap((c) => c.args[0]);
- const cms = applied.filter((c) => c.table === 'channel_moderation_settings');
- assert.strictEqual(cms.length, 1, 'two changes to one row: one upsert');
- assert.deepStrictEqual([cms[0].row.slow_mode_seconds, cms[0].row.emote_scale], [5, 150]);
- assert.deepStrictEqual(applied.find((c) => c.table === 'channel_moderators'), { table: 'channel_moderators', op: 'delete', pk: { id: 1 } });
- assert.strictEqual(stubRows('emotes')[0].code, 'pog');
- assert.strictEqual(stubRows('user_tags').length, 1);
- // Paused for an import: the capture goes on, nothing is sent; resumed, everything since goes.
- assert.strictEqual((await internal('POST', '/chat-tables/relay', { paused: true })).body.relay.paused, true);
- await chatTables.write('addChatTimelineEvents', 'global', 0, [{ ts: '2026-09-25 10:05:00', label: 'after the pause' }]);
- r = await sync.flush();
- assert.deepStrictEqual([r.error, r.pending], ['relay paused', 1]);
- assert.strictEqual(stubRows('chat_timeline_events').length, 1);
- assert.strictEqual((await internal('POST', '/chat-tables/relay', { paused: false })).body.relay.paused, false);
- assert.strictEqual((await sync.flush()).pending, 0);
- assert.strictEqual(stubRows('chat_timeline_events').length, 2);
-
- // 5. The dual read: off by default; on, a read is compared in the background; Live's answer never changes.
- const liveAnswer = db.getChannelModerationSettings(channel.id);
- await sleep(50);
- assert.strictEqual(stats('channel_moderation_settings').compared, 0, 'off: nothing compared');
- assert.strictEqual((await internal('POST', '/chat-tables/channel_moderation_settings', { dual_read: true })).body.table.dual_read, true);
- assert.deepStrictEqual(db.getChannelModerationSettings(channel.id), liveAnswer);
- await sleep(150);
- assert.deepStrictEqual([stats('channel_moderation_settings').compared, stats('channel_moderation_settings').matched], [1, 1]);
- // Chat's copy differs for another channel: counted, sampled, logged; Live answers from its own row.
- await chatTables.write('upsertChannelModerationSettings', channel2.id, { slow_mode_seconds: 30 });
- await sync.flush();
- stub.copy.prepare('UPDATE channel_moderation_settings SET slow_mode_seconds = 31 WHERE channel_id = ?').run(channel2.id);
- assert.strictEqual(db.getChannelModerationSettings(channel2.id).slow_mode_seconds, 30);
- await sleep(150);
- let st = stats('channel_moderation_settings');
- assert.deepStrictEqual([st.compared, st.mismatched], [2, 1]);
- assert.deepStrictEqual(st.last_mismatch.where, { channel_id: channel2.id });
- assert.deepStrictEqual(st.last_mismatch.differ, [JSON.stringify([channel2.id])]);
- // A change still on its way to Chat: no verdict.
- await internal('POST', '/chat-tables/emotes', { dual_read: true });
- await chatTables.write('updateEmote', e1, { size: 120 });
- db.getEmoteById(e1);
- await sleep(150);
- assert.deepStrictEqual([stats('emotes').inconclusive, stats('emotes').mismatched], [1, 0]);
- await sync.flush();
- // The same slice is compared at most once a minute.
- db.getChannelModerationSettings(channel2.id);
- await sleep(100);
- assert.strictEqual(stats('channel_moderation_settings').compared, 2);
- assert.strictEqual((await internal('POST', '/chat-tables/channel_moderation_settings', { reset_counters: true })).body.table.dual_read_stats.compared, 0);
-
- // 6. The status route: loopback + a service token (live.chat_mirror.write); both sides' view of every table.
- assert.strictEqual((await call('GET', '/internal/chat-tables')).status, 401, 'no credentials');
- assert.strictEqual((await call('GET', '/internal/chat-tables', { headers: { ...CHAT(), 'X-Forwarded-For': '203.0.113.1' } })).status, 403, 'a token through the public edge');
- assert.strictEqual((await call('GET', '/internal/chat-tables', { headers: { 'X-Internal-Key': 'k'.repeat(40) } })).status, 401, 'the retired key opens nothing');
- let s = (await internal('GET', '/chat-tables')).body;
- assert.strictEqual(s.chat_authority, 'chat');
- assert.deepStrictEqual([s.tables.emotes.authority, s.tables.emotes.chat_authority, s.tables.emotes.dual_read, s.tables.emotes.outbox_pending], ['live', 'live', true, 0]);
- assert.strictEqual((await internal('POST', '/chat-tables/chat_messages', { authority: 'chat' })).status, 404);
-
- // 7. While Live writes a table, Chat's mirror rows for it are refused.
- let m = await mirror([{ table: 'channel_moderators', op: 'upsert', row: { id: 900, channel_id: channel.id, user_id: viewer, added_by: streamer } }]);
- assert.deepStrictEqual([m.body.applied, m.body.skipped[0].reason], [0, 'Live writes this table (chat_table_authority live)']);
-
- // 8. Handoff to 'chat': Live's queued change reaches Chat first, then Chat takes it, then Live stops.
- await chatTables.write('addChannelModerator', channel.id, viewer, streamer);
- assert.strictEqual(outbox('channel_moderators').length, 1);
- stub.calls.length = 0;
- let f = await internal('POST', '/chat-tables/channel_moderators', { authority: 'chat', by: 'test' });
- assert.strictEqual(f.status, 200, f.text);
- // (chat-remote's own traffic — invalidations, forwarded moderation-log rows — is not this.)
- const tableOps = () => stub.calls.filter((c) => ['stagedApply', 'setTableAuthority', 'tableAuthority'].includes(c.op) || (c.op === 'db' && c.args[0] in chatTables.OPS));
- assert.deepStrictEqual(tableOps().map((c) => c.op), ['stagedApply', 'setTableAuthority', 'tableAuthority']);
- assert.deepStrictEqual([f.body.handoff.before, f.body.handoff.authority, f.body.table.authority, f.body.table.chat_authority], ['live', 'chat', 'chat', 'chat']);
- assert.strictEqual(stubRows('channel_moderators').length, 1, 'Chat has the row Live wrote last');
- d.prepare('DELETE FROM channel_moderators WHERE user_id = ?').run(viewer);
- assert.strictEqual(outbox('channel_moderators').length, 0, 'Live’s copy is the mirror now: nothing captured');
- m = await mirror([{ table: 'channel_moderators', op: 'upsert', row: stubRows('channel_moderators')[0] }]);
- assert.strictEqual(m.body.applied, 1, 'and Chat’s mirror is taken');
- assert.ok(db.isChannelModerator(viewer, channel.id));
-
- // 9. Live's writers go to Chat: the real route, Chat's id, Live's copy right away, nothing written locally first.
- stub.calls.length = 0;
- let res = await call('POST', `/api/channels/${channel.id}/mods`, { body: { username: 'moddy' }, headers: { Authorization: `Bearer ${streamerJwt}` } });
- assert.strictEqual(res.status, 200, res.text);
- const dbCall = tableOps().find((c) => c.op === 'db');
- assert.deepStrictEqual(dbCall.args, ['addChannelModerator', channel.id, mod, streamer]);
- assert.match(dbCall.key, /^staged:/, 'an idempotency key');
- const chatRow = stubRows('channel_moderators').find((x) => x.user_id === mod);
- assert.deepStrictEqual(res.body.moderators.map((x) => x.user_id).sort(), [mod, viewer].sort(), 'the answer reads Live’s copy, already current');
- assert.strictEqual(d.prepare('SELECT id FROM channel_moderators WHERE user_id = ?').get(mod).id, chatRow.id, 'Chat’s id');
- assert.strictEqual(outbox('channel_moderators').length, 0);
- res = await call('DELETE', `/api/channels/${channel.id}/mods/${mod}`, { headers: { Authorization: `Bearer ${streamerJwt}` } });
- assert.strictEqual(res.status, 200, res.text);
- assert.ok(!db.isChannelModerator(mod, channel.id) && !stubRows('channel_moderators').some((x) => x.user_id === mod));
- // Chat unreachable: the write fails and Live's copy is untouched.
- stub.down = true;
- res = await call('POST', `/api/channels/${channel.id}/mods`, { body: { username: 'moddy' }, headers: { Authorization: `Bearer ${streamerJwt}` } });
- assert.strictEqual(res.status, 500);
- assert.ok(!db.isChannelModerator(mod, channel.id), 'no local write when Chat writes the table');
- stub.down = false;
- // Other tables keep their own authority: emotes is still Live's.
- const e2 = Number((await chatTables.write('createEmote', { user_id: viewer, code: 'kek', url: '/e/kek.png' })).lastInsertRowid);
- assert.ok(db.getEmoteById(e2) && outbox('emotes').length === 1 && !stubRows('emotes').some((x) => x.code === 'kek'));
- await sync.flush();
-
- // 10. A handoff Chat refuses changes nothing; running it again is safe.
- stub.authority.emotes = 'chat';
- stub.refuseHandback = false;
- f = await internal('POST', '/chat-tables/emotes', { authority: 'chat' });
- assert.strictEqual(f.status, 200, 'Chat already agreed: the same call brings Live along');
- stub.down = true;
- f = await internal('POST', '/chat-tables/emotes', { authority: 'live' });
- assert.strictEqual(f.status, 503, f.text);
- assert.strictEqual(chatTables.authority('emotes'), 'chat', 'Chat unreachable: still Chat’s');
- stub.down = false;
- // Emote writes at 'chat' through the helper: Live's copy follows Chat's answer.
- const e3 = Number((await chatTables.write('createEmote', { user_id: viewer, code: 'lul', url: '/e/lul.png' })).lastInsertRowid);
- assert.strictEqual(db.getEmoteById(e3).code, 'lul');
- await chatTables.write('deleteEmote', e3);
- assert.strictEqual(db.getEmoteById(e3), undefined);
-
- stub.refuseHandback = true;
- f = await internal('POST', '/chat-tables/channel_moderators', { authority: 'live' });
- assert.deepStrictEqual([f.status, chatTables.authority('channel_moderators')], [409, 'chat'], 'Chat still has changes for Live: nothing moves');
- assert.match(f.body.error, /not in Live yet/);
- stub.refuseHandback = false;
-
- // 11. Back to 'live': Chat's changes first (the stub gave them), then Live writes and captures again.
- f = await internal('POST', '/chat-tables/channel_moderators', { authority: 'live' });
- assert.strictEqual(f.status, 200, f.text);
- assert.deepStrictEqual([f.body.table.authority, f.body.table.chat_authority], ['live', 'live']);
- await chatTables.write('addChannelModerator', channel.id, mod, streamer);
- assert.ok(db.isChannelModerator(mod, channel.id));
- assert.strictEqual(outbox('channel_moderators').length, 1, 'captured again');
- m = await mirror([{ table: 'channel_moderators', op: 'delete', pk: { id: 1 } }]);
- assert.strictEqual(m.body.applied, 0, 'Chat’s mirror is refused again');
- s = (await internal('GET', '/chat-tables')).body;
- assert.deepStrictEqual(Object.fromEntries(Object.entries(s.tables).map(([t, v]) => [t, v.authority])), { channel_moderators: 'live', channel_moderation_settings: 'live', emotes: 'chat', user_tags: 'live', chat_ai_summaries: 'live', chat_timeline_events: 'live' });
- assert.ok(Object.values(s.tables).every((v) => !v.disagree), 'Live and Chat agree on every table');
-
- // 12. Chat down with a table at 'chat': Live takes it back by force (after the operator drained
- // Chat's mirror); until Chat's side is set too, Chat refuses Live's changes and they wait.
- stub.down = true;
- assert.strictEqual((await internal('POST', '/chat-tables/emotes', { authority: 'chat', force: true })).status, 400, 'force only takes back');
- f = await internal('POST', '/chat-tables/emotes', { authority: 'live', force: true, by: 'test' });
- assert.strictEqual(f.status, 200, f.text);
- assert.strictEqual(chatTables.authority('emotes'), 'live');
- stub.down = false;
- await chatTables.write('updateEmote', e1, { code: 'pog2' });
- r = await sync.flush();
- assert.match(r.error, /OpenVibe.Chat writes emotes but Live does too/);
- assert.strictEqual(outbox('emotes').length, 1, 'Live’s change is kept');
- assert.strictEqual((await internal('GET', '/chat-tables')).body.tables.emotes.disagree, true);
- stub.authority.emotes = 'live'; // scripts/table-authority.js set emotes live --force, in Chat
- r = await sync.flush();
- assert.deepStrictEqual([r.pending, r.error], [0, null]);
- assert.strictEqual(stubRows('emotes').find((x) => x.id === e1).code, 'pog2');
-
- quiet('chat tables (C-04, Live side): all checks passed');
- } catch (err) {
- console.error(err);
- exit = 1;
- } finally {
- try { db.close(); } catch { /* */ }
- fs.rmSync(tmp, { recursive: true, force: true });
- process.exit(exit);
- }
-})();
+// roadmap T3: the six chat tables (channel_moderators, channel_moderation_settings, emotes,
+// user_tags, chat_ai_summaries, chat_timeline_events) are OpenVibe.Chat's, and this release deletes
+// the staged machinery this file exercised (chat-tables.js, chat-tables-sync.js, the
+// /internal/chat-tables* routes, the staged branch of the read mirror, the app_state keys and the
+// chat_staged_outbox/chat_dual_read_stats tables). There is nothing left here to check; the file
+// only reports that it is retired, and is listed with the machinery for physical removal under
+// "For Opus" (file deletion is not available in this run).
+console.log('T3 staged chat tables: skipped (roadmap T3: Chat owns the six tables)');
@@ -235,6 +235,25 @@ async function check(name, fn) {
@@ -235,6 +235,25 @@ async function check(name, fn) {
} finally { delete process.env.PASTES_AUTHORITY; }
} finally { delete process.env.PASTES_AUTHORITY; }
});
});
+ await check('setup hub: the emote count and moderator count come from OpenVibe.Chat', async () => {
+ const moderation = require('../server/chat/moderation-client');
+ const origCount = moderation.getEmoteCount, origMod = moderation.getChannelModeration;
+ moderation.getEmoteCount = async () => 5;
+ moderation.getChannelModeration = async () => ({ settings: { updated_at: '2026-09-01 00:00:00' }, moderator_ids: [7, 2] });
+ try {
+ const r = await call('GET', '/api/streams/setup-progress', 3);
+ assert.strictEqual(r.status, 200);
+ const task = (id) => r.json.tasks.find((t) => t.id === id);
+ assert.deepStrictEqual([task('emote').count, task('emote').done], [5, true]);
+ assert.deepStrictEqual([task('mods').count, task('mods').done], [2, true]);
+ assert.strictEqual(task('moderation').done, true, 'a saved settings row (updated_at) ticks the rules task');
+ moderation.getChannelModeration = async () => ({ settings: {}, moderator_ids: [] });
+ const r2 = await call('GET', '/api/streams/setup-progress', 3);
+ const t2 = (id) => r2.json.tasks.find((t) => t.id === id);
+ assert.strictEqual(t2('moderation').done, false, 'Chat defaults (no row) do not');
+ } finally { moderation.getEmoteCount = origCount; moderation.getChannelModeration = origMod; }
+ });
+
await check('avatar history: the avatar-tagged screenshots, from Community', async () => {
await check('avatar history: the avatar-tagged screenshots, from Community', async () => {
process.env.PASTES_AUTHORITY = 'community';
process.env.PASTES_AUTHORITY = 'community';
try {
try {
@@ -348,6 +348,7 @@ async function check(name, fn) {
@@ -348,6 +348,7 @@ async function check(name, fn) {
'server/comments-client.js': 'OpenVibe.Community (configured)',
'server/comments-client.js': 'OpenVibe.Community (configured)',
'server/chat/chat-remote.js': 'OpenVibe.Chat (configured)',
'server/chat/chat-remote.js': 'OpenVibe.Chat (configured)',
'server/chat/chat-tables.js': 'OpenVibe.Chat (configured)',
'server/chat/chat-tables.js': 'OpenVibe.Chat (configured)',
+ 'server/chat/moderation-client.js': 'OpenVibe.Chat internal read API (configured)',
'server/chat/chat-server.js': 'OpenVibe.Tools anon resolve (configured)',
'server/chat/chat-server.js': 'OpenVibe.Tools anon resolve (configured)',
'server/streaming/calls-authority.js': 'OpenVibe.Chat (configured)',
'server/streaming/calls-authority.js': 'OpenVibe.Chat (configured)',
'server/streaming/golive-notify.js': 'OpenVibe.Network (configured)',
'server/streaming/golive-notify.js': 'OpenVibe.Network (configured)',