openvibers/openvibe.live Commits

Remove Live's inert local chat REST routes and their mounts (#19)

OP OpenVibers committed 4 Oct 2026 · 307b40100b · 14 files, +21 −2304

local checks ✓ 3/3 · CI ✓ 3/3 · review SHIP by DeepSeek harness (QUALITY=high)

https://github.com/OpenVibers/OpenVibe.Live/pull/19

Changes

ddiff --git a/docs/architecture.md b/docs/architecture.md
iindex cd91f61d..1340095f 100644
---- a/docs/architecture.md
++++ b/docs/architecture.md
@@@ -154,7 +154,7 @@ and refills history on reconnect.
| Log redaction | `server/utils/redact.js` | stream keys and credentials in log lines |
| Database | `server/db/database.js` | queries and inline table setup |
| Migrations | `server/db/migrations.js` | versioned, transactional migrations with a ledger (`schema_migrations`) |
--| Chat | `server/chat/chat-server.js`, `server/chat/routes.js` | WebSocket chat, history, moderation |
++| Chat | `server/chat/chat-server.js` | WebSocket chat, history, moderation (REST: OpenVibe.Chat) |
| Streams | `server/streaming/routes.js` | stream and slot CRUD, channel pages |
| Media proxy | `server/media-proxy/*.js` | VODs, clips, pastes, thumbnails via OpenVibe.Media |
| Auth | `server/auth/auth.js`, `server/auth/permissions.js` | JWT/API tokens, scopes, role ranks |
ddiff --git a/docs/chat-system.md b/docs/chat-system.md
iindex f916ff80..4abc7830 100644
---- a/docs/chat-system.md
++++ b/docs/chat-system.md
@@@ -5,7 +5,7 @@ The OpenVibe.Live chat system provides real-time messaging, moderation, and exte
## Architecture
- **WebSocket server**: `server/chat/chat-server.js` — manages connections, rooms, and message routing
--- **REST API**: `server/chat/routes.js` — moderation endpoints, message search, admin tools
++- **REST API**: OpenVibe.Chat (nginx sends `/api/chat`, `/api/dm`, `/api/tts`, `/api/sounds` there) — moderation endpoints, message search, admin tools. Live's local `server/chat/routes.js` is retired.
- **Client**: `public/js/chat.js` — rendering, emotes, TTS, settings sync
### OpenVibe.Chat (roadmap Wave 6)
ddiff --git a/server/chat/dm-routes.js b/server/chat/dm-routes.js
ddeleted file mode 100644
iindex dc748028..00000000
---- a/server/chat/dm-routes.js
++++ /dev/null
@@@ -1,438 +0,0 @@
--/**
-- * OpenVibe.Live — DM REST API Routes
-- *
-- * GET /api/dm/conversations — list conversations
-- * POST /api/dm/conversations — create or get 1-on-1 / group
-- * GET /api/dm/conversations/:id — get conversation details + participants
-- * GET /api/dm/conversations/:id/messages — paginated messages
-- * POST /api/dm/conversations/:id/messages — send a message
-- * POST /api/dm/conversations/:id/read — mark conversation read
-- * POST /api/dm/conversations/:id/participants — add participant to group
-- * DELETE /api/dm/conversations/:id/participants/:userId — remove participant
-- * PATCH /api/dm/conversations/:id — rename group
-- * GET /api/dm/unread — total unread count
-- * GET /api/dm/users/search — search users for new-message picker
-- */
--const express = require('express');
--const router = express.Router();
--const { requireAuth } = require('../auth/auth');
--const dm = require('./dm');
--
--// All DM routes require authentication
--router.use(requireAuth);
--
--// ── Per-user rate limiting (in-memory sliding window) ────────
--const _rateBuckets = new Map();
--const RATE_MSG_MAX = 10, RATE_MSG_WINDOW = 60_000; // 10 messages / 60s
--const RATE_CONV_MAX = 5, RATE_CONV_WINDOW = 3_600_000; // 5 new conversations / hour
--const MAX_GROUP_SIZE = 20;
--
--function _checkRate(userId, bucket, max, windowMs) {
-- if (!_rateBuckets.has(userId)) _rateBuckets.set(userId, { messages: [], convos: [] });
-- const u = _rateBuckets.get(userId);
-- const now = Date.now(), cutoff = now - windowMs;
-- u[bucket] = u[bucket].filter(t => t > cutoff);
-- if (u[bucket].length >= max) return false;
-- u[bucket].push(now);
-- return true;
--}
--// Not in a restore drill (LIVE_DRILL): it runs no timers, and it takes no DMs.
--if (!require('../drill').enabled) setInterval(() => {
-- const cutoff = Date.now() - 3_600_000;
-- for (const [uid, b] of _rateBuckets) {
-- b.messages = b.messages.filter(t => t > cutoff);
-- b.convos = b.convos.filter(t => t > cutoff);
-- if (!b.messages.length && !b.convos.length) _rateBuckets.delete(uid);
-- }
--}, 300_000);
--
--// List conversations for current user
--router.get('/conversations', (req, res) => {
-- try {
-- const conversations = dm.getConversations(req.user.id);
-- // Attach participant info to each conversation
-- for (const conv of conversations) {
-- conv.participants = dm.getParticipants(conv.id);
-- }
-- res.json({ conversations });
-- } catch (err) {
-- console.error('[DM] List conversations error:', err.message);
-- res.status(500).json({ error: 'Failed to load conversations' });
-- }
--});
--
--// Create or get a conversation
--router.post('/conversations', (req, res) => {
-- try {
-- const { user_ids, name } = req.body;
-- if (!user_ids || !Array.isArray(user_ids) || user_ids.length === 0) {
-- return res.status(400).json({ error: 'user_ids array required' });
-- }
-- const participantIds = [...new Set([req.user.id, ...user_ids.map(Number).filter(Boolean)])];
-- if (participantIds.length < 2) {
-- return res.status(400).json({ error: 'Need at least one other user' });
-- }
-- if (participantIds.length > MAX_GROUP_SIZE) {
-- return res.status(400).json({ error: `Groups are limited to ${MAX_GROUP_SIZE} members` });
-- }
--
-- // Rate limit conversation creation
-- if (!_checkRate(req.user.id, 'convos', RATE_CONV_MAX, RATE_CONV_WINDOW)) {
-- return res.status(429).json({ error: 'Too many new conversations. Try again later.' });
-- }
--
-- // New account restriction
-- const accountCheck = dm.isAccountTooNew(req.user.id, 5);
-- if (accountCheck.tooNew) {
-- return res.status(403).json({ error: `Your account is too new to start conversations. Please wait ${accountCheck.minutesRemaining} more minute(s).` });
-- }
--
-- // Validate all target users exist, aren't banned, aren't blocked
-- const userDb = require('../db/database');
-- for (const uid of participantIds) {
-- if (uid === req.user.id) continue;
-- const target = userDb.getUserById(uid);
-- if (!target) return res.status(400).json({ error: 'User not found' });
-- if (target.is_banned) return res.status(400).json({ error: 'Cannot message banned users' });
-- if (dm.isBlockedEither(req.user.id, uid)) {
-- return res.status(403).json({ error: 'Cannot start a conversation with this user' });
-- }
-- }
--
-- let conversationId;
-- if (participantIds.length === 2) {
-- const otherId = participantIds.find(id => id !== req.user.id);
-- conversationId = dm.getOrCreateDirect(req.user.id, otherId);
-- } else {
-- const safeName = name ? String(name).replace(/<[^>]*>/g, '').replace(/[\\`'"<>(){};:/\[\]]/g, '').replace(/\s+/g, ' ').trim().slice(0, 100) : null;
-- conversationId = dm.createConversation(req.user.id, participantIds, safeName);
-- }
--
-- const conversation = dm.getConversation(conversationId);
-- conversation.participants = dm.getParticipants(conversationId);
-- res.json({ conversation });
-- } catch (err) {
-- console.error('[DM] Create conversation error:', err.message);
-- res.status(500).json({ error: 'Failed to create conversation' });
-- }
--});
--
--// Get conversation details
--router.get('/conversations/:id', (req, res) => {
-- try {
-- const convId = parseInt(req.params.id);
-- if (!dm.isParticipant(convId, req.user.id)) {
-- return res.status(403).json({ error: 'Not a participant' });
-- }
-- const conversation = dm.getConversation(convId);
-- if (!conversation) return res.status(404).json({ error: 'Not found' });
-- conversation.participants = dm.getParticipants(convId);
-- res.json({ conversation });
-- } catch (err) {
-- console.error('[DM] Get conversation error:', err.message);
-- res.status(500).json({ error: 'Failed to load conversation' });
-- }
--});
--
--// Get messages (paginated)
--router.get('/conversations/:id/messages', (req, res) => {
-- try {
-- const convId = parseInt(req.params.id);
-- if (!dm.isParticipant(convId, req.user.id)) {
-- return res.status(403).json({ error: 'Not a participant' });
-- }
-- const limit = Math.min(parseInt(req.query.limit) || 50, 100);
-- const before = parseInt(req.query.before) || null;
-- const after = parseInt(req.query.after) || null;
-- const messages = dm.getMessages(convId, limit, before, after);
-- res.json({ messages });
-- } catch (err) {
-- console.error('[DM] Get messages error:', err.message);
-- res.status(500).json({ error: 'Failed to load messages' });
-- }
--});
--
--// Send a message
--router.post('/conversations/:id/messages', (req, res) => {
-- try {
-- const convId = parseInt(req.params.id);
-- if (!dm.isParticipant(convId, req.user.id)) {
-- return res.status(403).json({ error: 'Not a participant' });
-- }
--
-- // Per-user message rate limit
-- if (!_checkRate(req.user.id, 'messages', RATE_MSG_MAX, RATE_MSG_WINDOW)) {
-- return res.status(429).json({ error: 'Slow down! You\'re sending messages too fast.' });
-- }
--
-- // Block check for 1-on-1 conversations
-- const conv = dm.getConversation(convId);
-- if (conv && !conv.is_group) {
-- const participants = dm.getParticipants(convId);
-- const other = participants.find(p => p.id !== req.user.id);
-- if (other && dm.isBlockedEither(req.user.id, other.id)) {
-- return res.status(403).json({ error: 'Cannot send messages in this conversation' });
-- }
-- }
--
-- const { message } = req.body;
-- if (!message || typeof message !== 'string' || !message.trim()) {
-- return res.status(400).json({ error: 'Message required' });
-- }
--
-- const msg = dm.sendMessage(convId, req.user.id, message);
-- if (!msg) return res.status(400).json({ error: 'Failed to send' });
--
-- // Attach sender info
-- const db = require('../db/database');
-- const sender = db.getUserById(req.user.id);
-- msg.username = sender?.username;
-- msg.display_name = sender?.display_name;
-- msg.avatar_url = sender?.avatar_url;
-- msg.profile_color = sender?.profile_color;
--
-- // Real-time delivery via chat WebSocket
-- try {
-- const chatServer = require('./chat-server');
-- const participants = dm.getParticipants(convId);
-- for (const p of participants) {
-- if (p.id === req.user.id) continue; // skip sender
-- chatServer.sendDm(p.id, {
-- type: 'dm',
-- conversation_id: convId,
-- message: msg,
-- });
-- }
-- } catch { /* non-critical */ }
--
-- // Push notifications to other participants (for when they're offline)
-- try {
-- const { pushBulkNotification, actorInfo } = require('../utils/notify');
-- const participants = dm.getParticipants(convId);
-- const otherIds = participants.filter(p => p.id !== req.user.id).map(p => p.id);
-- if (otherIds.length) {
-- const senderName = sender?.display_name || sender?.username || 'Someone';
-- // Use generic preview — actual message content is only in real-time delivery
-- const preview = 'Sent you a message';
-- pushBulkNotification(otherIds, {
-- type: 'DIRECT_MESSAGE',
-- title: `Message from ${senderName}`,
-- message: preview,
-- url: `https://openvibe.live/?dm=${convId}`,
-- ...actorInfo(sender),
-- });
-- }
-- } catch { /* non-critical */ }
--
-- res.json({ message: msg });
-- } catch (err) {
-- console.error('[DM] Send message error:', err.message);
-- res.status(500).json({ error: 'Failed to send message' });
-- }
--});
--
--// Mark conversation read
--router.post('/conversations/:id/read', (req, res) => {
-- try {
-- const convId = parseInt(req.params.id);
-- if (!dm.isParticipant(convId, req.user.id)) {
-- return res.status(403).json({ error: 'Not a participant' });
-- }
-- dm.markRead(convId, req.user.id);
--
-- // Clear matching DM notifications in openvibe-tools
-- try {
-- const { markNotificationsRead } = require('../utils/notify');
-- markNotificationsRead(req.user.id, 'DIRECT_MESSAGE', `%/dm/${convId}`);
-- } catch { /* non-critical */ }
--
-- res.json({ ok: true });
-- } catch (err) {
-- console.error('[DM] Mark read error:', err.message);
-- res.status(500).json({ error: 'Failed' });
-- }
--});
--
--// Add participant to group
--router.post('/conversations/:id/participants', (req, res) => {
-- try {
-- const convId = parseInt(req.params.id);
-- if (!dm.isParticipant(convId, req.user.id)) {
-- return res.status(403).json({ error: 'Not a participant' });
-- }
-- const { user_id } = req.body;
-- if (!user_id) return res.status(400).json({ error: 'user_id required' });
--
-- // Validate target user exists, isn't banned, isn't blocked
-- const userDb = require('../db/database');
-- const target = userDb.getUserById(user_id);
-- if (!target) return res.status(400).json({ error: 'User not found' });
-- if (target.is_banned) return res.status(400).json({ error: 'Cannot add banned users' });
-- // A private 1:1 stays private: adding a third person would hand them the whole history.
-- const conv = dm.getConversation(convId);
-- if (!conv || !conv.is_group) {
-- return res.status(400).json({ error: 'Start a group conversation to add people' });
-- }
-- // Nobody already in the group should end up talking to someone they blocked (or who blocked them).
-- const members = dm.getParticipants(convId) || [];
-- if (members.some((p) => dm.isBlockedEither(p.user_id || p.id, user_id))) {
-- return res.status(403).json({ error: 'Cannot add this user' });
-- }
--
-- // Group size limit
-- if (dm.getParticipantCount(convId) >= MAX_GROUP_SIZE) {
-- return res.status(400).json({ error: `Group is full (max ${MAX_GROUP_SIZE} members)` });
-- }
--
-- dm.addParticipant(convId, user_id);
-- const participants = dm.getParticipants(convId);
--
-- // Notify all participants about the new member
-- try {
-- const chatServer = require('./chat-server');
-- for (const p of participants) {
-- chatServer.sendDm(p.id, {
-- type: 'dm-participant-added',
-- conversation_id: convId,
-- user_id,
-- participants,
-- });
-- }
-- } catch { /* non-critical */ }
--
-- res.json({ participants });
-- } catch (err) {
-- console.error('[DM] Add participant error:', err.message);
-- res.status(500).json({ error: 'Failed' });
-- }
--});
--
--// Remove participant from group
--router.delete('/conversations/:id/participants/:userId', (req, res) => {
-- try {
-- const convId = parseInt(req.params.id);
-- const targetUserId = parseInt(req.params.userId);
-- if (!dm.isParticipant(convId, req.user.id)) {
-- return res.status(403).json({ error: 'Not a participant' });
-- }
-- // Can only remove self (leave) or be the creator
-- const conv = dm.getConversation(convId);
-- if (targetUserId !== req.user.id && conv?.created_by !== req.user.id) {
-- return res.status(403).json({ error: 'Only the creator can remove others' });
-- }
-- dm.removeParticipant(convId, targetUserId);
-- res.json({ ok: true });
-- } catch (err) {
-- console.error('[DM] Remove participant error:', err.message);
-- res.status(500).json({ error: 'Failed' });
-- }
--});
--
--// Rename group conversation
--router.patch('/conversations/:id', (req, res) => {
-- try {
-- const convId = parseInt(req.params.id);
-- if (!dm.isParticipant(convId, req.user.id)) {
-- return res.status(403).json({ error: 'Not a participant' });
-- }
-- let { name } = req.body;
-- if (name) {
-- name = String(name).replace(/<[^>]*>/g, '').replace(/[\\`'"<>(){};:/\[\]]/g, '').replace(/\s+/g, ' ').trim().slice(0, 100);
-- }
-- dm.renameConversation(convId, name || null);
-- res.json({ ok: true });
-- } catch (err) {
-- console.error('[DM] Rename error:', err.message);
-- res.status(500).json({ error: 'Failed' });
-- }
--});
--
--// Total unread count
--router.get('/unread', (req, res) => {
-- try {
-- const total = dm.getTotalUnread(req.user.id);
-- res.json({ unread: total });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed' });
-- }
--});
--
--// Search users for new-message picker
--router.get('/users/search', (req, res) => {
-- try {
-- const q = (req.query.q || '').trim();
-- if (q.length < 2) return res.json({ users: [] });
-- const users = dm.searchUsers(q, req.user.id);
-- res.json({ users });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed' });
-- }
--});
--
--// ── Block & message management ───────────────────────────────
--
--// Block a user
--router.post('/blocks/:userId', (req, res) => {
-- try {
-- const targetId = parseInt(req.params.userId);
-- if (!targetId || targetId === req.user.id) {
-- return res.status(400).json({ error: 'Invalid user' });
-- }
-- dm.blockUser(req.user.id, targetId);
-- res.json({ ok: true });
-- } catch (err) {
-- console.error('[DM] Block error:', err.message);
-- res.status(500).json({ error: 'Failed to block user' });
-- }
--});
--
--// Unblock a user
--router.delete('/blocks/:userId', (req, res) => {
-- try {
-- const targetId = parseInt(req.params.userId);
-- dm.unblockUser(req.user.id, targetId);
-- res.json({ ok: true });
-- } catch (err) {
-- console.error('[DM] Unblock error:', err.message);
-- res.status(500).json({ error: 'Failed to unblock user' });
-- }
--});
--
--// List blocked users
--router.get('/blocks', (req, res) => {
-- try {
-- const blocked = dm.getBlockedUsers(req.user.id);
-- res.json({ blocked });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to load blocked users' });
-- }
--});
--
--// Check if a specific user is blocked
--router.get('/blocks/check/:userId', (req, res) => {
-- try {
-- const targetId = parseInt(req.params.userId);
-- res.json({ blocked: dm.hasBlocked(req.user.id, targetId) });
-- } catch {
-- res.json({ blocked: false });
-- }
--});
--
--// Delete own message
--router.delete('/conversations/:id/messages/:msgId', (req, res) => {
-- try {
-- const convId = parseInt(req.params.id);
-- const msgId = parseInt(req.params.msgId);
-- if (!dm.isParticipant(convId, req.user.id)) {
-- return res.status(403).json({ error: 'Not a participant' });
-- }
-- const deleted = dm.deleteMessage(msgId, req.user.id);
-- if (!deleted) return res.status(403).json({ error: 'Cannot delete this message' });
-- res.json({ ok: true });
-- } catch (err) {
-- console.error('[DM] Delete message error:', err.message);
-- res.status(500).json({ error: 'Failed to delete message' });
-- }
--});
--
--module.exports = router;
ddiff --git a/server/chat/routes.js b/server/chat/routes.js
ddeleted file mode 100644
iindex fffd3120..00000000
---- a/server/chat/routes.js
++++ /dev/null
@@@ -1,777 +0,0 @@
--/**
-- * OpenVibe.Live — Chat API Routes
-- *
-- * POST /api/chat/send - Send a chat message (REST fallback)
-- * GET /api/chat/:streamId/history - Get chat history for a stream
-- * GET /api/chat/:streamId/users - Get users in chat
-- * GET /api/chat/search - Search chat messages
-- * GET /api/chat/user/:userId/history - Get a user's chat history
-- * GET /api/chat/user/:username/profile - Get user profile card data
-- */
--const express = require('express');
--const db = require('../db/database');
--const { optionalAuth, requireAuth, requireAdmin } = require('../auth/auth');
--const permissions = require('../auth/permissions');
--const historyStore = require('./history-store');
--
--const router = express.Router();
--
--/**
-- * Attach each author's CURRENT cosmetics (name/particle/hat effects) + tag to
-- * history rows, so historical messages render with the same effects as live ones
-- * (chat_messages doesn't persist cosmetics). Cached per user_id within the batch.
-- */
--function enrichMessagesWithCosmetics(messages) {
-- let cosmetics = null, tags = null;
-- try { cosmetics = require('../monetization/cosmetics'); } catch { /* */ }
-- try { tags = require('./tags'); } catch { /* */ }
-- if ((!cosmetics && !tags) || !Array.isArray(messages)) return messages;
-- const cache = new Map();
-- for (const m of messages) {
-- if (!m || !m.user_id) continue;
-- let prof = cache.get(m.user_id);
-- if (!prof) {
-- prof = {};
-- try { if (cosmetics) Object.assign(prof, cosmetics.getCosmeticProfile(m.user_id) || {}); } catch { /* */ }
-- try { if (tags) prof.tag = tags.getTagProfile(m.user_id) || null; } catch { /* */ }
-- cache.set(m.user_id, prof);
-- }
-- if (prof.nameFX) m.nameFX = prof.nameFX;
-- if (prof.particleFX) m.particleFX = prof.particleFX;
-- if (prof.hatFX) m.hatFX = prof.hatFX;
-- if (prof.tag) m.tag = prof.tag;
-- }
-- return messages;
--}
--
--const MIN_SELF_DELETE_MINUTES = 3;
--const MAX_SELF_DELETE_MINUTES = 10080;
--const GIF_ALLOWED_PROVIDERS = new Set(['tenor', 'giphy']);
--
--function getGifProviderConfig(provider) {
-- if (provider === 'giphy') {
-- return {
-- key: db.getSetting('gif_giphy_api_key') || '',
-- trendingUrl: 'https://api.giphy.com/v1/gifs/trending',
-- searchUrl: 'https://api.giphy.com/v1/gifs/search',
-- };
-- }
-- return {
-- key: db.getSetting('gif_tenor_api_key') || '',
-- trendingUrl: 'https://tenor.googleapis.com/v2/featured',
-- searchUrl: 'https://tenor.googleapis.com/v2/search',
-- };
--}
--
--function normalizeGifProvider(provider) {
-- const normalized = String(provider || 'tenor').trim().toLowerCase();
-- return GIF_ALLOWED_PROVIDERS.has(normalized) ? normalized : 'tenor';
--}
--
--function mapGifResults(provider, payload) {
-- if (provider === 'giphy') {
-- const items = Array.isArray(payload?.data) ? payload.data : [];
-- return items.map((item) => {
-- const tiny = item?.images?.fixed_width_small?.url || item?.images?.preview_gif?.url || item?.images?.fixed_width?.url || '';
-- const full = item?.images?.original?.url || item?.images?.fixed_width?.url || tiny;
-- return {
-- id: item?.id || null,
-- preview_url: tiny,
-- full_url: full,
-- title: item?.title || item?.slug || 'GIF',
-- provider: 'giphy',
-- source_url: item?.url || full,
-- };
-- }).filter((item) => item.preview_url && item.full_url);
-- }
--
-- const items = Array.isArray(payload?.results) ? payload.results : [];
-- return items.map((item) => {
-- const tiny = item?.media_formats?.tinygif?.url || item?.media_formats?.gif?.url || '';
-- const full = item?.media_formats?.gif?.url || tiny;
-- return {
-- id: item?.id || null,
-- preview_url: tiny,
-- full_url: full,
-- title: item?.content_description || item?.title || 'GIF',
-- provider: 'tenor',
-- source_url: item?.itemurl || full,
-- };
-- }).filter((item) => item.preview_url && item.full_url);
--}
--
--function normalizeAutoDeleteMinutes(value) {
-- const mins = parseInt(value, 10);
-- if (!Number.isFinite(mins) || mins < MIN_SELF_DELETE_MINUTES) return 0;
-- return Math.min(MAX_SELF_DELETE_MINUTES, mins);
--}
--
--router.get('/gif/providers', optionalAuth, (req, res) => {
-- res.json({
-- providers: {
-- tenor: !!db.getSetting('gif_tenor_api_key'),
-- giphy: !!db.getSetting('gif_giphy_api_key'),
-- },
-- defaultProvider: db.getSetting('gif_tenor_api_key') ? 'tenor' : (db.getSetting('gif_giphy_api_key') ? 'giphy' : null),
-- });
--});
--
--router.get('/gif/trending', optionalAuth, async (req, res) => {
-- try {
-- const provider = normalizeGifProvider(req.query.provider);
-- const config = getGifProviderConfig(provider);
-- if (!config.key) return res.status(503).json({ error: `${provider} API key not configured` });
--
-- const url = new URL(config.trendingUrl);
-- if (provider === 'giphy') {
-- url.searchParams.set('api_key', config.key);
-- url.searchParams.set('limit', '30');
-- url.searchParams.set('rating', 'pg-13');
-- } else {
-- url.searchParams.set('key', config.key);
-- url.searchParams.set('limit', '30');
-- url.searchParams.set('media_filter', 'tinygif,gif');
-- }
--
-- const upstream = await fetch(url, { headers: { 'User-Agent': 'OpenVibe.Live/1.0' } });
-- if (!upstream.ok) return res.status(502).json({ error: `GIF provider request failed (${upstream.status})` });
-- const payload = await upstream.json();
-- res.json({ provider, results: mapGifResults(provider, payload) });
-- } catch (err) {
-- res.status(500).json({ error: err.message || 'Failed to load GIFs' });
-- }
--});
--
--router.get('/gif/search', optionalAuth, async (req, res) => {
-- try {
-- const provider = normalizeGifProvider(req.query.provider);
-- const query = String(req.query.q || '').trim();
-- if (query.length < 2) return res.status(400).json({ error: 'Query must be at least 2 characters' });
--
-- const config = getGifProviderConfig(provider);
-- if (!config.key) return res.status(503).json({ error: `${provider} API key not configured` });
--
-- const url = new URL(config.searchUrl);
-- if (provider === 'giphy') {
-- url.searchParams.set('api_key', config.key);
-- url.searchParams.set('q', query);
-- url.searchParams.set('limit', '30');
-- url.searchParams.set('rating', 'pg-13');
-- } else {
-- url.searchParams.set('key', config.key);
-- url.searchParams.set('q', query);
-- url.searchParams.set('limit', '30');
-- url.searchParams.set('media_filter', 'tinygif,gif');
-- }
--
-- const upstream = await fetch(url, { headers: { 'User-Agent': 'OpenVibe.Live/1.0' } });
-- if (!upstream.ok) return res.status(502).json({ error: `GIF provider request failed (${upstream.status})` });
-- const payload = await upstream.json();
-- res.json({ provider, results: mapGifResults(provider, payload) });
-- } catch (err) {
-- res.status(500).json({ error: err.message || 'Failed to search GIFs' });
-- }
--});
--
--/**
-- * Hydrate reply_to context onto an array of message rows.
-- * Each row must have reply_to_id (from DB). Adds a reply_to object
-- * with { id, username, user_id, message } for the parent message.
-- */
--function hydrateReplies(messages) {
-- const replyIds = [...new Set(messages.map(m => m.reply_to_id).filter(Boolean))];
-- if (!replyIds.length) return messages;
-- const placeholders = replyIds.map(() => '?').join(',');
-- const parents = db.all(
-- `SELECT id, username, user_id, message
-- FROM chat_messages
-- WHERE id IN (${placeholders})
-- AND is_deleted = 0
-- AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)`,
-- replyIds
-- );
-- const parentMap = new Map(parents.map(p => [p.id, p]));
-- return messages.map(m => {
-- if (m.reply_to_id && parentMap.has(m.reply_to_id)) {
-- const p = parentMap.get(m.reply_to_id);
-- m.reply_to = {
-- id: p.id,
-- username: p.username,
-- user_id: p.user_id,
-- message: p.message.length > 100 ? p.message.slice(0, 100) + '…' : p.message,
-- };
-- }
-- return m;
-- });
--}
--
--// ── Send Chat Message (REST fallback when WS is down) ────────
--router.post('/send', requireAuth, (req, res) => {
-- try {
-- const text = (req.body.message || '').trim();
-- // 6000 = the absolute ceiling any channel/admin can configure (see chat-server handleChatMessage).
-- if (!text || text.length > 6000) {
-- return res.status(400).json({ error: 'Invalid message' });
-- }
--
-- // Ban check
-- if (db.isUserBanned(req.user.id, null)) {
-- return res.status(403).json({ error: 'You are banned from chat' });
-- }
--
-- const chatServer = require('./chat-server');
--
-- // Word filter
-- const wordFilter = require('./word-filter');
-- const filterResult = wordFilter.check(text);
-- const filtered = filterResult.safe ? text : filterResult.filtered;
--
-- if (wordFilter.isSpam(filtered)) {
-- return res.status(400).json({ error: 'Message blocked: detected as spam' });
-- }
--
-- const username = req.user.display_name || req.user.username;
-- const autoDeleteMinutes = normalizeAutoDeleteMinutes(req.body.auto_delete_minutes);
-- const autoDeleteAt = autoDeleteMinutes
-- ? new Date(Date.now() + autoDeleteMinutes * 60 * 1000).toISOString()
-- : null;
--
-- // Reply-to support
-- const replyToId = req.body.reply_to_id ? parseInt(req.body.reply_to_id) : null;
-- let replyTo = null;
-- if (replyToId) {
-- const parent = db.getChatMessageById(replyToId);
-- if (parent && !parent.is_deleted) {
-- replyTo = {
-- id: parent.id,
-- username: parent.username,
-- user_id: parent.user_id,
-- message: parent.message.length > 100 ? parent.message.slice(0, 100) + '…' : parent.message,
-- };
-- }
-- }
--
-- const chatMsg = {
-- type: 'chat',
-- username,
-- core_username: req.user.username,
-- user_id: req.user.id,
-- anon_id: null,
-- role: req.user.role || 'user',
-- message: filtered,
-- stream_id: null,
-- is_global: true,
-- avatar_url: req.user.avatar_url || null,
-- profile_color: req.user.profile_color || '#999',
-- filtered: !filterResult.safe,
-- timestamp: new Date().toISOString(),
-- auto_delete_at: autoDeleteAt,
-- };
--
-- // Attach cosmetics
-- try {
-- const cosmetics = require('../monetization/cosmetics');
-- const cosmeticProfile = cosmetics.getCosmeticProfile(req.user.id);
-- if (cosmeticProfile.nameFX) chatMsg.nameFX = cosmeticProfile.nameFX;
-- if (cosmeticProfile.particleFX) chatMsg.particleFX = cosmeticProfile.particleFX;
-- if (cosmeticProfile.hatFX) chatMsg.hatFX = cosmeticProfile.hatFX;
-- if (cosmeticProfile.voiceFX) chatMsg.voiceFX = cosmeticProfile.voiceFX;
-- } catch { /* non-critical */ }
--
-- // Attach tag
-- try {
-- const tags = require('./tags');
-- const tagProfile = tags.getTagProfile(req.user.id);
-- if (tagProfile) chatMsg.tag = tagProfile;
-- } catch { /* non-critical */ }
--
-- // Save to database
-- let savedId = null;
-- try {
-- const result = db.saveChatMessage({
-- stream_id: null,
-- user_id: req.user.id,
-- anon_id: null,
-- username,
-- message: filtered,
-- message_type: 'chat',
-- is_global: true,
-- reply_to_id: replyToId,
-- auto_delete_at: autoDeleteAt,
-- });
-- savedId = result.lastInsertRowid;
-- } catch { /* non-critical */ }
--
-- // Attach message ID and reply context to broadcast
-- if (savedId) chatMsg.id = Number(savedId);
-- if (replyTo) chatMsg.reply_to = replyTo;
--
-- // Broadcast to all global chat clients
-- chatServer.broadcastGlobal(chatMsg);
-- res.json({ ok: true });
-- } catch (err) {
-- console.error('[Chat] REST send error:', err.message);
-- res.status(500).json({ error: 'Failed to send message' });
-- }
--});
--
--// ── Search Chat Messages (admin or self) ─────────────────────
--router.get('/search', requireAuth, (req, res) => {
-- try {
-- const limit = Math.min(parseInt(req.query.limit || '50'), 200);
-- const offset = parseInt(req.query.offset || '0');
-- const query = req.query.q || '';
-- const userId = req.query.user_id ? parseInt(req.query.user_id) : null;
-- const streamId = req.query.stream_id ? parseInt(req.query.stream_id) : null;
--
-- // Admin / global_mod can search anyone; others search only their own
-- const effectiveUserId = permissions.canViewOtherUserLogs(req.user) ? userId : req.user.id;
--
-- const result = db.searchChatMessages({
-- query, userId: effectiveUserId, streamId, limit, offset,
-- });
--
-- res.json(result);
-- } catch (err) {
-- res.status(500).json({ error: 'Search failed' });
-- }
--});
--
--// ── User Chat History ────────────────────────────────────────
--router.get('/user/:userId/history', requireAuth, (req, res) => {
-- try {
-- const userId = parseInt(req.params.userId);
-- const limit = Math.min(parseInt(req.query.limit || '50'), 200);
-- const offset = parseInt(req.query.offset || '0');
--
-- // Admin / global_mod can view anyone; others view only their own
-- if (!permissions.canViewOtherUserLogs(req.user) && req.user.id !== userId) {
-- return res.status(403).json({ error: 'Access denied' });
-- }
--
-- const result = db.getUserChatHistory(userId, limit, offset);
-- res.json(result);
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to get chat history' });
-- }
--});
--
--// ── User Profile Card ────────────────────────────────────────
--router.get('/user/:username/profile', optionalAuth, (req, res) => {
-- try {
-- // Try core username first, then fall back to display_name lookup
-- let user = db.getUserByUsername(req.params.username);
-- if (!user) {
-- user = db.get('SELECT * FROM users WHERE display_name = ? COLLATE NOCASE', [req.params.username]);
-- }
-- if (!user) return res.status(404).json({ error: 'User not found' });
--
-- const profile = db.getUserProfile(user.id);
-- if (!profile) return res.status(404).json({ error: 'Profile not found' });
-- // The card shows OpenCoins (earned, part of the game). Vibes are real money and presence is
-- // personal, so both stay with the user themselves.
-- if (!req.user || req.user.id !== user.id) {
-- delete profile.openvibe_bucks_balance;
-- delete profile.last_seen;
-- }
--
-- // Legacy game skills, read-only (never creates a game_players row)
-- const game = db.getLegacyGameProfile(user.id);
-- if (game) profile.game = game;
--
-- res.json(profile);
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to get profile' });
-- }
--});
--
--// ── Chat-relay (external) user info: join date (first message) etc. ──
--router.get('/relay-user/:platform/:username', optionalAuth, (req, res) => {
-- try {
-- const r = db.getRelayUser(req.params.platform, req.params.username);
-- res.json({ relayUser: r || null });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to get relay user' });
-- }
--});
--
--// Anonymous chatter info: first-seen (anon-number assignment) + first chat + count.
--router.get('/anon/:anonId', optionalAuth, (req, res) => {
-- try {
-- const anonId = String(req.params.anonId || '');
-- if (!/^anon\d+$/i.test(anonId)) return res.status(400).json({ error: 'Invalid anon id' });
-- res.json({ anon: db.getAnonMeta(anonId) });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to get anon info' });
-- }
--});
--
--// An anonymous chatter's chat-message history — same gate as native logs.
--router.get('/anon/:anonId/logs', requireAuth, (req, res) => {
-- try {
-- if (!permissions.canViewOtherUserLogs(req.user)) return res.status(403).json({ error: 'Not authorized' });
-- const anonId = String(req.params.anonId || '');
-- if (!/^anon\d+$/i.test(anonId)) return res.status(400).json({ error: 'Invalid anon id' });
-- const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
-- const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
-- const query = String(req.query.q || '').trim();
-- const r = db.getAnonChatHistory(anonId, { limit, offset, query });
-- res.json(r);
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to get anon user logs' });
-- }
--});
--
--// A relay (external-platform) user's chat-message history — same gate as native logs.
--router.get('/relay-user/:platform/:username/logs', requireAuth, (req, res) => {
-- try {
-- if (!permissions.canViewOtherUserLogs(req.user)) return res.status(403).json({ error: 'Not authorized' });
-- const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
-- const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
-- const query = String(req.query.q || '').trim();
-- const r = db.getRelayUserChatHistory(req.params.platform, req.params.username, { limit, offset, query });
-- res.json(r);
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to get relay user logs' });
-- }
--});
--
--// ── Global Chat History (all streams) ────────────────────────
--// Patterns behind the viewer-side "Friendly global chat" setting (server/chat/moderation-utils.js is
--// the single source; the client compiles these).
--router.get('/filters/friendly', (req, res) => {
-- const { FRIENDLY_FILTER_CATEGORIES } = require('./moderation-utils');
-- res.set('Cache-Control', 'public, max-age=3600');
-- res.json({ version: 1, categories: FRIENDLY_FILTER_CATEGORIES.map(({ key, label, patterns }) => ({ key, label, patterns })) });
--});
--
--router.get('/global/history', optionalAuth, (req, res) => {
-- try {
-- const limit = req.query.limit;
-- const before = req.query.before;
-- const afterId = req.query.after_id;
-- const channelUsername = String(req.query.username || '').trim();
-- const decorate = (rows) => enrichMessagesWithCosmetics(hydrateReplies(rows));
-- // after_id = cursor read: only what the caller has not seen (oldest→newest, PK range scan).
-- // Anything else = a page: the newest rows, optionally before a timestamp.
-- const out = afterId != null
-- ? historyStore.delta('global', { afterId, limit, channelUsername, decorate })
-- : historyStore.page('global', { limit, before, channelUsername, decorate });
-- res.json(out);
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to get global chat history' });
-- }
--});
--
--// ── Chat Replay (for VOD/clip playback sync) ────────────────
--router.get('/:streamId/replay', optionalAuth, (req, res) => {
-- try {
-- const streamId = parseInt(req.params.streamId);
-- if (!streamId) return res.status(400).json({ error: 'Invalid stream ID' });
--
-- const from = req.query.from || null; // ISO timestamp
-- const to = req.query.to || null; // ISO timestamp
--
-- const messages = db.getChatReplay(streamId, from, to);
-- res.json({ messages });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to get chat replay' });
-- }
--});
--
--// ── Chat History ─────────────────────────────────────────────
--// Loads history for a streamer's chat. By default this spans ALL of that
--// broadcaster's stream sessions (not just the one slot/session being viewed),
--// so viewers keep context when a stream restarts or the streamer runs multiple
--// slots. Each message carries its source stream (title/slug/live) so the client
--// can label it and let viewers hop between the streamer's live slots.
--// Pass ?scope=stream to restrict to the single session (legacy behavior).
--router.get('/:streamId/history', optionalAuth, (req, res) => {
-- try {
-- const limit = Math.min(parseInt(req.query.limit || '500'), 500);
-- const before = req.query.before; // ISO timestamp for pagination
-- const scope = req.query.scope || 'streamer';
--
-- // Resolve which broadcaster this stream belongs to.
-- const stream = db.getStreamById(req.params.streamId);
-- const broadcasterId = stream ? stream.user_id : null;
-- const spanStreamer = scope !== 'stream' && broadcasterId;
--
-- // COALESCE the channel name from the source stream's owner (live rows) OR the
-- // message's channel_user_id (offline rows) so the streamer/channel tag renders
-- // in history for both. cu = the broadcaster resolved from channel_user_id.
-- const select = `SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name,
-- u.username AS core_username,
-- s.title AS source_stream_title, s.managed_stream_id AS source_managed_id,
-- s.is_live AS source_is_live, ms.slug AS source_slug,
-- COALESCE(bu.username, cu.username) AS source_channel`;
-- let sql;
-- const params = [];
-- if (spanStreamer) {
-- // Span by channel_user_id (LEFT JOIN streams) so OFFLINE messages
-- // (stream_id NULL) survive when the streamer goes live — the old INNER
-- // JOIN on streams dropped them.
-- sql = `${select}
-- FROM chat_messages cm
-- LEFT JOIN users u ON cm.user_id = u.id
-- LEFT JOIN streams s ON cm.stream_id = s.id
-- LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id
-- LEFT JOIN users bu ON s.user_id = bu.id
-- LEFT JOIN users cu ON cm.channel_user_id = cu.id
-- WHERE cm.channel_user_id = ? AND cm.is_deleted = 0
-- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
-- params.push(broadcasterId);
-- } else {
-- sql = `${select}
-- FROM chat_messages cm
-- LEFT JOIN users u ON cm.user_id = u.id
-- LEFT JOIN streams s ON cm.stream_id = s.id
-- LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id
-- LEFT JOIN users bu ON s.user_id = bu.id
-- LEFT JOIN users cu ON cm.channel_user_id = cu.id
-- WHERE cm.stream_id = ? AND cm.is_deleted = 0
-- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
-- params.push(req.params.streamId);
-- }
--
-- // after_id: cursor read (oldest→newest, primary-key range scan) for reopen/reconnect.
-- // Otherwise a page: the newest rows, optionally before a timestamp.
-- const afterId = req.query.after_id != null ? Math.max(0, parseInt(req.query.after_id, 10) || 0) : null;
-- if (afterId != null) {
-- sql += ` AND cm.id > ? ORDER BY cm.id ASC LIMIT ?`;
-- params.push(afterId, limit + 1);
-- } else {
-- if (before) {
-- sql += ` AND cm.timestamp < ?`;
-- params.push(before);
-- }
-- sql += ` ORDER BY cm.timestamp DESC LIMIT ?`;
-- params.push(limit);
-- }
--
-- let rows = db.all(sql, params);
-- let complete = true;
-- if (afterId != null) { complete = rows.length <= limit; rows = rows.slice(0, limit); } else rows.reverse();
-- const latest_id = rows.reduce((m, x) => (x.id > m ? x.id : m), 0) || (afterId || 0);
-- const messages = enrichMessagesWithCosmetics(hydrateReplies(rows));
--
-- // Currently-live slots for this broadcaster — lets the client render a
-- // "hop between live streams" affordance.
-- let liveSlots = [];
-- let channel = null;
-- if (broadcasterId) {
-- try {
-- const owner = db.getUserById(broadcasterId);
-- channel = owner ? owner.username : null;
-- liveSlots = (db.getManagedStreamsByUserId(broadcasterId) || [])
-- .filter(ms => ms.is_currently_live)
-- .map(ms => ({
-- managed_stream_id: ms.id,
-- slug: ms.slug,
-- title: ms.title,
-- live_session_id: ms.live_session_id,
-- }));
-- } catch { /* non-critical */ }
-- }
-- res.json({
-- messages, latest_id, complete, liveSlots, channel,
-- activeStreamId: parseInt(req.params.streamId) || null,
-- activeManagedId: stream ? (stream.managed_stream_id || null) : null,
-- });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to get chat history' });
-- }
--});
--
--// Persistent per-streamer chat history keyed by the broadcaster's USER id — spans
--// every live slot AND offline periods (channel_user_id), so it works when the
--// streamer has no active session. Used by the channel page (online + offline).
--router.get('/channel/:userId/history', optionalAuth, (req, res) => {
-- try {
-- const userId = parseInt(req.params.userId);
-- if (!userId) return res.status(400).json({ error: 'Bad user id' });
-- const decorate = (rows) => enrichMessagesWithCosmetics(hydrateReplies(rows));
-- const room = `channel:${userId}`;
-- const out = req.query.after_id != null
-- ? historyStore.delta(room, { afterId: req.query.after_id, limit: req.query.limit, decorate })
-- : historyStore.page(room, { limit: req.query.limit, before: req.query.before, decorate });
-- let liveSlots = [], channel = null;
-- try {
-- const owner = db.getUserById(userId);
-- channel = owner ? owner.username : null;
-- liveSlots = (db.getManagedStreamsByUserId(userId) || [])
-- .filter(ms => ms.is_currently_live)
-- .map(ms => ({ managed_stream_id: ms.id, slug: ms.slug, title: ms.title, live_session_id: ms.live_session_id }));
-- } catch { /* non-critical */ }
-- res.json({ ...out, liveSlots, channel, activeStreamId: null, activeManagedId: null });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to get channel chat history' });
-- }
--});
--
--// ── Chat User Count ──────────────────────────────────────────
--router.get('/:streamId/users', (req, res) => {
-- const chatServer = require('./chat-server');
-- const count = chatServer.getStreamViewerCount(parseInt(req.params.streamId));
-- res.json({ count });
--});
--
--// ── Chat Log Management ─────────────────────────────────────
--
--// Preview count of messages in a time range
--router.post('/admin/purge/preview', requireAuth, (req, res) => {
-- try {
-- const { streamId, from, to } = req.body;
-- if (!from || !to) return res.status(400).json({ error: 'from and to are required' });
--
-- // Must be admin or stream owner
-- let effectiveStreamId = streamId || null;
-- if (!permissions.isAdmin(req.user)) {
-- if (streamId) {
-- const stream = db.getStreamById(streamId);
-- if (!stream || stream.user_id !== req.user.id) {
-- return res.status(403).json({ error: 'Not authorized' });
-- }
-- } else {
-- // Non-admin without streamId: scope to their most recent stream
-- const userStreams = db.getStreamsByUserId(req.user.id, 1);
-- if (!userStreams?.length) {
-- return res.json({ count: 0 });
-- }
-- effectiveStreamId = userStreams[0].id;
-- }
-- }
--
-- const count = db.countChatMessagesByTimeRange(effectiveStreamId, from, to);
-- res.json({ count });
-- } catch (e) {
-- console.error('[Chat] Purge preview error:', e.message);
-- res.status(500).json({ error: 'Failed to count messages' });
-- }
--});
--
--// Delete messages in a time range (soft delete)
--router.delete('/admin/purge', requireAuth, (req, res) => {
-- try {
-- const { streamId, from, to } = req.body;
-- if (!from || !to) return res.status(400).json({ error: 'from and to are required' });
--
-- let effectiveStreamId = streamId || null;
-- if (!permissions.isAdmin(req.user)) {
-- if (streamId) {
-- const stream = db.getStreamById(streamId);
-- if (!stream || stream.user_id !== req.user.id) {
-- return res.status(403).json({ error: 'Not authorized' });
-- }
-- } else {
-- // Non-admin without streamId: scope to their most recent stream
-- const userStreams = db.getStreamsByUserId(req.user.id, 1);
-- if (!userStreams?.length) {
-- return res.json({ deleted: 0 });
-- }
-- effectiveStreamId = userStreams[0].id;
-- }
-- }
--
-- const result = db.deleteChatMessagesByTimeRange(effectiveStreamId, from, to, req.user.display_name || req.user.username);
--
-- // Broadcast delete event to live chat
-- try {
-- const chatServer = require('./chat-server');
-- const payload = { type: 'purge', streamId: effectiveStreamId, from, to, by: req.user.display_name };
-- if (effectiveStreamId) {
-- chatServer.broadcastToStream(effectiveStreamId, payload);
-- } else {
-- chatServer.broadcastGlobal(payload);
-- }
-- } catch { /* chat server may not be initialized */ }
--
-- console.log(`[Chat] Purged messages: stream=${effectiveStreamId || 'global'} from=${from} to=${to} by=${req.user.username} changes=${result?.changes || 0}`);
-- res.json({ deleted: result?.changes || 0 });
-- } catch (e) {
-- console.error('[Chat] Purge error:', e.message);
-- res.status(500).json({ error: 'Failed to purge messages' });
-- }
--});
--
--// Get paginated chat logs with filters
--router.get('/admin/logs', requireAuth, (req, res) => {
-- try {
-- const { streamId, username, search, from, to, messageType, page, limit, includeDeleted } = req.query;
--
-- // Must be admin or stream owner
-- let effectiveStreamId = streamId ? parseInt(streamId) : undefined;
-- if (!permissions.isAdmin(req.user)) {
-- if (streamId) {
-- const stream = db.getStreamById(parseInt(streamId));
-- if (!stream || stream.user_id !== req.user.id) {
-- return res.status(403).json({ error: 'Not authorized' });
-- }
-- } else {
-- // Non-admin without streamId: scope to their most recent stream
-- const userStreams = db.getStreamsByUserId(req.user.id, 1);
-- if (!userStreams?.length) {
-- return res.json({ rows: [], total: 0, page: 1, limit: 50, totalPages: 0 });
-- }
-- effectiveStreamId = userStreams[0].id;
-- }
-- }
--
-- const result = db.getChatLogs({
-- streamId: effectiveStreamId,
-- username, search, from, to, messageType,
-- page: parseInt(page) || 1,
-- limit: Math.min(parseInt(limit) || 50, 200),
-- includeDeleted: includeDeleted === 'true' && permissions.isAdmin(req.user),
-- });
-- res.json(result);
-- } catch (e) {
-- console.error('[Chat] Logs error:', e.message);
-- res.status(500).json({ error: 'Failed to get chat logs' });
-- }
--});
--
--// Export chat logs as CSV or JSON
--router.get('/admin/logs/export', requireAuth, (req, res) => {
-- try {
-- const { streamId, username, search, from, to, messageType, format } = req.query;
--
-- if (!permissions.isAdmin(req.user)) {
-- if (streamId) {
-- const stream = db.getStreamById(parseInt(streamId));
-- if (!stream || stream.user_id !== req.user.id) {
-- return res.status(403).json({ error: 'Not authorized' });
-- }
-- } else {
-- return res.status(403).json({ error: 'Only admins can export all chat logs' });
-- }
-- }
--
-- // Get all matching rows (up to 50k)
-- const result = db.getChatLogs({
-- streamId: streamId ? parseInt(streamId) : undefined,
-- username, search, from, to, messageType,
-- page: 1, limit: 50000,
-- });
--
-- if (format === 'csv') {
-- const header = 'id,timestamp,username,message,message_type,stream_id,is_global,source_platform\n';
-- const csvRows = result.rows.map(r => {
-- const msg = (r.message || '').replace(/"/g, '""');
-- return `${r.id},"${r.timestamp}","${(r.username || '').replace(/"/g, '""')}","${msg}","${r.message_type || ''}",${r.stream_id || ''},"${r.is_global || 0}","${r.source_platform || ''}"`;
-- });
-- res.setHeader('Content-Type', 'text/csv');
-- res.setHeader('Content-Disposition', `attachment; filename="chat-logs-${Date.now()}.csv"`);
-- res.send(header + csvRows.join('\n'));
-- } else {
-- res.setHeader('Content-Type', 'application/json');
-- res.setHeader('Content-Disposition', `attachment; filename="chat-logs-${Date.now()}.json"`);
-- res.json(result.rows);
-- }
-- } catch (e) {
-- console.error('[Chat] Export error:', e.message);
-- res.status(500).json({ error: 'Failed to export chat logs' });
-- }
--});
--
--module.exports = router;
ddiff --git a/server/chat/sounds-routes.js b/server/chat/sounds-routes.js
ddeleted file mode 100644
iindex 84765104..00000000
---- a/server/chat/sounds-routes.js
++++ /dev/null
@@@ -1,392 +0,0 @@
--/**
-- * OpenVibe.Live — Channel Sound Commands API
-- *
-- * Per-channel, viewer-uploadable sound clips triggered by !command in chat.
-- *
-- * GET /api/sounds/channel/:userId - Sounds for a streamer's channel
-- * GET /api/sounds/all/:streamId - Sounds available in a stream context
-- * POST /api/sounds - Upload a channel sound (auth required)
-- * DELETE /api/sounds/:id - Delete a sound (uploader / mod / owner)
-- * GET /api/sounds/file/:filename - Serve a sound file
-- */
--const express = require('express');
--const path = require('path');
--const fs = require('fs');
--const { spawn } = require('child_process');
--const multer = require('multer');
--const db = require('../db/database');
--const { requireAuth } = require('../auth/auth');
--const permissions = require('../auth/permissions');
--const config = require('../config');
--
--const router = express.Router();
--
--// Commands already handled elsewhere in chat — cannot be overridden by a sound.
--const RESERVED_COMMANDS = new Set([
-- 'sb', 'gotti', 'sr', 'yt', 'youtube', 'req', 'request', 'queue', 'np',
-- 'nowplaying', 'watching', 'skip', 'mediahelp', 'say',
-- 'forward', 'backward', 'left', 'right', 'liftup', 'liftdown', 'headup', 'headdown',
--]);
--
--const MIME_TO_EXT = {
-- 'audio/mpeg': '.mp3', 'audio/mp3': '.mp3', 'audio/wav': '.wav', 'audio/x-wav': '.wav',
-- 'audio/wave': '.wav', 'audio/vnd.wave': '.wav', 'audio/x-pn-wav': '.wav',
-- 'audio/ogg': '.ogg', 'audio/webm': '.webm', 'audio/mp4': '.m4a', 'audio/x-m4a': '.m4a',
-- 'audio/aac': '.aac', 'audio/flac': '.flac', 'audio/x-flac': '.flac', 'audio/opus': '.opus',
--};
--// Browsers/OSes report .wav (and others) under many mimetypes — or none at all
--// (application/octet-stream). Fall back to the file extension so uploads don't wrongly fail.
--const ALLOWED_EXT = new Set(['.mp3', '.wav', '.ogg', '.oga', '.opus', '.webm', '.m4a', '.mp4', '.aac', '.flac']);
--
--function extFor(file) {
-- if (MIME_TO_EXT[file.mimetype]) return MIME_TO_EXT[file.mimetype];
-- const ext = path.extname(file.originalname || '').toLowerCase();
-- return ALLOWED_EXT.has(ext) ? ext : '';
--}
--
--function soundsDir() {
-- const dir = path.resolve(config.sounds.path);
-- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
-- return dir;
--}
--
--const soundStorage = multer.diskStorage({
-- destination: (req, file, cb) => cb(null, soundsDir()),
-- filename: (req, file, cb) => {
-- const ext = extFor(file) || '.bin';
-- cb(null, `snd-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
-- },
--});
--const soundUpload = multer({
-- storage: soundStorage,
-- limits: { fileSize: config.sounds.maxSizeKb * 1024 },
-- fileFilter: (req, file, cb) => {
-- // Accept by known audio mimetype OR by a recognized audio extension.
-- if (MIME_TO_EXT[file.mimetype] || ALLOWED_EXT.has(path.extname(file.originalname || '').toLowerCase())) {
-- cb(null, true);
-- } else {
-- cb(new Error('Only audio files are allowed (MP3, WAV, OGG, Opus, WebM, M4A, AAC, FLAC).'));
-- }
-- },
--});
--
--/** Transcode an audio file to a canonical MP3 (fast decode, small, universal). Returns the
-- * new path on success (original left for the caller to delete), or null on failure. */
--function convertToMp3(srcPath) {
-- return new Promise((resolve) => {
-- const outPath = srcPath.replace(/\.[^.]+$/, '') + '.conv.mp3';
-- let proc;
-- try {
-- proc = spawn('ffmpeg', ['-y', '-i', srcPath, '-vn', '-ac', '2', '-ar', '44100', '-c:a', 'libmp3lame', '-b:a', '128k', outPath]);
-- } catch { return resolve(null); }
-- proc.on('close', (code) => resolve(code === 0 && fs.existsSync(outPath) ? outPath : null));
-- proc.on('error', () => resolve(null));
-- setTimeout(() => { try { proc.kill(); } catch {} resolve(null); }, 20000);
-- });
--}
--
--/** Probe an audio file's duration in seconds (0 on failure). */
--function probeDuration(filePath) {
-- return new Promise((resolve) => {
-- let done = false;
-- const finish = (v) => { if (!done) { done = true; resolve(v); } };
-- let out = '';
-- let probe;
-- try {
-- probe = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', filePath]);
-- } catch { return finish(0); }
-- probe.stdout.on('data', (d) => { out += d; });
-- probe.on('close', () => {
-- try {
-- const info = JSON.parse(out);
-- const dur = parseFloat(info.format?.duration || '0');
-- finish(Number.isFinite(dur) && dur > 0 ? dur : 0);
-- } catch { finish(0); }
-- });
-- probe.on('error', () => finish(0));
-- setTimeout(() => { try { probe.kill(); } catch {} finish(0); }, 10000);
-- });
--}
--
--function serializeSound(s) {
-- return {
-- id: s.id,
-- command: s.command,
-- url: `/api/sounds/file/${path.basename(s.url)}`,
-- duration_seconds: Math.round((s.duration_seconds || 0) * 10) / 10,
-- uploader: s.created_by_name || 'someone',
-- uploader_id: s.created_by,
-- emote_code: s.emote_code || '',
-- created_at: s.created_at,
-- };
--}
--
--// ── List a channel's sounds ──────────────────────────────────
--router.get('/channel/:userId', (req, res) => {
-- try {
-- const userId = parseInt(req.params.userId);
-- if (!userId) return res.json({ sounds: [] });
-- res.json({ sounds: db.getChannelSounds(userId).map(serializeSound) });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to load channel sounds' });
-- }
--});
--
--// ── List sounds available in a stream context ────────────────
--router.get('/all/:streamId', (req, res) => {
-- try {
-- const stream = db.getStreamById(parseInt(req.params.streamId));
-- if (!stream?.user_id) return res.json({ sounds: [] });
-- res.json({ sounds: db.getChannelSounds(stream.user_id).map(serializeSound) });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to load sounds' });
-- }
--});
--
--// ── Upload a channel sound ───────────────────────────────────
--router.post('/', requireAuth, soundUpload.single('sound'), async (req, res) => {
-- const cleanup = () => { if (req.file && fs.existsSync(req.file.path)) { try { fs.unlinkSync(req.file.path); } catch {} } };
-- try {
-- if (!req.file) return res.status(400).json({ error: 'No audio file uploaded' });
--
-- const command = String(req.body.command || '').trim().toLowerCase().replace(/^!+/, '');
-- if (!/^[a-z0-9_]{2,24}$/.test(command)) {
-- cleanup();
-- return res.status(400).json({ error: 'Command must be 2-24 letters, numbers, or underscores' });
-- }
-- if (RESERVED_COMMANDS.has(command)) {
-- cleanup();
-- return res.status(400).json({ error: `"!${command}" is a reserved command — pick another name` });
-- }
--
-- // Resolve target channel: channel_id = streamer's user id; stream_id resolves to its owner.
-- let channelOwnerId = parseInt(req.body.channel_id) || null;
-- if (!channelOwnerId && req.body.stream_id) {
-- channelOwnerId = db.getStreamById(parseInt(req.body.stream_id))?.user_id || null;
-- }
-- if (!channelOwnerId) channelOwnerId = req.user.id;
-- const channel = db.getChannelByUserId(channelOwnerId) || (channelOwnerId === req.user.id ? db.ensureChannel(req.user.id) : null);
-- if (!channel) {
-- cleanup();
-- return res.status(404).json({ error: 'Channel not found' });
-- }
--
-- 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) {
-- cleanup();
-- return res.status(403).json({ error: 'This streamer has disabled viewer sound uploads.' });
-- }
-- if (!isMod && !isOwnChannel && (settings.uploads_mods_only || settings.sounds_mods_only)) {
-- cleanup();
-- return res.status(403).json({ error: 'Only channel mods can upload sounds here.' });
-- }
--
-- // Adding a NEW file to an EXISTING command is restricted to that command's
-- // creator (or channel mods/owner), so viewers can't hijack someone's command.
-- const existingForCmd = db.getChannelSoundByCommand(channelOwnerId, command);
-- if (existingForCmd && !isMod && !isOwnChannel && existingForCmd.created_by !== req.user.id) {
-- cleanup();
-- return res.status(403).json({ error: `Only the creator of !${command} (or a mod) can add more sounds to it.` });
-- }
--
-- // Per-channel + per-uploader caps
-- if (db.countChannelSounds(channelOwnerId) >= config.sounds.maxPerChannel) {
-- cleanup();
-- return res.status(400).json({ error: `This channel has reached its sound limit (${config.sounds.maxPerChannel}).` });
-- }
-- if (!isMod && db.countChannelSoundsByUploader(channelOwnerId, req.user.id) >= config.sounds.maxPerUploaderPerChannel) {
-- cleanup();
-- return res.status(400).json({ error: `You've reached your upload limit for this channel (${config.sounds.maxPerUploaderPerChannel}).` });
-- }
--
-- // A command may hold multiple sounds (one is chosen at random on playback),
-- // so duplicates are allowed. Per-channel / per-uploader count caps below still apply.
--
-- // Duration gate
-- const maxSeconds = Math.min(30, Math.max(1, settings.max_sound_seconds || config.sounds.defaultMaxSeconds));
-- const duration = await probeDuration(req.file.path);
-- if (!duration) {
-- cleanup();
-- return res.status(400).json({ error: 'Could not read that audio file — try a standard MP3 or WAV.' });
-- }
-- if (duration > maxSeconds + 0.25) {
-- cleanup();
-- return res.status(400).json({ error: `Sound is too long (${duration.toFixed(1)}s). Max is ${maxSeconds}s for this channel.` });
-- }
--
-- // Normalize every upload to MP3 — universal, small, and the fastest to decode for
-- // low-latency chat playback. (Skip if it's already an .mp3.)
-- let finalPath = req.file.path;
-- let finalMime = req.file.mimetype;
-- if (path.extname(finalPath).toLowerCase() !== '.mp3') {
-- const mp3 = await convertToMp3(req.file.path);
-- if (mp3) {
-- try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
-- finalPath = mp3;
-- finalMime = 'audio/mpeg';
-- } else {
-- cleanup();
-- return res.status(400).json({ error: 'Could not process that audio file. Try a standard MP3 or WAV.' });
-- }
-- }
--
-- // Emote attached to the command (per-command; new files inherit the existing one).
-- const emoteCode = String(req.body.emote_code || (existingForCmd && existingForCmd.emote_code) || '').trim().slice(0, 32);
-- const result = db.createChannelSound({
-- channel_owner_id: channelOwnerId,
-- command,
-- url: finalPath,
-- mime: finalMime,
-- duration_seconds: duration,
-- created_by: req.user.id,
-- created_by_name: req.user.display_name || req.user.username,
-- emote_code: emoteCode,
-- });
-- // If an emote was (re)specified, apply it to every sound under this command.
-- if (req.body.emote_code !== undefined) { try { db.setChannelSoundEmote(channelOwnerId, command, emoteCode); } catch { /* */ } }
-- try { require('../media-proxy/asset-sync').syncSoon(); } catch { /* mirror is best-effort */ }
--
-- // Tell everyone watching this channel to refresh their sound list live.
-- try { require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'sounds-updated' }); } catch { /* */ }
--
-- res.json({
-- sound: {
-- id: result.lastInsertRowid,
-- command,
-- url: `/api/sounds/file/${path.basename(finalPath)}`,
-- duration_seconds: Math.round(duration * 10) / 10,
-- channel_id: channelOwnerId,
-- },
-- });
-- } catch (err) {
-- console.error('[Sounds] Upload error:', err);
-- cleanup();
-- if (err && /UNIQUE constraint/i.test(err.message || '')) {
-- return res.status(409).json({ error: 'That command already exists on this channel.' });
-- }
-- res.status(500).json({ error: 'Failed to upload sound' });
-- }
--});
--
--// ── Multer error handler ─────────────────────────────────────
--router.use((err, req, res, next) => {
-- if (err.code === 'LIMIT_FILE_SIZE') {
-- return res.status(400).json({ error: `File too large (max ${Math.round(config.sounds.maxSizeKb / 1024 * 10) / 10}MB)` });
-- }
-- if (err.message && err.message.includes('Only')) {
-- return res.status(400).json({ error: err.message });
-- }
-- console.error('[Sounds] Middleware error:', err);
-- res.status(500).json({ error: 'Sound upload failed' });
--});
--
--// ── Delete a sound ───────────────────────────────────────────
--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 && await permissions.canModerateChannel(req.user, channel.id)) allowed = true;
-- }
-- if (!allowed) return res.status(403).json({ error: 'Not your sound' });
--
-- if (sound.url && fs.existsSync(sound.url)) { try { fs.unlinkSync(sound.url); } catch {} }
-- db.deleteChannelSound(sound.id);
-- try { require('../media-proxy/asset-sync').removeAsset(sound.media_asset_id); } catch { /* */ }
-- try { require('./chat-server').broadcastToOwnerStreams(sound.channel_owner_id, { type: 'sounds-updated' }); } catch { /* */ }
-- res.json({ message: 'Sound deleted' });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to delete sound' });
-- }
--});
--
--// ── Edit a !command group in place (rename / set emote) ──────
--// Body: { channel_id? , stream_id?, command, newCommand?, emoteCode? }.
--// 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, async (req, res) => {
-- try {
-- let channelOwnerId = parseInt(req.body.channel_id) || null;
-- if (!channelOwnerId && req.body.stream_id) {
-- channelOwnerId = db.getStreamById(parseInt(req.body.stream_id))?.user_id || null;
-- }
-- if (!channelOwnerId) channelOwnerId = req.user.id;
-- const channel = db.getChannelByUserId(channelOwnerId);
-- if (!channel) return res.status(404).json({ error: 'Channel not found' });
--
-- const command = String(req.body.command || '').trim().toLowerCase().replace(/^!+/, '');
-- 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 = 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')) {
-- return res.status(403).json({ error: `Only the creator of !${command} (or a mod) can edit it.` });
-- }
--
-- let finalCommand = command;
-- if (req.body.newCommand !== undefined) {
-- const next = String(req.body.newCommand || '').trim().toLowerCase().replace(/^!+/, '');
-- if (!/^[a-z0-9_]{2,24}$/.test(next)) {
-- return res.status(400).json({ error: 'Command must be 2-24 letters, numbers, or underscores' });
-- }
-- if (RESERVED_COMMANDS.has(next)) {
-- return res.status(400).json({ error: `"!${next}" is a reserved command — pick another name` });
-- }
-- if (next !== command && db.getChannelSoundByCommand(channelOwnerId, next)) {
-- return res.status(409).json({ error: `!${next} already exists in this channel.` });
-- }
-- if (next !== command) {
-- db.renameChannelSoundCommand(channelOwnerId, command, next);
-- finalCommand = next;
-- }
-- }
-- if (req.body.emoteCode !== undefined) {
-- const emoteCode = String(req.body.emoteCode || '').trim().replace(/^:|:$/g, '');
-- if (emoteCode && !/^[a-zA-Z0-9_]{2,32}$/.test(emoteCode)) {
-- return res.status(400).json({ error: 'Emote code can only contain letters, numbers, and underscores' });
-- }
-- db.setChannelSoundEmote(channelOwnerId, finalCommand, emoteCode);
-- }
--
-- try { require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'sounds-updated' }); } catch { /* */ }
-- res.json({ message: 'Sound command updated', command: finalCommand });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to update sound command' });
-- }
--});
--
--// ── Serve a sound file ───────────────────────────────────────
--const EXT_TO_MIME = {
-- '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
-- '.webm': 'audio/webm', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.flac': 'audio/flac',
--};
--router.get('/file/:filename', (req, res) => {
-- try {
-- const filename = path.basename(req.params.filename);
-- const filePath = path.resolve(soundsDir(), filename);
-- if (!filePath.startsWith(soundsDir()) || !fs.existsSync(filePath)) {
-- return res.status(404).json({ error: 'Sound not found' });
-- }
-- const ext = path.extname(filename).toLowerCase();
-- res.setHeader('Content-Type', EXT_TO_MIME[ext] || 'application/octet-stream');
-- res.setHeader('Cache-Control', 'public, max-age=86400');
-- fs.createReadStream(filePath).pipe(res);
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to serve 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/tts-routes.js b/server/chat/tts-routes.js
ddeleted file mode 100644
iindex 90394cb3..00000000
---- a/server/chat/tts-routes.js
++++ /dev/null
@@@ -1,134 +0,0 @@
--/**
-- * OpenVibe.Live — TTS API Routes
-- *
-- * Public routes (authenticated users):
-- * GET /api/tts/voices — List available voices
-- * GET /api/tts/settings — Get TTS config for client
-- *
-- * Admin routes:
-- * GET /api/tts/admin/settings — Full TTS config with API keys
-- * PUT /api/tts/admin/settings — Update TTS config
-- */
--const express = require('express');
--const router = express.Router();
--const { requireAuth, requireAdmin } = require('../auth/auth');
--const { isOwner } = require('../auth/permissions');
--const ttsEngine = require('./tts-engine');
--const db = require('../db/database');
--
--// TTS credential fields/keys are owner-only. Admins may still manage the
--// non-secret TTS config (enabled, provider, limits, default voice).
--const TTS_SECRET_FIELDS = ['googleApiKey', 'googleServiceAccount', 'awsAccessKeyId', 'awsSecretAccessKey'];
--const TTS_SECRET_KEYS = new Set([
-- 'tts_google_api_key', 'tts_google_service_account',
-- 'tts_aws_access_key_id', 'tts_aws_secret_access_key',
--]);
--function maskTtsSecret(v) {
-- if (!v) return v;
-- const s = String(v);
-- return s.length <= 8 ? '••••••••' : '••••' + s.slice(-4);
--}
--
--// ── Public: Get available voices ──────────────────────────────
--router.get('/voices', (req, res) => {
-- try {
-- const voices = ttsEngine.getAvailableVoices();
-- res.json({ voices });
-- } catch (e) {
-- res.status(500).json({ error: e.message });
-- }
--});
--
--// ── Public: Get TTS settings for client ───────────────────────
--router.get('/settings', (req, res) => {
-- try {
-- const settings = ttsEngine.getTTSSettings();
-- // Don't expose API keys to the client
-- res.json({
-- enabled: settings.enabled,
-- provider: settings.provider,
-- googleConfigured: !!(settings.googleApiKey || settings.googleServiceAccount),
-- pollyConfigured: !!settings.awsAccessKeyId,
-- espeakAvailable: !!ttsEngine.detectEspeak(),
-- maxLength: settings.maxLength,
-- maxQueuePerUser: settings.maxQueuePerUser,
-- maxQueueGlobal: settings.maxQueueGlobal,
-- defaultVoice: settings.defaultVoice,
-- });
-- } catch (e) {
-- res.status(500).json({ error: e.message });
-- }
--});
--
--// ── Admin: Get full TTS config ────────────────────────────────
--router.get('/admin/settings', requireAuth, requireAdmin, (req, res) => {
-- try {
-- const settings = ttsEngine.getTTSSettings();
-- // Mask credential fields for non-owners.
-- if (!isOwner(req.user)) {
-- for (const f of TTS_SECRET_FIELDS) {
-- if (settings[f]) settings[f] = maskTtsSecret(settings[f]);
-- }
-- settings._secretsRedacted = true;
-- }
-- res.json({ settings });
-- } catch (e) {
-- res.status(500).json({ error: e.message });
-- }
--});
--
--// ── Admin: Update TTS config ──────────────────────────────────
--router.put('/admin/settings', requireAuth, requireAdmin, (req, res) => {
-- try {
-- const allowed = [
-- 'tts_enabled', 'tts_provider',
-- 'tts_google_api_key', 'tts_google_service_account',
-- 'tts_aws_access_key_id', 'tts_aws_secret_access_key', 'tts_aws_region',
-- 'tts_max_length', 'tts_max_queue_per_user', 'tts_max_queue_global',
-- 'tts_default_voice',
-- ];
-- const updates = req.body.settings || req.body;
-- const owner = isOwner(req.user);
-- let count = 0;
-- for (const [key, value] of Object.entries(updates)) {
-- if (!allowed.includes(key)) continue;
-- // Credential keys are owner-only.
-- if (TTS_SECRET_KEYS.has(key) && !owner) continue;
-- // Never overwrite a stored secret with a masked placeholder.
-- if (TTS_SECRET_KEYS.has(key) && typeof value === 'string' && /^••••/.test(value)) continue;
-- db.setSetting(key, value);
-- count++;
-- }
-- ttsEngine.invalidateSettingsCache();
-- res.json({ success: true, updated: count });
-- } catch (e) {
-- res.status(500).json({ error: e.message });
-- }
--});
--
--// ── Admin: Test voice synthesis ───────────────────────────────
--router.post('/admin/test', requireAuth, requireAdmin, async (req, res) => {
-- try {
-- const { voiceId, text } = req.body;
-- const result = await ttsEngine.synthesize(text || 'This is a TTS test from OpenVibe.Live', voiceId);
-- if (!result) return res.status(400).json({ error: 'Voice unavailable or TTS disabled' });
-- // Same-origin URL alongside the base64: plays under media-src 'self' even where the
-- // browser (or an overzealous shield) refuses blob:/data: audio.
-- try { const stashed = require('../arena/voice').stash(result.audio, result.mimeType); if (stashed) result.url = `/api/tts/audio/${stashed.file}`; } catch { /* base64 still works */ }
-- res.json(result);
-- } catch (e) {
-- res.status(500).json({ error: e.message });
-- }
--});
--
--// Cached one-off clips (admin tests, previews) — streamed same-origin so no blob:/data: is needed.
--router.get('/audio/:file', (req, res) => {
-- try {
-- const hit = require('../arena/voice').cachedByName(req.params.file);
-- if (!hit) return res.status(404).json({ error: 'No such clip' });
-- res.set({ 'Content-Type': hit.mimeType, 'Cache-Control': 'private, max-age=86400' });
-- require('fs').createReadStream(hit.path).pipe(res);
-- } catch (e) { res.status(500).json({ error: e.message }); }
--});
--
--module.exports = router;
ddiff --git a/server/index.js b/server/index.js
iindex 573a5742..34c2a003 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -108,7 +108,6 @@ const VibeCodingPublishServer = require('./vibe-coding/publish-server');
// Routes
const authRoutes = require('./auth/routes');
const streamRoutes = require('./streaming/routes');
--const chatRoutes = require('./chat/routes');
const monetizationRoutes = require('./monetization/routes');
const coinsRoutes = require('./monetization/coins-routes');
const cosmeticsRoutes = require('./monetization/cosmetics-routes');
@@@ -669,11 +668,8 @@ app.use('/api/streams', streamRoutes);
// Cross-site "streamer went live" SSE feed (consumed by /live-notify.js everywhere).
const liveEvents = require('./streaming/live-events');
app.get('/api/live-events', (req, res) => liveEvents.subscribe(req, res));
--// CHAT_AUTHORITY=chat: OpenVibe.Chat serves the chat REST routes (nginx sends /api/chat/, /api/dm/,
--// /api/tts/, /api/sounds there). One that still reaches Live must not write to the mirror.
--const chatRemote = require('./chat/chat-authority').isRemote();
--const chatMoved = (req, res) => res.status(503).set('Retry-After', '2').json({ error: 'Chat is served by OpenVibe.Chat' });
--app.use('/api/chat', chatRemote ? chatMoved : chatRoutes);
++// OpenVibe.Chat owns the chat REST routes (/api/chat/, /api/dm/, /api/tts/, /api/sounds); nginx
++// sends them there, so Live mounts none of them and keeps no CHAT_AUTHORITY rollback.
app.use('/api/funds', monetizationRoutes);
app.use('/api/coins', coinsRoutes);
app.use('/api/payments', require('./monetization/payments-routes'));
@@@ -700,7 +696,6 @@ app.use('/api/restream', restreamRoutes);
// they may be embedded cross-site; everything else keeps helmet's same-origin resource policy.
app.use('/api/thumbnails', (req, res, next) => { res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); next(); }, thumbnailRoutes);
app.use('/api/themes', themeRoutes);
--app.use('/api/sounds', chatRemote ? chatMoved : require('./chat/sounds-routes'));
app.use('/api/ai-viewers', require('./ai/viewers/routes'));
app.use('/api/powerchat', require('./integrations/powerchat-routes'));
app.use('/api/meta', metaRoutes);
@@@ -717,10 +712,6 @@ app.use('/api/media', require('./media/routes'));
// viewer's IP/UA never reaches a host a streamer chose. SSRF-guarded, image-only, cache-forever.
app.use('/api/img-proxy', require('./media/external-image-proxy'));
app.use('/api/vibe-coding', vibeCodingRoutes);
--const ttsRoutes = require('./chat/tts-routes');
--app.use('/api/tts', chatRemote ? chatMoved : ttsRoutes);
--const dmRoutes = require('./chat/dm-routes');
--app.use('/api/dm', chatRemote ? chatMoved : dmRoutes);
const analyticsRoutes = require('./streaming/analytics-routes');
app.use('/api/analytics', analyticsRoutes);
const newsRoutes = require('./news/news-routes');
ddiff --git a/test/arena-voice.test.js b/test/arena-voice.test.js
iindex b97bd1b4..68461c04 100644
---- a/test/arena-voice.test.js
++++ b/test/arena-voice.test.js
@@@ -60,16 +60,10 @@ voice._setSynth(async (v, text, username) => { calls.push({ sig: v.sig, text, us
assert.strictEqual(voice.cachedByName('../../etc/passwd'), null, 'no traversal');
assert.strictEqual(voice.cachedByName('zz.wav'), null);
assert.ok(voice.cachedByName(st.file).mimeType === 'audio/wav');
-- const app2 = require('express')(); app2.use('/api/tts', require('../server/chat/tts-routes'));
-- const srv2 = await new Promise(r => { const x = app2.listen(0, () => r(x)); });
-- const clip = await fetch(`http://127.0.0.1:${srv2.address().port}/api/tts/audio/${st.file}`);
-- assert.strictEqual(clip.status, 200); assert.strictEqual(clip.headers.get('content-type'), 'audio/wav'); assert.strictEqual(await clip.text(), 'RIFFfakewav');
-- assert.strictEqual((await fetch(`http://127.0.0.1:${srv2.address().port}/api/tts/audio/nope.wav`)).status, 404);
-- // an arena voice line is reachable through the same clip route by its cache filename
++ // An arena voice line lands in the clip cache by filename (the /api/tts clip route is Chat's now).
const line = await voice.speak({ user, text: 'you think your chat is loud?' });
-- assert.strictEqual((await fetch(`http://127.0.0.1:${srv2.address().port}/api/tts/audio/${path.basename(line.path)}`)).status, 200);
-- srv2.close();
-- console.log('✅ one-off clip stash + same-origin /api/tts/audio route');
++ assert.ok(voice.cachedByName(path.basename(line.path)), 'a spoken line is served from the clip cache');
++ console.log('✅ one-off clip stash + arena voice cache');
console.log('\n✅ All Arena voice tests passed');
process.exit(0);
})().catch(err => { console.error(err); process.exit(1); });
ddiff --git a/test/authorization.test.js b/test/authorization.test.js
iindex 2ee853bf..044537f7 100644
---- a/test/authorization.test.js
++++ b/test/authorization.test.js
@@@ -57,7 +57,6 @@ app.use(express.json());
app.use('/api/mod', require('../server/admin/mod-routes'));
app.use('/api/controls', require('../server/controls/routes'));
app.use('/api/admin', require('../server/admin/routes'));
--app.use('/api/dm', require('../server/chat/dm-routes'));
app.use('/api/media', require('../server/media/routes'));
app.use('/api/ai-viewers', require('../server/ai/viewers/routes'));
app.use('/api/payments', require('../server/monetization/payments-routes'));
@@@ -146,16 +145,6 @@ async function check(name, fn) {
assert.strictEqual(r.status, 403, r.text);
});
-- await check('DM: a private 1:1 cannot have a third person added', async () => {
-- const dm = require('../server/chat/dm');
-- dm.ensureTables();
-- const conv = dm.createConversation(3, [3, 4]);
-- const convId = typeof conv === 'object' ? (conv.id || conv.lastInsertRowid) : conv;
-- const r = await call('POST', `/api/dm/conversations/${convId}/participants`, 3, { user_id: 5 });
-- assert.strictEqual(r.status, 400, r.text);
-- assert.ok(!dm.isParticipant(convId, 5));
-- });
--
await check('media refund: no credit when the streamer no longer holds the Vibes', () => {
const mq = require('../server/media/media-queue');
const reqRow = raw.prepare(`INSERT INTO media_requests (streamer_id, user_id, username, title, cost, currency, status, provider, input, canonical_url)
ddiff --git a/test/chat-log-ui.test.js b/test/chat-log-ui.test.js
ddeleted file mode 100644
iindex fd317b3b..00000000
---- a/test/chat-log-ui.test.js
++++ /dev/null
@@@ -1,254 +0,0 @@
--/**
-- * Legacy parity (roadmap D20): the chat-log views.
-- *
-- * streamer Dashboard → Chat Logs (public/js/dashboard.js loadDashChatLogs / dashPurgeChatRange):
-- * search, user and time filters, pages of 50, purge a time range after a preview count.
-- * Scoped server-side to the streamer's own (most recent) stream.
-- * mod the chat user card's "View Chat Logs" (public/js/chat.js openChatLogsModal): a user's,
-- * an anonymous chatter's or a relay chatter's history, and search. Staff (admin, global
-- * mod: staff.moderation.logs) see anyone's; everyone else only their own.
-- *
-- * The page's own functions run against a fake DOM and the real routes (server/chat/routes.js, the
-- * CHAT_AUTHORITY=live path; OpenVibe.Chat serves the same API otherwise) on a temp database.
-- *
-- * node test/chat-log-ui.test.js
-- */
--'use strict';
--const assert = require('assert');
--const fs = require('fs');
--const os = require('os');
--const path = require('path');
--const http = require('http');
--
--const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chat-logs-'));
--process.env.DB_PATH = path.join(tmp, 'live.db');
--process.env.DATA_DIR = tmp;
--process.env.NODE_ENV = 'test';
--const quiet = console.log;
--console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
--console.warn = () => {};
--
--const root = path.join(__dirname, '..');
--const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
--const db = require('../server/db/database');
--db.initDb();
--const raw = db.getDb();
--
--const auth = require('../server/auth/auth');
--const signIn = (req) => {
-- const id = Number(req.headers['x-test-user'] || 0);
-- const u = id ? db.getUserById(id) : null;
-- if (u) { req.user = u; req.authSource = 'network'; }
-- return u;
--};
--auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));
--auth.optionalAuth = (req, res, next) => { signIn(req); next(); };
--require.cache[require.resolve('../server/chat/chat-server')] = {
-- id: 'chat-server-stub', filename: 'chat-server-stub', loaded: true,
-- exports: { broadcastToStream() {}, broadcastGlobal() {}, getStreamViewerCount: () => 0 },
--};
--
--const addUser = (id, username, role) => raw.prepare(
-- `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)
-- VALUES (?, ?, ?, ?, 'x', ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@x`, role);
--addUser(1, 'admin', 'admin');
--addUser(3, 'alice', 'streamer');
--addUser(4, 'bob', 'streamer');
--addUser(6, 'mod', 'global_mod');
--addUser(7, 'viewer', 'user');
--for (const id of [3, 4]) db.ensureChannel(id);
--const stream = (userId, created) => {
-- const id = Number(db.createStream({ user_id: userId, channel_id: db.getChannelByUserId(userId).id, title: 's', protocol: 'webrtc' }).lastInsertRowid);
-- raw.prepare('UPDATE streams SET created_at = ? WHERE id = ?').run(created, id);
-- return id;
--};
--const aliceOld = stream(3, '2026-09-19 10:00:00');
--const aliceNow = stream(3, '2026-09-20 10:00:00');
--const bobs = stream(4, '2026-09-20 11:00:00');
--const say = (sid, userId, username, message, ts, extra = {}) => raw.prepare(
-- `INSERT INTO chat_messages (stream_id, user_id, anon_id, username, message, message_type, timestamp) VALUES (?, ?, ?, ?, ?, 'chat', ?)`)
-- .run(sid, userId, extra.anon || null, username, message, ts);
--say(aliceOld, 7, 'viewer', 'yesterday hello', '2026-09-19 10:05:00');
--say(aliceNow, 7, 'viewer', 'hi alice', '2026-09-20 10:01:00');
--say(aliceNow, 7, 'viewer', '<img src=x onerror=alert(1)>', '2026-09-20 10:02:00');
--say(aliceNow, null, 'anon42', 'anon says hi', '2026-09-20 10:03:00', { anon: 'anon42' });
--say(bobs, 7, 'viewer', 'hi bob', '2026-09-20 11:01:00');
--say(bobs, 4, 'bob', 'bob replies', '2026-09-20 11:02:00');
--
--const express = require('express');
--const app = express();
--app.use(express.json());
--app.use('/api/chat', require('../server/chat/routes'));
--const server = http.createServer(app).listen(0);
--function call(method, p, user, body) {
-- return new Promise((resolve, reject) => {
-- const data = body ? JSON.stringify(body) : null;
-- const headers = { 'content-type': 'application/json' };
-- if (data) headers['content-length'] = Buffer.byteLength(data);
-- if (user) headers['x-test-user'] = String(user);
-- const req = http.request({ port: server.address().port, path: p, method, headers }, (res) => {
-- let text = '';
-- res.on('data', (c) => { text += c; });
-- res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text, headers: res.headers }); });
-- });
-- req.on('error', reject);
-- if (data) req.write(data);
-- req.end();
-- });
--}
--
--/** The source of a top-level function declaration, braces matched. */
--function extract(src, name) {
-- const m = new RegExp(`(?:async )?function ${name}\\(`).exec(src);
-- assert.ok(m, `${name}() must exist`);
-- let i = src.indexOf('{', m.index);
-- let depth = 0;
-- for (; i < src.length; i++) {
-- if (src[i] === '{') depth++;
-- else if (src[i] === '}' && --depth === 0) return src.slice(m.index, i + 1);
-- }
-- throw new Error(`unbalanced ${name}`);
--}
--
--/** The dashboard card, with the page's own functions and api() going to the real routes as `user`. */
--function dashboard(user, inputs = {}) {
-- const els = {};
-- for (const id of ['dash-chatlog-results', 'dash-chatlog-pagination', 'dash-chatlog-search', 'dash-chatlog-username', 'dash-chatlog-from', 'dash-chatlog-to']) {
-- els[id] = { innerHTML: '', value: inputs[id] || '' };
-- }
-- const toasts = [];
-- const requests = [];
-- const fixtures = {
-- document: { getElementById: (id) => els[id] || null },
-- api: async (p, opts = {}) => {
-- requests.push([opts.method || 'GET', p]);
-- const r = await call(opts.method || 'GET', `/api${p}`, user, opts.body ? JSON.parse(opts.body) : undefined);
-- if (r.status >= 400) throw new Error(r.json?.error || `HTTP ${r.status}`);
-- return r.json;
-- },
-- toast: (msg, kind) => toasts.push([msg, kind]),
-- confirm: () => true,
-- _chatLogPage: 1,
-- };
-- const scope = new Proxy(fixtures, {
-- has: (t, k) => typeof k === 'string',
-- get: (t, k) => (k === Symbol.unscopables ? undefined : (k in t ? t[k] : globalThis[k])),
-- set: (t, k, v) => { t[k] = v; return true; },
-- });
-- const src = [extract(read('public/js/app.js'), 'esc'), extract(read('public/js/dashboard.js'), 'loadDashChatLogs'), extract(read('public/js/dashboard.js'), 'dashPurgeChatRange')].join('\n');
-- // eslint-disable-next-line no-new-func
-- const fns = new Function('scope', `with (scope) { ${src}\n return { loadDashChatLogs, dashPurgeChatRange }; }`)(scope);
-- return { fns, els, toasts, requests };
--}
--
--let failures = 0;
--async function check(name, fn) {
-- try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack || e.message); }
--}
--
--(async () => {
-- await new Promise((r) => server.once('listening', r));
--
-- await check('the dashboard card is part of the dashboard page and wired to the page\'s functions', async () => {
-- const reg = JSON.parse(read('public/features.json'));
-- assert.strictEqual(reg.features.dashboard.fragment, 'dashboard');
-- assert.ok(reg.features.dashboard.js.includes('/js/dashboard.js'));
-- const html = read('public/fragments/dashboard.html');
-- for (const id of ['dash-card-chatlogs', 'dash-chatlog-search', 'dash-chatlog-username', 'dash-chatlog-from', 'dash-chatlog-to', 'dash-chatlog-results', 'dash-chatlog-pagination']) {
-- assert.ok(html.includes(`id="${id}"`), `#${id}`);
-- }
-- assert.match(html, /onclick="loadDashChatLogs\(\)"/);
-- assert.match(html, /onclick="dashPurgeChatRange\(\)"/);
-- });
--
-- await check('a streamer sees their own stream\'s log, escaped, newest first', async () => {
-- const d = dashboard(3);
-- await d.fns.loadDashChatLogs();
-- const out = d.els['dash-chatlog-results'].innerHTML;
-- assert.ok(out.includes('<table'), out);
-- assert.ok(out.includes('hi alice') && out.includes('anon says hi'));
-- assert.ok(!out.includes('hi bob') && !out.includes('yesterday hello'), 'not another channel\'s, nor an older stream\'s');
-- assert.ok(out.includes('&lt;img src=x onerror=alert(1)&gt;') && !out.includes('<img src=x'), 'messages are escaped');
-- assert.ok(out.indexOf('anon says hi') < out.indexOf('hi alice'), 'newest first');
-- });
--
-- await check('the search, user and time filters narrow it', async () => {
-- let d = dashboard(3, { 'dash-chatlog-search': 'says' });
-- await d.fns.loadDashChatLogs();
-- assert.ok(d.els['dash-chatlog-results'].innerHTML.includes('anon says hi'));
-- assert.ok(!d.els['dash-chatlog-results'].innerHTML.includes('hi alice'));
-- d = dashboard(3, { 'dash-chatlog-username': 'nobody' });
-- await d.fns.loadDashChatLogs();
-- assert.match(d.els['dash-chatlog-results'].innerHTML, /No messages found/);
-- // datetime-local inputs are local time; the page sends them as ISO instants.
-- const local = (iso) => { const t = new Date(iso); return new Date(t.getTime() - t.getTimezoneOffset() * 60000).toISOString().slice(0, 16); };
-- d = dashboard(3, { 'dash-chatlog-from': local('2026-09-20T10:02:00Z'), 'dash-chatlog-to': local('2026-09-20T10:02:59Z') });
-- await d.fns.loadDashChatLogs();
-- const out = d.els['dash-chatlog-results'].innerHTML;
-- assert.ok(out.includes('onerror') && !out.includes('hi alice') && !out.includes('anon says hi'), 'exactly the minute asked for');
-- });
--
-- await check('pages of 50 with previous/next', async () => {
-- for (let i = 0; i < 60; i++) say(aliceNow, 7, 'viewer', `line ${i}`, `2026-09-20 12:${String(i).padStart(2, '0')}:00`);
-- const d = dashboard(3);
-- await d.fns.loadDashChatLogs();
-- assert.match(d.els['dash-chatlog-pagination'].innerHTML, /Page 1 of 2/);
-- assert.match(d.els['dash-chatlog-pagination'].innerHTML, /loadDashChatLogs\(2\)/);
-- await d.fns.loadDashChatLogs(2);
-- assert.match(d.els['dash-chatlog-pagination'].innerHTML, /loadDashChatLogs\(1\)/);
-- assert.ok(d.requests.every(([, p]) => /limit=50/.test(p)));
-- });
--
-- await check('purge a range from the card: preview count, confirm, purge, reload', async () => {
-- const local = (iso) => { const t = new Date(iso); return new Date(t.getTime() - t.getTimezoneOffset() * 60000).toISOString().slice(0, 16); };
-- const d = dashboard(3, { 'dash-chatlog-from': local('2026-09-20T12:00:00Z'), 'dash-chatlog-to': local('2026-09-20T12:09:00Z') });
-- await d.fns.dashPurgeChatRange();
-- assert.deepStrictEqual(d.toasts.pop(), ['Purged 10 messages', 'success']);
-- assert.deepStrictEqual(d.requests.map(([m, p]) => `${m} ${p.split('?')[0]}`), ['POST /chat/admin/purge/preview', 'DELETE /chat/admin/purge', 'GET /chat/admin/logs']);
-- const empty = dashboard(3);
-- await empty.fns.dashPurgeChatRange();
-- assert.strictEqual(empty.toasts[0][1], 'error', 'both ends of the range are required');
-- });
--
-- await check('the server keeps each streamer to their own logs; staff see any, and purged lines on request', async () => {
-- assert.strictEqual((await call('GET', `/api/chat/admin/logs?streamId=${bobs}`, 3)).status, 403);
-- assert.strictEqual((await call('GET', `/api/chat/admin/logs/export?streamId=${bobs}&format=json`, 3)).status, 403);
-- assert.strictEqual((await call('GET', '/api/chat/admin/logs/export?format=json', 3)).status, 403, 'only staff export everything');
-- const own = await call('GET', `/api/chat/admin/logs/export?streamId=${aliceOld}&format=csv`, 3);
-- assert.strictEqual(own.status, 200);
-- assert.match(own.headers['content-type'], /text\/csv/);
-- assert.match(own.text, /yesterday hello/, 'an older stream of their own by id');
-- const admin = await call('GET', `/api/chat/admin/logs?streamId=${aliceNow}&includeDeleted=true&limit=200`, 1);
-- assert.strictEqual(admin.json.total, 63, 'purged lines stay auditable by staff');
-- const owner = await call('GET', `/api/chat/admin/logs?streamId=${aliceNow}&includeDeleted=true&limit=200`, 3);
-- assert.strictEqual(owner.json.total, 53, 'the streamer only sees what is left');
-- });
--
-- await check('the mod view: staff read anyone\'s history, anonymous and relay chatters included; others only their own', async () => {
-- const chat = read('public/js/chat.js');
-- const load = extract(chat, 'loadChatLogs');
-- for (const route of ['/chat/anon/${encodeURIComponent(ctx.anon)}/logs', '/chat/relay-user/${encodeURIComponent(ctx.relay.platform)}/${encodeURIComponent(ctx.relay.username)}/logs', '/chat/user/${ctx.userId}/history', '/chat/search?${params}']) {
-- assert.ok(load.includes(route), `the modal reads ${route}`);
-- }
-- assert.match(load, /esc\(m\.message\)/, 'and escapes what it shows');
-- assert.match(chat, /onclick="ctxViewLogs\('\$\{esc\(username\)\}', '\$\{data\.user\.id\}'\);closeModal\(\)"/, 'from the user card');
--
-- const mine = await call('GET', '/api/chat/user/7/history?limit=50', 7);
-- assert.strictEqual(mine.status, 200, 'a user reads their own');
-- assert.strictEqual((await call('GET', '/api/chat/user/4/history', 7)).status, 403, 'not someone else\'s');
-- const modView = await call('GET', '/api/chat/user/7/history?limit=200', 6);
-- assert.strictEqual(modView.status, 200);
-- assert.strictEqual(modView.json.total, 54, 'the viewer\'s lines on every channel, less the 10 purged');
-- assert.strictEqual((await call('GET', '/api/chat/anon/anon42/logs', 6)).json.messages[0].message, 'anon says hi');
-- assert.strictEqual((await call('GET', '/api/chat/anon/anon42/logs', 3)).status, 403, 'a streamer is not staff');
-- const search = await call('GET', '/api/chat/search?q=bob&user_id=4', 7);
-- assert.ok(search.json.messages.every((m) => m.user_id === 7), 'a non-staff search is limited to their own lines');
-- const staffSearch = await call('GET', '/api/chat/search?q=bob&user_id=4', 6);
-- assert.deepStrictEqual(staffSearch.json.messages.map((m) => m.message), ['bob replies']);
-- });
--
-- server.close();
-- if (failures) { quiet(`\n${failures} check(s) failed`); process.exit(1); }
-- quiet('\nAll chat-log view checks passed');
-- process.exit(0);
--})().catch((e) => { quiet(e); process.exit(1); });
ddiff --git a/test/legacy-game-readonly.test.js b/test/legacy-game-readonly.test.js
iindex b6a395b3..02b7fe83 100644
---- a/test/legacy-game-readonly.test.js
++++ b/test/legacy-game-readonly.test.js
@@@ -54,26 +54,10 @@ for (const [id, name] of [[1, 'veteran'], [2, 'newcomer']]) {
assert.strictEqual(g.total_level, 11 + 3 + 6, 'eight skills summed, like the old getPlayer()');
assert.strictEqual(g.total_coins_earned, 42);
-- // The chat profile card (Live's route and the one OpenVibe.Chat reads) never adds a row.
-- const express = require('express');
-- const app = express();
-- app.use('/api/chat', require('../server/chat/routes'));
-- const server = http.createServer(app).listen(0);
-- const get = (p) => new Promise((resolve, reject) => {
-- http.get({ port: server.address().port, path: p }, (res) => {
-- let text = '';
-- res.on('data', (c) => { text += c; });
-- res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(text || 'null') }));
-- }).on('error', reject);
-- });
-- let r = await get('/api/chat/user/newcomer/profile');
-- assert.strictEqual(r.status, 200);
-- assert.strictEqual(r.json.game, undefined, 'no legacy game block for someone who never played');
-- assert.strictEqual(count(), 1, 'and viewing their profile created no game_players row');
-- r = await get('/api/chat/user/veteran/profile');
-- assert.strictEqual(r.json.game.total_level, 20, 'a legacy player still shows their skills');
-- assert.strictEqual(count(), 1);
-- server.close();
++ // The profile-card reader (what Live's removed route and OpenVibe.Chat both called) never adds a row.
++ assert.strictEqual(db.getLegacyGameProfile(2), null, 'no legacy game block for someone who never played');
++ assert.strictEqual(g.total_level, 20, 'a legacy player still shows their skills');
++ assert.strictEqual(count(), 1, 'reading a profile created no game_players row');
// Chat tags: read-only, and still shown.
const tags = require('../server/chat/tags');
ddiff --git a/test/replay-purge.test.js b/test/replay-purge.test.js
iindex 37c40125..44f3ffd7 100644
---- a/test/replay-purge.test.js
++++ b/test/replay-purge.test.js
@@@ -1,18 +1,15 @@
/**
-- * Legacy parity (roadmap D20): purging chat and deleting replays stays consistent.
++ * Legacy parity (roadmap D20): deleting a VOD or clip keeps Live's rows, its comment thread and its
++ * Search document consistent.
*
-- * 1. Chat purge ↔ VOD chat replay. The dashboard's "Purge Range" (and its preview and log filter)
-- * sends ISO instants ('2026-09-20T10:00:00.000Z'); chat_messages keeps SQLite's
-- * '2026-09-20 10:00:00'. Compared as TEXT ('T' sorts after ' '), a range matched nothing on its
-- * first day and ALL of its last day: the purge removed lines outside the chosen range, kept the ones
-- * inside it, and VOD chat replay went on showing them. Live's local routes (CHAT_AUTHORITY=live, and
-- * the mirror Live keeps under CHAT_AUTHORITY=chat) now read both forms as UTC, as OpenVibe.Chat does.
++ * Media deletes the item; Live used to only hide the comment thread, and kept its own rows: the AI
++ * state the backfill takes newest-first (a deleted VOD's row sat at the head of the overview queue
++ * for good) and the unique views. The admin storage page and the "older than" action also left the
++ * item's Search document until the daily refresh. Every delete path now goes through
++ * server/media-proxy/purge.js.
*
-- * 2. Deleting a VOD or clip. Media deletes the item; Live used to only hide the comment thread, and
-- * kept its own rows: the AI state the backfill takes newest-first (a deleted VOD's row sat at the
-- * head of the overview queue for good) and the unique views. The admin storage page and the
-- * "older than" action also left the item's Search document until the daily refresh. Every delete
-- * path now goes through server/media-proxy/purge.js.
++ * (The chat purge ↔ VOD chat replay half moved with Live's /api/chat routes: OpenVibe.Chat serves
++ * them and covers that consistency in its own suite.)
*
* node test/replay-purge.test.js
*/
@@@ -57,29 +54,6 @@ const streamA = Number(db.createStream({ user_id: 3, channel_id: db.getChannelBy
raw.prepare("UPDATE streams SET started_at = '2026-09-20 09:00:00', ended_at = '2026-09-21 03:00:00', is_live = 0 WHERE id = ?").run(streamA);
Number(db.createStream({ user_id: 7, channel_id: db.getChannelByUserId(7).id, title: 'M', protocol: 'webrtc' }).lastInsertRowid);
--// Alice's chat during her stream, as Live stores it (UTC, SQLite form).
--const LINES = [
-- ['2026-09-20 09:59:59', 'before'],
-- ['2026-09-20 10:00:00', 'first in range'],
-- ['2026-09-20 10:30:00', 'middle'],
-- ['2026-09-20 11:00:00', 'last in range'],
-- ['2026-09-20 11:00:01', 'just after'],
-- ['2026-09-20 23:30:00', 'late that evening'],
-- ['2026-09-21 01:00:00', 'after midnight'],
-- ['2026-09-21 02:30:00', 'next morning'],
--];
--for (const [ts, text] of LINES) {
-- raw.prepare(`INSERT INTO chat_messages (stream_id, channel_user_id, user_id, username, message, message_type, timestamp)
-- VALUES (?, 3, 7, 'mallory', ?, 'chat', ?)`).run(streamA, text, ts);
--}
--
--// ── the chat server the purge route announces to ──
--const announced = [];
--require.cache[require.resolve('../server/chat/chat-server')] = {
-- id: 'chat-server-stub', filename: 'chat-server-stub', loaded: true,
-- exports: { broadcastToStream: (sid, p) => announced.push([sid, p]), broadcastGlobal: (p) => announced.push(['global', p]) },
--};
--
// ── Media, Community and Search stubs for the delete paths ──
const media = require('../server/media-client');
const VODS = {
@@@ -114,7 +88,6 @@ raw.prepare("INSERT INTO content_views (content_type, content_id, ip) VALUES ('c
const express = require('express');
const app = express();
app.use(express.json());
--app.use('/api/chat', require('../server/chat/routes'));
app.use('/api/vods', require('../server/media-proxy/vods'));
app.use('/api/clips', require('../server/media-proxy/clips'));
const server = http.createServer(app).listen(0);
@@@ -135,7 +108,6 @@ function call(method, p, user, body) {
req.end();
});
}
--const replayTexts = async () => (await call('GET', `/api/chat/${streamA}/replay?from=${encodeURIComponent('2026-09-20 09:00:00')}&to=${encodeURIComponent('2026-09-21 03:00:00')}`)).json.messages.map((m) => m.message);
const aiRow = (id) => raw.prepare('SELECT 1 FROM vod_ai_state WHERE vod_id = ?').get(id);
const views = (type, id) => raw.prepare('SELECT COUNT(*) AS n FROM content_views WHERE content_type = ? AND content_id = ?').get(type, id).n;
@@@ -146,53 +118,8 @@ async function check(name, fn) {
(async () => {
await new Promise((r) => server.once('listening', r));
-- const all = LINES.map(([, t]) => t);
--
-- await check('chat replay covers the whole stream before any purge', async () => {
-- assert.deepStrictEqual(await replayTexts(), all);
-- });
--
-- // What the dashboard sends for 10:00–11:00 UTC (new Date(input).toISOString()).
-- const range = { from: '2026-09-20T10:00:00.000Z', to: '2026-09-20T11:00:00.000Z' };
--
-- await check('the log filter, the purge preview and the purge agree on an ISO range', async () => {
-- const q = new URLSearchParams(range).toString();
-- const logs = await call('GET', `/api/chat/admin/logs?${q}`, 3);
-- assert.strictEqual(logs.status, 200);
-- assert.deepStrictEqual(logs.json.rows.map((r) => r.message).sort(), ['first in range', 'last in range', 'middle']);
-- const preview = await call('POST', '/api/chat/admin/purge/preview', 3, range);
-- assert.deepStrictEqual(preview.json, { count: 3 }, 'the preview counts exactly the lines in the range');
-- const purge = await call('DELETE', '/api/chat/admin/purge', 3, range);
-- assert.deepStrictEqual(purge.json, { deleted: 3 }, 'and the purge removes exactly those');
-- assert.deepStrictEqual(announced.pop(), [streamA, { type: 'purge', streamId: streamA, ...range, by: 'alice' }], 'live chat is told');
-- });
--
-- await check('VOD chat replay no longer shows the purged lines, and still shows every other one', async () => {
-- assert.deepStrictEqual(await replayTexts(), ['before', 'just after', 'late that evening', 'after midnight', 'next morning']);
-- });
--
-- await check('a range across midnight purges only its own hours, not the whole last day', async () => {
-- const overnight = { from: '2026-09-20T23:00:00.000Z', to: '2026-09-21T01:30:00.000Z' };
-- assert.deepStrictEqual((await call('POST', '/api/chat/admin/purge/preview', 3, overnight)).json, { count: 2 });
-- assert.deepStrictEqual((await call('DELETE', '/api/chat/admin/purge', 3, overnight)).json, { deleted: 2 });
-- assert.deepStrictEqual(await replayTexts(), ['before', 'just after', 'next morning'], '02:30 the next morning is kept');
-- });
--
-- await check('a clip\'s replay window (SQLite form) still works, and staff see purged lines only on request', async () => {
-- const r = await call('GET', `/api/chat/${streamA}/replay?from=${encodeURIComponent('2026-09-20 09:59:00')}&to=${encodeURIComponent('2026-09-20 11:00:05')}`);
-- assert.deepStrictEqual(r.json.messages.map((m) => m.message), ['before', 'just after']);
-- const staff = await call('GET', `/api/chat/admin/logs?streamId=${streamA}&includeDeleted=true&limit=50`, 1);
-- assert.strictEqual(staff.json.total, LINES.length, 'the purge is a soft delete an admin can still audit');
-- const owner = await call('GET', `/api/chat/admin/logs?streamId=${streamA}&includeDeleted=true&limit=50`, 3);
-- assert.strictEqual(owner.json.total, 3, 'the streamer sees only what is left');
-- });
--
-- await check('nobody purges someone else\'s stream', async () => {
-- const r = await call('DELETE', '/api/chat/admin/purge', 7, { streamId: streamA, from: '2026-09-20T00:00:00Z', to: '2026-09-22T00:00:00Z' });
-- assert.strictEqual(r.status, 403);
-- assert.deepStrictEqual(await replayTexts(), ['before', 'just after', 'next morning']);
-- });
++ // Deleting replays: Live's rows about each item, its comment thread and its Search document.
const before = db.getVodsNeedingOverview(6).map((r) => r.id);
await check('deleting a VOD drops Live\'s rows about it, hides its thread and re-reads its Search document', async () => {
assert.ok(before.includes(100), 'the VOD was queued for an AI overview');
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
ddeleted file mode 100644
iindex 650f0463..00000000
---- a/test/security-private.test.js
++++ /dev/null
@@@ -1,172 +0,0 @@
--'use strict';
--/**
-- * Direct messages stay between their participants on every HTTP path (roadmap WS-R task 5, the
-- * private-room class). dm-delivery.test.js pins live delivery (dm.isParticipant before a socket
-- * gets a message) and authorization.test.js that a 1:1 cannot grow a third person; this suite
-- * walks every /api/dm route as someone outside the conversation, as a participant who was removed
-- * from a group, and anonymously:
-- *
-- * read the conversation, its messages (every cursor form), the conversation list and the unread
-- * count; post into it, mark it read, rename it, add themselves or someone else, remove a
-- * participant, delete a participant's message.
-- *
-- * Each refusal leaves the conversation as it was and returns none of its text. (Private VODs and
-- * clips are media-privacy.test.js's; with Chat serving /api/dm, OpenVibe.Chat has the same suite.)
-- *
-- * node test/security-private.test.js
-- */
--const assert = require('assert');
--const fs = require('fs');
--const os = require('os');
--const path = require('path');
--const http = require('http');
--
--const tmp = path.join(os.tmpdir(), `ov-private-${process.pid}.db`);
--process.env.DB_PATH = tmp;
--process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-sec-data-')); // nothing lands in the checkout's data/
--process.env.NODE_ENV = 'test';
--const quiet = console.log;
--console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
--console.warn = () => {};
--console.error = () => {};
--
--const db = require('../server/db/database');
--db.initDb();
--const raw = db.getDb();
--const dm = require('../server/chat/dm');
--dm.ensureTables();
--
--const auth = require('../server/auth/auth');
--const signIn = (req) => {
-- const id = Number(req.headers['x-test-user'] || 0);
-- const u = id ? db.getUserById(id) : null;
-- if (u) { req.user = u; req.authSource = 'network'; }
-- return u;
--};
--auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));
--auth.optionalAuth = (req, res, next) => { signIn(req); next(); };
--
--const addUser = (id, username, role = 'user') => raw.prepare(
-- `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)
-- VALUES (?, ?, ?, ?, 'x', ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@example.test`, role);
--const ANN = 11, BEN = 12, CAT = 13, EVE = 14, MOD = 15;
--addUser(ANN, 'ann'); addUser(BEN, 'ben'); addUser(CAT, 'cat'); addUser(EVE, 'eve'); addUser(MOD, 'moddy', 'global_mod');
--
--// A 1:1 between Ann and Ben, and a group Ann made with Ben and Cat (Cat is removed later).
--const directId = Number(dm.getOrCreateDirect(ANN, BEN));
--const groupId = Number(dm.createConversation(ANN, [ANN, BEN, CAT], 'Plans'));
--const say = (conv, who, text) => Number(dm.sendMessage(conv, who, text).id);
--const msgDirect = say(directId, BEN, 'the secret direct message');
--const msgGroupOld = say(groupId, BEN, 'the secret group message');
--
--const express = require('express');
--const app = express();
--app.use(express.json());
--app.use('/api/dm', require('../server/chat/dm-routes'));
--const server = http.createServer(app).listen(0, '127.0.0.1');
--function call(method, p, user, body) {
-- return new Promise((resolve, reject) => {
-- const data = body ? JSON.stringify(body) : null;
-- const headers = { 'content-type': 'application/json' };
-- if (user) headers['x-test-user'] = String(user);
-- const req = http.request({ host: '127.0.0.1', port: server.address().port, path: p, method, headers, agent: false }, (res) => {
-- let text = '';
-- res.on('data', (c) => { text += c; });
-- res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text }); });
-- });
-- req.on('error', reject);
-- if (data) req.write(data);
-- req.end();
-- });
--}
--const SECRETS = ['secret direct', 'secret group', 'Plans'];
--const snapshot = () => ({
-- conversations: raw.prepare('SELECT * FROM dm_conversations ORDER BY id').all(),
-- participants: raw.prepare('SELECT conversation_id, user_id FROM dm_participants ORDER BY conversation_id, user_id').all(),
-- messages: raw.prepare('SELECT id, conversation_id, sender_id, message FROM dm_messages ORDER BY id').all().map((m) => ({ ...m })),
--});
--
--let failures = 0;
--async function check(name, fn) {
-- try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', String(e.stack || e.message).split('\n').slice(0, 6).join('\n ')); }
--}
--
--/** Every DM path against conversation `id`, as `user`: all must refuse and carry nothing. */
--async function outsider(user, id, label) {
-- const before = snapshot();
-- const reads = [
-- ['GET', `/api/dm/conversations/${id}`], ['GET', `/api/dm/conversations/${id}/messages`],
-- ['GET', `/api/dm/conversations/${id}/messages?before=999999`], ['GET', `/api/dm/conversations/${id}/messages?after=0`],
-- ['GET', `/api/dm/conversations/${id}/messages?limit=500`],
-- ];
-- const writes = [
-- ['POST', `/api/dm/conversations/${id}/messages`, { message: 'hi', text: 'hi' }],
-- ['POST', `/api/dm/conversations/${id}/read`, {}],
-- ['PATCH', `/api/dm/conversations/${id}`, { name: 'pwned' }],
-- ['POST', `/api/dm/conversations/${id}/participants`, { user_id: user || EVE }],
-- ['POST', `/api/dm/conversations/${id}/participants`, { user_id: MOD }],
-- ['DELETE', `/api/dm/conversations/${id}/participants/${BEN}`],
-- ['DELETE', `/api/dm/conversations/${id}/messages/${msgDirect}`],
-- ['DELETE', `/api/dm/conversations/${id}/messages/${msgGroupOld}`],
-- ];
-- for (const [m, p, body] of [...reads, ...writes]) {
-- const r = await call(m, p, user, body);
-- assert.ok([401, 403, 404].includes(r.status), `${label}: ${m} ${p} → ${r.status} ${r.text.slice(0, 160)}`);
-- for (const s of SECRETS) assert.ok(!r.text.includes(s), `${label}: ${m} ${p} carries "${s}"`);
-- }
-- assert.deepStrictEqual(snapshot(), before, `${label}: nothing changed`);
--}
--
--(async () => {
-- await new Promise((r) => server.once('listening', r));
-- quiet('private: direct messages');
--
-- await check('control: participants read their conversations (the routes work in this harness)', async () => {
-- const r = await call('GET', `/api/dm/conversations/${directId}/messages`, ANN);
-- assert.strictEqual(r.status, 200, r.text.slice(0, 200));
-- assert.ok(r.text.includes('the secret direct message'));
-- const g = await call('GET', `/api/dm/conversations/${groupId}/messages`, CAT);
-- assert.strictEqual(g.status, 200);
-- assert.ok(g.text.includes('the secret group message'));
-- });
-- await check('an outsider gets nothing from a 1:1 on any path, and changes nothing', () => outsider(EVE, directId, 'outsider/direct'));
-- await check('an outsider gets nothing from a group on any path, and changes nothing', () => outsider(EVE, groupId, 'outsider/group'));
-- await check('a global mod is an outsider to other people\'s DMs too', () => outsider(MOD, directId, 'mod/direct'));
-- await check('anonymous gets 401 everywhere', () => outsider(null, directId, 'anonymous'));
-- await check('the outsider\'s own lists hold none of it (conversations, unread, search)', async () => {
-- const list = await call('GET', '/api/dm/conversations', EVE);
-- assert.strictEqual(list.status, 200);
-- for (const s of SECRETS) assert.ok(!list.text.includes(s), list.text.slice(0, 200));
-- const unread = await call('GET', '/api/dm/unread', EVE);
-- assert.strictEqual(unread.status, 200);
-- assert.ok(!/[1-9]/.test(JSON.stringify(unread.json)), `unread: ${unread.text}`);
-- const search = await call('GET', '/api/dm/users/search?q=ann', EVE);
-- assert.ok(!search.text.includes('@example.test'), 'no email in user search');
-- });
-- await check('a participant removed from a group loses every path from then on', async () => {
-- const r = await call('DELETE', `/api/dm/conversations/${groupId}/participants/${CAT}`, ANN);
-- assert.strictEqual(r.status, 200, r.text.slice(0, 200));
-- say(groupId, BEN, 'the secret group message after cat left');
-- await outsider(CAT, groupId, 'removed/group');
-- const list = await call('GET', '/api/dm/conversations', CAT);
-- assert.ok(!list.text.includes('Plans'), 'the group left her list');
-- });
-- await check('a participant of one conversation cannot reach another through it (ids swapped)', async () => {
-- // Ann is in both; Eve starts her own 1:1 with Ann and tries Ann's other conversations' message ids.
-- const mineId = Number(dm.getOrCreateDirect(EVE, ANN));
-- const before = snapshot();
-- const del = await call('DELETE', `/api/dm/conversations/${mineId}/messages/${msgDirect}`, EVE);
-- assert.ok([403, 404].includes(del.status), `delete through her own conversation: ${del.status}`);
-- const page = await call('GET', `/api/dm/conversations/${mineId}/messages?before=${msgDirect + 1}`, EVE);
-- assert.strictEqual(page.status, 200);
-- for (const s of SECRETS) assert.ok(!page.text.includes(s), `her own conversation's page carries "${s}"`);
-- assert.deepStrictEqual(snapshot(), before);
-- });
--
-- server.close();
-- for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
-- try { fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true }); } catch { /* */ }
-- if (failures) { quiet(`\n${failures} failure(s)`); process.exit(1); }
-- quiet('\nsecurity-private: all checks passed');
-- process.exit(0);
--})().catch((e) => { quiet(e); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex b4c080de..660bb256 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -364,7 +364,6 @@ async function check(name, fn) {
'server/openre/openre-client.js': 'OpenRe.Stream (configured)',
'server/ai/ai-service.js': 'OpenVibe.AI (configured)',
'server/ai/llm.js': 'images are Live\'s own frames; a streamer\'s typed provider address (testProvider) goes through egress.postJson',
-- 'server/chat/routes.js': 'GIF providers (fixed hosts)',
'server/chat/soundboard-service.js': '101soundboards (host allowlist; the audio download through safeLookup)',
'server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)',
'server/meta/routes.js': 'GitHub API (fixed host)',