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).

OP OpenVibers committed 29 Sept 2026 · 8cead36378 · 34 files, +714 −742


Changes

ddiff --git a/AGENTS.md b/AGENTS.md
iindex 700c8bea..f05e0c1b 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -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
ddiff --git a/docs/chat-system.md b/docs/chat-system.md
iindex 3fabf576..adf851eb 100644
---- a/docs/chat-system.md
++++ b/docs/chat-system.md
@@@ -30,6 +30,13 @@ with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches
unless `CHAT_AUTHORITY=chat`.
- 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.
++- **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
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 1635d71c..83dd9aba 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -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'); }
}
ddiff --git a/public/js/app-chatpage.js b/public/js/app-chatpage.js
iindex 0f3c565c..86dc782f 100644
---- a/public/js/app-chatpage.js
++++ b/public/js/app-chatpage.js
@@@ -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 || [];
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 5575e6f3..e552d14d 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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 out
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;
ddiff --git a/public/js/channel-uploads.js b/public/js/channel-uploads.js
iindex acef4253..b6f7cf8c 100644
---- a/public/js/channel-uploads.js
++++ b/public/js/channel-uploads.js
@@@ -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();
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex c8a29d8d..b3feba77 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -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();
ddiff --git a/public/js/dashboard-moderation.js b/public/js/dashboard-moderation.js
iindex b70c6883..cea880fc 100644
---- a/public/js/dashboard-moderation.js
++++ b/public/js/dashboard-moderation.js
@@@ -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(channelI
if (!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(ch
if (!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(ch
window.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(channe
if (!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);
ddiff --git a/server/admin/channel-mod-routes.js b/server/admin/channel-mod-routes.js
iindex df97b2fa..2c1d001b 100644
---- a/server/admin/channel-mod-routes.js
++++ b/server/admin/channel-mod-routes.js
@@@ -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;
ddiff --git a/server/admin/mod-routes.js b/server/admin/mod-routes.js
iindex 2687cb55..f74831a7 100644
---- a/server/admin/mod-routes.js
++++ b/server/admin/mod-routes.js
@@@ -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);
ddiff --git a/server/auth/account-data.js b/server/auth/account-data.js
iindex 0c6df8fd..1f98eedf 100644
---- a/server/auth/account-data.js
++++ b/server/auth/account-data.js
@@@ -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));
ddiff --git a/server/auth/permissions.js b/server/auth/permissions.js
iindex 660f44a8..3a0a3a13 100644
---- a/server/auth/permissions.js
++++ b/server/auth/permissions.js
@@@ -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,
ddiff --git a/server/chat/chat-remote.js b/server/chat/chat-remote.js
iindex f2be6b81..76f98a33 100644
---- a/server/chat/chat-remote.js
++++ b/server/chat/chat-remote.js
@@@ -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'),
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex fa77769f..512ec1cb 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -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 length
const 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 UI
this.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);
}
ddiff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
iindex 3bd80cfc..379b2d3f 100644
---- a/server/chat/live-context-routes.js
++++ b/server/chat/live-context-routes.js
@@@ -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.profi
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 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));
ddiff --git a/server/chat/moderation-client.js b/server/chat/moderation-client.js
nnew file mode 100644
iindex 00000000..c186e499
---- /dev/null
++++ b/server/chat/moderation-client.js
@@@ -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,
++};
ddiff --git a/server/chat/sounds-routes.js b/server/chat/sounds-routes.js
iindex 1c4ab3be..84765104 100644
---- a/server/chat/sounds-routes.js
++++ b/server/chat/sounds-routes.js
@@@ -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;
ddiff --git a/server/chat/tags.js b/server/chat/tags.js
iindex 8c47ed07..b63d6adc 100644
---- a/server/chat/tags.js
++++ b/server/chat/tags.js
@@@ -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 };
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 6950d049..ae6fc8ac 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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,
ddiff --git a/server/emotes/routes.js b/server/emotes/routes.js
iindex b7e31c80..6d7a8dad 100644
---- a/server/emotes/routes.js
++++ b/server/emotes/routes.js
@@@ -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' });
ddiff --git a/server/media-proxy/asset-sync.js b/server/media-proxy/asset-sync.js
iindex 087c89ca..9178bb6e 100644
---- a/server/media-proxy/asset-sync.js
++++ b/server/media-proxy/asset-sync.js
@@@ -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;
ddiff --git a/server/media-proxy/clips.js b/server/media-proxy/clips.js
iindex e576882b..02341239 100644
---- a/server/media-proxy/clips.js
++++ b/server/media-proxy/clips.js
@@@ -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');
ddiff --git a/server/media/routes.js b/server/media/routes.js
iindex 5bb6d8e5..2cf5bd33 100644
---- a/server/media/routes.js
++++ b/server/media/routes.js
@@@ -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');
ddiff --git a/server/monetization/alerts.js b/server/monetization/alerts.js
iindex a6721fdf..fe294301 100644
---- a/server/monetization/alerts.js
++++ b/server/monetization/alerts.js
@@@ -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 */ }
}
ddiff --git a/server/streaming/call-server.js b/server/streaming/call-server.js
iindex cf5310dd..f5729f21 100644
---- a/server/streaming/call-server.js
++++ b/server/streaming/call-server.js
@@@ -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;
}
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 14b25cfd..b77f662a 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -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 },
ddiff --git a/test/alerts-bridge.test.js b/test/alerts-bridge.test.js
nnew file mode 100644
iindex 00000000..1b0544e2
---- /dev/null
++++ b/test/alerts-bridge.test.js
@@@ -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');
ddiff --git a/test/channel-links.test.js b/test/channel-links.test.js
iindex 9f0dc5d9..3cc44d59 100644
---- a/test/channel-links.test.js
++++ b/test/channel-links.test.js
@@@ -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>' },
];
ddiff --git a/test/channel-moderation-routes.test.js b/test/channel-moderation-routes.test.js
iindex bef9d24c..cd118896 100644
---- a/test/channel-moderation-routes.test.js
++++ b/test/channel-moderation-routes.test.js
@@@ -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 () => {
ddiff --git a/test/chat-context.test.js b/test/chat-context.test.js
iindex 5bc75cb0..beae9051 100644
---- a/test/chat-context.test.js
++++ b/test/chat-context.test.js
@@@ -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');
ddiff --git a/test/chat-moderation-client.test.js b/test/chat-moderation-client.test.js
nnew file mode 100644
iindex 00000000..8cb3630c
---- /dev/null
++++ b/test/chat-moderation-client.test.js
@@@ -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); });
ddiff --git a/test/chat-tables.test.js b/test/chat-tables.test.js
iindex f6d6f5d9..6ac9e753 100644
---- a/test/chat-tables.test.js
++++ b/test/chat-tables.test.js
@@@ -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)');
ddiff --git a/test/media-lookups.test.js b/test/media-lookups.test.js
iindex 13507934..1afa01fb 100644
---- a/test/media-lookups.test.js
++++ b/test/media-lookups.test.js
@@@ -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 {
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 578bbccb..8524e3c9 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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)',