openvibers/openvibe.live Commits
Live: calls are Chat's only; Live's call server, voice-channel and group-call routes are deleted (T3) (#71)
Changes
@@@ -177,12 +177,8 @@ OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400# Bot's origin for the embed URL (<origin>/panel/<robot id>/embed): a bare https origin, or# http://localhost* outside production; anything else falls back to the default with a warning.#LIVE_BOT_URL=https://openvibe.bot--# Voice/video calls run in OpenVibe.Chat when this is "chat" (WS-I task 1;--# server/streaming/calls-authority.js): go-live, stream end, WHIP teardown and the admin force-end--# create/remove stream voice channels in Chat (POST/DELETE /internal/calls/stream-channel, the chat--# bridge's service token) instead of Live's call server. Set it together with the nginx include that--# sends /ws/call and the call routes to Chat (OpenVibe.Chat docs/calls-cutover.md). Unset = Live.--#CALLS_AUTHORITY=chat++# Voice/video calls are OpenVibe.Chat's (no setting): nginx sends /ws/call and the call routes there, and Live++# creates/removes stream voice channels in Chat on go-live and stream end (server/streaming/calls-authority.js).# Money authority (roadmap Wave 8, ADR-012). live (default): Live's own Vibes columns, exactly as# before. billing: OpenVibe.Billing is the ledger — Live calls it for checkouts, donations, cashouts,@@@ -96,13 +96,13 @@ public/js/app-*.js route code split out of app.js: home, channel, media, cpage. Auto-clips made before Media kept the flag are marked from Live's `auto_clip_log`(`syncAutoClipFlags` in `server/ai/auto-clip-job.js`).- **Voice channels.** `public/js/call.js` + `voice-channels.js` (feature `voice`, chat route): full-mesh-- WebRTC, signalling over `/ws/call` (`server/streaming/call-server.js`). The newcomer offers, existing-- members answer; the server pushes the channel list to every chat socket (`voice-channels` message);-- TURN credentials come from `server/net/turn.js` (short-lived with `TURN_AUTH_SECRET`). OpenVibe.Chat-- runs the same protocol and routes (its `server/calls/`); `CALLS_AUTHORITY=chat`-- (`server/streaming/calls-authority.js`) plus an nginx include moves calls there, and then go-live,-- stream end, WHIP teardown and the admin force-end create/remove stream voice channels in Chat-- (OpenVibe.Chat `docs/calls-cutover.md`).++ WebRTC, signalling over `/ws/call`. Calls are OpenVibe.Chat's (its `server/calls/`): nginx sends `/ws/call`,++ `/api/streams/voice-channels…` and `/api/streams/:id/call` to Chat (the include in `deploy/nginx/openvibe.live.conf`),++ and Live's own call server was deleted on 2026-10-10. The newcomer offers, existing members answer; Chat pushes the++ channel list to every chat socket (`voice-channels` message); TURN credentials come from `server/net/turn.js`++ (short-lived with `TURN_AUTH_SECRET`). Live tells Chat about a stream's call through++ `server/streaming/calls-authority.js`: go-live, stream end, WHIP teardown and the admin force-end create or remove the++ stream's voice channel (POST/DELETE `/internal/calls/stream-channel`; OpenVibe.Chat `docs/calls-cutover.md`).- **Budgets.** `scripts/perf/check-budgets.js` (in `npm test`) fails if the home page's HTML, eagerJavaScript or blocking CSS grow past recorded limits, or if route code returns to the home page.@@@ -66,7 +66,6 @@ router.get('/diagnostics', async (req, res) => {db,chatDelivery: require('../chat/chat-delivery'),broadcastServer: require('../streaming/broadcast-server'),-- callServer: (() => { try { return require('../streaming/call-server'); } catch { return null; } })(),restreamManager: (() => { try { return require('../streaming/restream-manager'); } catch { return null; } })(),}));});@@@ -26,8 +26,8 @@** Synchronous reads came from Chat's presence snapshot (GET /internal/chat/presence), polled every few* seconds by init()/close() (called from server/index.js at boot and drain): connection and viewer counts,-- * slow modes, a connected user's address. Live's own address/anon helpers live here too (call-server,-- * Chat's /internal/chat-effects/anon), along with Live's writes to data Chat caches (IP approvals, bans)++ * slow modes, a connected user's address. Live's own address/anon helpers live here too (Chat's++ * /internal/chat-effects/anon), along with Live's writes to data Chat caches (IP approvals, bans)* which send Chat a cache hint instead of waiting for its cache to expire.*/const db = require('../db/database');@@@ -189,7 +189,7 @@ function findClientByAnonId(anonId, streamId) {return hit ? { anonId: hit.anon_id, ip: hit.ip, streamId: hit.stream_id || null, user: null } : null;}--// ── Live's own address / anon helpers (Chat's /internal/chat-effects/anon, call-server) ──++// ── Live's own address / anon helpers (Chat's /internal/chat-effects/anon) ──const _openvibeToolsUrl = process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:3100';const anonMap = new Map(); // IP → numberconst pendingResolves = new Map(); // IP → pending resolve promise (dedup concurrent)@@@ -48,7 +48,6 @@ async function snapshot(services = {}) {connections: {chat: await safe(() => services.chatDelivery.getTotalConnections()),broadcast: await safe(() => services.broadcastServer.clients.size),-- call: await safe(() => services.callServer.clients.size),liveEventStreams: await safe(() => require('./streaming/live-events').clientCount()),},streams: {@@@ -102,7 +102,6 @@ const rtmpServer = require('./streaming/rtmp-server');const chatDelivery = require('./chat/chat-delivery');const controlServer = require('./controls/control-server');const broadcastServer = require('./streaming/broadcast-server');--const callServer = require('./streaming/call-server');const VibeCodingPublishServer = require('./vibe-coding/publish-server');// Routes@@@ -197,7 +196,6 @@ const stopper = gracefulStop({try { restreamManager.stopViewerCountPolling(); } catch { /* */ }try { restreamManager.stopAll(); } catch { /* */ }try { recorder.stopAll(); } catch { /* */ }-- try { callServer.close(); } catch { /* */ }try { chatDelivery.close(); } catch { /* */ }try { controlServer.close(); } catch { /* */ }try { broadcastServer.close(); } catch { /* */ }@@@ -856,7 +854,7 @@ app.get('/api/ready', readiness.handler);observability.registerDomainGauges(metricsRegistry, {liveStreams: async () => (await db.getLiveStreams()).length,-- wsServers: { broadcast: broadcastServer, control: controlServer, call: callServer },++ wsServers: { broadcast: broadcastServer, control: controlServer },outboxStatus: async () => await require('./events/stream-events').counts(),});@@@ -1144,8 +1142,6 @@ server.on('upgrade', async (req, socket, head) => {broadcastServer.handleUpgrade(req, socket, head);} else if (url.startsWith('/ws/control')) {controlServer.handleUpgrade(req, socket, head);-- } else if (url.startsWith('/ws/call')) {-- callServer.handleUpgrade(req, socket, head);} else if (url.startsWith('/ws/robotstreamer-publish')) {robotStreamerService.handleUpgrade(req, socket, head);} else {@@@ -1217,11 +1213,7 @@ async function start() {// 4b. Initialize broadcast serverbroadcastServer.init(server);-- // 4d. Initialize group call signaling server-- callServer.init(server);-- if (require('./streaming/calls-authority').isChat()) {-- console.log('[Calls] CALLS_AUTHORITY=chat — stream voice channels are created and removed in OpenVibe.Chat; Live\'s /ws/call stays up, unreached once nginx routes it to Chat');-- }++ // Group calls and voice channels are OpenVibe.Chat's (/ws/call is routed there by nginx).for (const stream of await db.getLiveStreams()) {robotStreamerService.startForStream(stream).catch((err) => {@@@ -1344,7 +1336,6 @@ async function start() {console.log(`[Server] WebSocket: ws://${config.host}:${config.port}/ws/chat`);console.log(`[Server] WebSocket: ws://${config.host}:${config.port}/ws/broadcast`);console.log(`[Server] WebSocket: ws://${config.host}:${config.port}/ws/control`);-- console.log(`[Server] WebSocket: ws://${config.host}:${config.port}/ws/call`);console.log(`[Server] Environment: ${config.nodeEnv}`);console.log(`[Server] BASE_URL: ${config.baseUrl}`);console.log(`[Server] WHIP_PUBLIC_URL: ${config.whip?.publicUrl}`);ddeleted file mode 100644@@@ -1,548 +0,0 @@--/**-- * OpenVibe.Live — Voice Channel WebSocket Server-- *-- * Discord-style voice/video channels accessible from the Chat tab.-- * Uses full-mesh WebRTC peer connections (each participant ↔ every other).-- * Signaling only — no media passes through the server.-- *-- * A permanent "Public" lobby channel always exists. Users can create-- * temporary channels. Stream-linked channels are auto-created when a-- * streamer enables voice on their stream.-- *-- * WebSocket path: /ws/call?channelId=<id>&token=X-- *-- * Channel modes:-- * 'mic' — Microphone only-- * 'mic+cam' — Mic + optional camera-- * 'cam+mic' — Both mic and camera-- */----const WebSocket = require('ws');--const db = require('../db/database');--const { extractWsToken, authenticateWs } = require('../auth/auth');--const permissions = require('../auth/permissions');--const chatDelivery = require('../chat/chat-delivery');--const cosmetics = require('../monetization/cosmetics');----const WS_HEARTBEAT_MS = 30000;--const MAX_PARTICIPANTS = 8;--const MAX_SOCKETS_PER_IP = 3; // one household in one channel; never one host filling it--const KICK_COOLDOWN_MS = 60 * 1000;--const COSMETIC_TTL_MS = 60 * 1000;--const PUBLIC_CHANNEL_ID = 'public';----class CallServer {-- constructor() {-- this.wss = null;-- /** channelId → Map<peerId, clientInfo> */-- this.rooms = new Map();-- /** ws → { channelId, peerId } */-- this.clients = new Map();-- /** channelId → Set<userId> */-- this.callBans = new Map();-- /** channelId → channel metadata { id, name, mode, createdBy, streamId?, permanent, createdAt, maxParticipants } */-- this.channels = new Map();-- this._nextPeerId = 1;-- this._heartbeatInterval = null;-- this._inactivityTimer = null;-- /** `${channelId}:${identity}` → until (ms): a kicked participant waits before rejoining */-- this.kickCooldown = new Map();-- /** userId → { at, profile } */-- this._cosmeticCache = new Map();-- this._notifyTimer = null;---- // Seed the permanent Public channel-- this.channels.set(PUBLIC_CHANNEL_ID, {-- id: PUBLIC_CHANNEL_ID, name: 'Public', mode: 'mic+cam', createdBy: null,-- streamId: null, permanent: true, createdAt: Date.now(), maxParticipants: MAX_PARTICIPANTS,-- });-- }---- init(server) {-- this.wss = new WebSocket.Server({ noServer: true, maxPayload: 256 * 1024, perMessageDeflate: false });-- if (this._heartbeatInterval) clearInterval(this._heartbeatInterval);-- this._heartbeatInterval = setInterval(() => {-- if (!this.wss) return;-- this.wss.clients.forEach((ws) => {-- if (ws.isAlive === false) { try { ws.terminate(); } catch {} return; }-- ws.isAlive = false;-- try { ws.ping(); } catch {}-- });-- }, WS_HEARTBEAT_MS);-- this.wss.on('connection', (ws, req) => this._handleConnection(ws, req).catch((e) => console.warn('[Call] connection failed:', e && e.message)));---- // Cleanup user-created channels that have been empty for over an hour-- if (this._inactivityTimer) clearInterval(this._inactivityTimer);-- this._inactivityTimer = setInterval(() => this._cleanupInactiveChannels(), 5 * 60 * 1000);---- console.log('[CallServer] Voice channels initialized');-- }---- _cleanupInactiveChannels() {-- const ONE_HOUR = 60 * 60 * 1000;-- const now = Date.now();-- for (const [k, until] of this.kickCooldown) if (until <= now) this.kickCooldown.delete(k);-- for (const [channelId, ch] of this.channels) {-- if (ch.permanent || ch.streamId) continue;-- const room = this.rooms.get(channelId);-- const isEmpty = !room || room.size === 0;-- if (isEmpty && ch.lastEmptiedAt && (now - ch.lastEmptiedAt) >= ONE_HOUR) {-- console.log(`[CallServer] Auto-deleting inactive channel "${ch.name}" (${channelId}) — empty for ${Math.round((now - ch.lastEmptiedAt) / 60000)}m`);-- this.channels.delete(channelId);-- this.callBans.delete(channelId);-- }-- }-- }---- handleUpgrade(req, socket, head) {-- if (!req.url.startsWith('/ws/call')) return false;-- this.wss.handleUpgrade(req, socket, head, (ws) => { this.wss.emit('connection', ws, req); });-- return true;-- }---- /* ── Channel management ──────────────────────────────────── */---- /** Channels a viewer may see: a private call only for its creator, its invitees and staff. */-- async listChannels(viewer = null) {-- const result = [];-- for (const [id, ch] of this.channels) {-- if (ch.private && !this._canSeePrivate(ch, viewer)) continue;-- const room = this.rooms.get(id);-- const participants = [];-- if (room) { for (const [pid, info] of room) participants.push(await this._buildParticipantInfo(pid, info)); }-- result.push({ ...this._publicChannel(ch), participantCount: room ? room.size : 0, participants });-- }-- return result;-- }-- _publicChannel(ch) { const { invited, ...rest } = ch; return rest; }-- _canSeePrivate(ch, viewer) {-- if (!ch.private) return true;-- if (!viewer) return false;-- if (permissions.can(viewer, 'staff.moderation.calls')) return true;-- return ch.createdBy === viewer.id || !!(ch.invited && ch.invited.has(viewer.id));-- }-- /** A caller invited this user to their call (POST /voice-channels/call-user). */-- invite(channelId, userId) {-- const ch = this.channels.get(channelId);-- if (!ch) return false;-- if (!ch.invited) ch.invited = new Set();-- ch.invited.add(userId);-- return true;-- }-- hasInvite(channelId, userId) {-- const ch = this.channels.get(channelId);-- return !!(ch && ch.invited && ch.invited.has(userId));-- }-- /** Debounced "the list changed" for every chat socket (the sidebar used to poll every 4 s). */-- _notifyChannelsChanged() {-- if (this._notifyTimer) return;-- this._notifyTimer = setTimeout(async () => {-- this._notifyTimer = null;-- try { await require('../chat/chat-delivery').event({ kind: 'all' }, { type: 'voice-channels', channels: await this.listChannels(null) }); } catch { /* */ }-- }, 400);-- }---- async getChannel(channelId, viewer = null) {-- const ch = this.channels.get(channelId);-- if (!ch || !this._canSeePrivate(ch, viewer)) return null;-- const room = this.rooms.get(channelId);-- const participants = [];-- if (room) { for (const [pid, info] of room) participants.push(await this._buildParticipantInfo(pid, info)); }-- return { ...this._publicChannel(ch), participantCount: room ? room.size : 0, participants };-- }---- createChannel({ name, mode, createdBy, maxParticipants, isPrivate = false }) {-- // One user-created channel per user-- for (const [, ch] of this.channels) {-- if (!ch.permanent && !ch.streamId && ch.createdBy === createdBy) {-- throw Object.assign(new Error('You already have a voice channel. Delete it first.'), { code: 'CHANNEL_LIMIT' });-- }-- }-- const id = `user-${createdBy}-${Date.now().toString(36)}`;-- const ch = {-- id, name: String(name || 'Voice Channel').slice(0, 40),-- mode: ['mic', 'mic+cam', 'cam+mic'].includes(mode) ? mode : 'mic+cam',-- createdBy, streamId: null, permanent: false, createdAt: Date.now(),-- maxParticipants: Math.min(Math.max(Number(maxParticipants) || MAX_PARTICIPANTS, 2), MAX_PARTICIPANTS),-- lastEmptiedAt: Date.now(), // starts empty; inactivity timer tracks from creation-- private: !!isPrivate,-- };-- this.channels.set(id, ch);-- this._notifyChannelsChanged();-- return this._publicChannel(ch);-- }---- async createStreamChannel(streamId, mode, streamerId) {-- const id = `stream-${streamId}`;-- const existing = this.channels.get(id);-- if (existing) { const old = existing.mode; existing.mode = mode; if (old !== mode) this.endCall(id); return existing; }-- const stream = await db.getStreamById(streamId);-- const ch = {-- id, name: stream ? (stream.title || `Stream ${streamId}`) : `Stream ${streamId}`,-- mode: ['mic', 'mic+cam', 'cam+mic'].includes(mode) ? mode : 'mic',-- createdBy: streamerId, streamId, permanent: false, createdAt: Date.now(), maxParticipants: MAX_PARTICIPANTS,-- };-- this.channels.set(id, ch);-- return ch;-- }---- removeStreamChannel(streamId) {-- const id = `stream-${streamId}`;-- this.endCall(id);-- this.channels.delete(id);-- this.callBans.delete(id);-- }---- async deleteChannel(channelId, userId) {-- const ch = this.channels.get(channelId);-- if (!ch || ch.permanent) return false;-- if (ch.createdBy !== userId) {-- const user = await db.getUserById(userId);-- if (!user || !permissions.can(user, 'staff.moderation.calls')) return false;-- }-- this.endCall(channelId);-- this.channels.delete(channelId);-- this.callBans.delete(channelId);-- this._notifyChannelsChanged();-- return true;-- }---- /* ── WebRTC signaling ──────────────────────────────────────── */---- _generatePeerId() { return `call-peer-${this._nextPeerId++}-${Date.now().toString(36)}`; }---- async _buildParticipantInfo(peerId, info) {-- let cosmeticProfile = {};-- if (info.user?.id) {-- // One query per participant per minute, not one per participant per list request.-- const c = this._cosmeticCache.get(info.user.id);-- if (c && Date.now() - c.at < COSMETIC_TTL_MS) cosmeticProfile = c.profile;-- else { try { cosmeticProfile = await cosmetics.getCosmeticProfile(info.user.id) || {}; } catch {} this._cosmeticCache.set(info.user.id, { at: Date.now(), profile: cosmeticProfile }); }-- }-- return {-- peerId, username: info.user ? info.user.username : null,-- anonId: info.anonId || null,-- displayName: info.user ? (info.user.display_name || info.user.username) : info.anonId,-- userId: info.user ? info.user.id : null,-- avatarUrl: info.user ? info.user.avatar_url : null,-- profileColor: info.user ? info.user.profile_color : null,-- isChannelCreator: info.isChannelCreator || false,-- isStreamer: info.isStreamer || false,-- muted: info.muted, cameraOff: info.cameraOff,-- forceMuted: info.forceMuted || false, forceCameraOff: info.forceCameraOff || false,-- speaking: info.speaking || false,-- nameFX: cosmeticProfile.nameFX || null, particleFX: cosmeticProfile.particleFX || null, hatFX: cosmeticProfile.hatFX || null,-- };-- }---- async _handleConnection(ws, req) {-- const url = new URL(req.url, 'http://localhost');-- const channelId = url.searchParams.get('channelId') || url.searchParams.get('streamId');-- const token = extractWsToken(req);-- const ip = chatDelivery.normalizeIp(req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress);---- ws.isAlive = true;-- ws.on('pong', () => { ws.isAlive = true; });-- try { ws._socket?.setNoDelay(true); } catch {}---- if (!channelId) { ws.send(JSON.stringify({ type: 'error', message: 'Missing channelId' })); ws.close(); return; }---- // Legacy: bare number → stream-<N>-- const resolvedId = /^\d+$/.test(channelId) ? `stream-${channelId}` : channelId;---- const channel = this.channels.get(resolvedId);-- if (!channel) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }---- // If stream-linked, verify stream is live-- if (channel.streamId) {-- const stream = await db.getStreamById(channel.streamId);-- if (!stream || !stream.is_live) { ws.send(JSON.stringify({ type: 'error', message: 'Stream not live' })); ws.close(); return; }-- }---- const user = await authenticateWs(token);-- if (channel.private && !this._canSeePrivate(channel, user)) { ws.send(JSON.stringify({ type: 'error', message: 'This is a private call' })); ws.close(); return; }---- const peerId = this._generatePeerId();-- const anonId = user ? null : await chatDelivery.getAnonIdForConnection(ip, resolvedId);-- const identity = user ? `u:${user.id}` : (anonId ? `a:${anonId}` : `ip:${ip}`);-- const cooled = this.kickCooldown.get(`${resolvedId}:${identity}`);-- if (cooled && cooled > Date.now()) { ws.send(JSON.stringify({ type: 'error', message: 'You were removed from this channel; try again in a minute' })); ws.close(); return; }-- const isChannelCreator = user && channel.createdBy === user.id;-- const isStreamer = channel.streamId ? (user && (await db.getStreamById(channel.streamId))?.user_id === user.id) : false;-- const canModerate = await this._canModerate(user, resolvedId);---- const bans = this.callBans.get(resolvedId);-- if (bans) {-- const banned = user-- ? bans.has(user.id) || bans.has(`u:${user.id}`)-- : bans.has(`a:${anonId}`) || bans.has(`ip:${ip}`);-- if (banned) {-- ws.send(JSON.stringify({ type: 'error', message: 'You are banned from this voice channel' })); ws.close(); return;-- }-- }---- if (!this.rooms.has(resolvedId)) this.rooms.set(resolvedId, new Map());-- const room = this.rooms.get(resolvedId);-- // The same account again (a second tab, or a reconnect before the old socket timed out)-- // replaces its older socket instead of standing next to it as a ghost for a minute.-- if (user) {-- for (const [pid, info] of room) {-- if (info.user && info.user.id === user.id) {-- try { info.ws.send(JSON.stringify({ type: 'replaced' })); } catch { /* */ }-- await this._handleDisconnect(info.ws, resolvedId, pid);-- try { info.ws.close(); } catch { /* */ }-- }-- }-- }-- let fromIp = 0;-- for (const [, info] of room) if (info.ip === ip) fromIp++;-- if (fromIp >= MAX_SOCKETS_PER_IP && !permissions.can(user, 'staff.limits.exempt')) { ws.send(JSON.stringify({ type: 'error', message: 'Too many connections from your network' })); ws.close(); return; }-- const maxP = channel.maxParticipants || MAX_PARTICIPANTS;-- if (room.size >= maxP) { ws.send(JSON.stringify({ type: 'error', message: `Channel full (max ${maxP})` })); ws.close(); return; }---- const clientInfo = { ws, user, anonId, ip, peerId, identity, muted: false, cameraOff: true, forceMuted: false, forceCameraOff: false, speaking: false, isChannelCreator, isStreamer, _msgCount: 0, _msgResetTime: Date.now() };-- this.clients.set(ws, { channelId: resolvedId, peerId });-- room.set(peerId, clientInfo);---- // Mark channel as active (reset inactivity timer)-- if (channel && !channel.permanent && !channel.streamId) {-- channel.lastEmptiedAt = null;-- }---- const participants = [];-- for (const [pid, info] of room) participants.push(await this._buildParticipantInfo(pid, info));---- ws.send(JSON.stringify({-- type: 'welcome',-- peerId,-- channelId: resolvedId,-- channelName: channel.name,-- callMode: channel.mode,-- participants,-- isStreamer: isStreamer || isChannelCreator,-- canModerate,-- }));---- const joinMsg = JSON.stringify({ type: 'peer-joined', ...(await this._buildParticipantInfo(peerId, clientInfo)) });-- for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(joinMsg); }-- this._broadcastParticipantCount(resolvedId);-- this._notifyChannelsChanged();---- ws.on('message', async (data) => {-- // Rate limit: 200/s. A newcomer to a full room sends seven offers and their candidates in-- // a burst; the old 50/s silently dropped some of them and the join stalled.-- const now = Date.now();-- if (now - clientInfo._msgResetTime > 1000) { clientInfo._msgCount = 0; clientInfo._msgResetTime = now; }-- if (++clientInfo._msgCount > 200) return;-- try { await this._handleMessage(ws, JSON.parse(data), resolvedId, peerId); } catch (err) { console.warn('[Call] Message error for peer', peerId, ':', err.message); }-- });-- ws.on('close', () => this._handleDisconnect(ws, resolvedId, peerId).catch(() => {}));-- ws.on('error', () => this._handleDisconnect(ws, resolvedId, peerId).catch(() => {}));-- }---- async _canModerate(user, channelId) {-- if (!user) return false;-- if (permissions.can(user, 'staff.moderation.calls')) return true;-- const ch = this.channels.get(channelId);-- if (ch?.createdBy === user.id) return true;-- if (ch?.streamId) return await permissions.canModerateCallSync(user, ch.streamId);-- return false;-- }---- async _handleMessage(ws, msg, channelId, peerId) {-- const room = this.rooms.get(channelId);-- if (!room) return;---- switch (msg.type) {-- case 'offer': case 'answer': case 'ice-candidate': {-- // Validate SDP/candidate size to prevent abuse-- if (msg.sdp && (typeof msg.sdp.sdp !== 'string' || msg.sdp.sdp.length > 16384)) break;-- if (msg.candidate && (typeof msg.candidate.candidate !== 'string' || msg.candidate.candidate.length > 2048)) break;-- const tp = room.get(msg.targetPeerId);-- if (tp && tp.ws.readyState === WebSocket.OPEN) tp.ws.send(JSON.stringify({ type: msg.type, fromPeerId: peerId, sdp: msg.sdp, candidate: msg.candidate }));-- break;-- }-- case 'mute': {-- const c = room.get(peerId); if (!c) break;-- c.muted = !!msg.muted;-- const m = JSON.stringify({ type: 'peer-muted', peerId, muted: c.muted });-- for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }-- this._notifyChannelsChanged();-- break;-- }-- case 'camera-off': {-- const c = room.get(peerId); if (!c) break;-- c.cameraOff = !!msg.cameraOff;-- const m = JSON.stringify({ type: 'peer-camera', peerId, cameraOff: c.cameraOff });-- for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }-- break;-- }-- case 'speaking': {-- const c = room.get(peerId); if (!c) break;-- const s = !!msg.speaking; if (c.speaking === s) break; c.speaking = s;-- const m = JSON.stringify({ type: 'peer-speaking', peerId, speaking: s });-- for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }-- break;-- }-- case 'auth-update': {-- const c = room.get(peerId); if (!c) break;-- let user = c.user || null;-- if (typeof msg.token === 'string' && msg.token.trim()) {-- const nextUser = await authenticateWs(msg.token);-- if (!nextUser) {-- console.warn('[Call] auth-update rejected for peer', peerId, '(invalid or expired token)');-- } else if (user && user.id !== nextUser.id) {-- console.warn(`[Call] auth-update identity mismatch for peer ${peerId}: keeping existing user ${user.username}, ignoring ${nextUser.username}`);-- } else {-- user = nextUser;-- }-- }-- if (user && this.callBans.has(channelId) && this.callBans.get(channelId).has(user.id)) {-- if (c.ws.readyState === WebSocket.OPEN) { c.ws.send(JSON.stringify({ type: 'error', message: 'Banned' })); c.ws.close(); } break;-- }-- const ch = this.channels.get(channelId);-- c.user = user; c.anonId = user ? null : await chatDelivery.getAnonIdForConnection(c.ip, channelId);-- c.isChannelCreator = !!(user && ch?.createdBy === user.id);-- c.isStreamer = ch?.streamId ? !!(user && (await db.getStreamById(ch.streamId))?.user_id === user.id) : false;-- const pInfo = await this._buildParticipantInfo(peerId, c);-- if (c.ws.readyState === WebSocket.OPEN) c.ws.send(JSON.stringify({-- type: 'self-updated',-- isStreamer: c.isStreamer || c.isChannelCreator,-- canModerate: await this._canModerate(user, channelId),-- participant: pInfo,-- }));-- const u = JSON.stringify({ type: 'peer-updated', ...pInfo });-- for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(u); }-- break;-- }-- case 'force-mute': {-- const sender = room.get(peerId); if (!sender || !(await this._canModerate(sender.user, channelId))) break;-- const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;-- target.forceMuted = !!msg.forceMuted;-- if (target.ws.readyState === WebSocket.OPEN) target.ws.send(JSON.stringify({ type: 'force-muted', forceMuted: target.forceMuted }));-- const m = JSON.stringify({ type: 'peer-force-muted', peerId: msg.targetPeerId, forceMuted: target.forceMuted });-- for (const [pid, info] of room) { if (info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }-- break;-- }-- case 'force-camera-off': {-- const sender = room.get(peerId); if (!sender || !(await this._canModerate(sender.user, channelId))) break;-- const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;-- target.forceCameraOff = !!msg.forceCameraOff;-- if (target.ws.readyState === WebSocket.OPEN) target.ws.send(JSON.stringify({ type: 'force-camera-off', forceCameraOff: target.forceCameraOff }));-- const m = JSON.stringify({ type: 'peer-force-camera-off', peerId: msg.targetPeerId, forceCameraOff: target.forceCameraOff });-- for (const [pid, info] of room) { if (info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }-- break;-- }-- case 'kick': {-- const sender = room.get(peerId); if (!sender || !(await this._canModerate(sender.user, channelId))) break;-- const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;-- if (target.identity) this.kickCooldown.set(`${channelId}:${target.identity}`, Date.now() + KICK_COOLDOWN_MS);-- if (target.ws.readyState === WebSocket.OPEN) { target.ws.send(JSON.stringify({ type: 'kicked' })); target.ws.close(); }-- room.delete(msg.targetPeerId); this.clients.delete(target.ws);-- const m = JSON.stringify({ type: 'peer-left', peerId: msg.targetPeerId, reason: 'kicked' });-- for (const [pid, info] of room) { if (info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }-- this._broadcastParticipantCount(channelId);-- break;-- }-- case 'ban': {-- const sender = room.get(peerId); if (!sender || !(await this._canModerate(sender.user, channelId))) break;-- const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;-- if (!this.callBans.has(channelId)) this.callBans.set(channelId, new Set());-- const banSet = this.callBans.get(channelId);-- if (target.user?.id) {-- banSet.add(target.user.id); // backward compatibility with existing entries-- banSet.add(`u:${target.user.id}`);-- } else {-- if (target.anonId) banSet.add(`a:${target.anonId}`);-- if (target.ip) banSet.add(`ip:${target.ip}`);-- }-- if (target.ws.readyState === WebSocket.OPEN) { target.ws.send(JSON.stringify({ type: 'banned' })); target.ws.close(); }-- room.delete(msg.targetPeerId); this.clients.delete(target.ws);-- const m = JSON.stringify({ type: 'peer-left', peerId: msg.targetPeerId, reason: 'banned' });-- for (const [pid, info] of room) { if (info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }-- this._broadcastParticipantCount(channelId);-- break;-- }-- case 'unban': {-- const sender = room.get(peerId); if (!sender || !(await this._canModerate(sender.user, channelId))) break;-- const uid = parseInt(msg.userId);-- if (uid && this.callBans.has(channelId)) {-- this.callBans.get(channelId).delete(uid);-- this.callBans.get(channelId).delete(`u:${uid}`);-- }-- break;-- }-- case 'end-call': case 'end-channel': {-- const c = room.get(peerId); if (!c) break;-- const ch = this.channels.get(channelId);-- const canEnd = (ch?.createdBy && c.user?.id === ch.createdBy) || c.isStreamer || permissions.can(c.user, 'staff.moderation.calls');-- if (canEnd) { this.endCall(channelId); if (ch && !ch.permanent) this.channels.delete(channelId); }-- break;-- }-- }-- }---- async _handleDisconnect(ws, channelId, peerId) {-- if (!this.clients.has(ws)) return; // already handled (error+close fire back-to-back)-- this.clients.delete(ws);-- const room = this.rooms.get(channelId);-- if (!room) return;-- const leaving = room.get(peerId);-- const leftInfo = leaving ? await this._buildParticipantInfo(peerId, leaving) : { peerId };-- room.delete(peerId);-- const m = JSON.stringify({ type: 'peer-left', ...leftInfo, reason: 'disconnect' });-- for (const [pid, info] of room) { if (info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }-- this._broadcastParticipantCount(channelId);-- this._notifyChannelsChanged();-- if (room.size === 0) {-- this.rooms.delete(channelId);-- const ch = this.channels.get(channelId);-- // For user-created channels: don't delete immediately; track when they-- // became empty so the inactivity timer can clean up after 1 hour.-- if (ch && !ch.permanent && !ch.streamId) {-- ch.lastEmptiedAt = Date.now();-- }-- }-- }---- _broadcastParticipantCount(channelId) {-- const room = this.rooms.get(channelId);-- const count = room ? room.size : 0;-- const m = JSON.stringify({ type: 'participant-count', count, channelId });-- if (room) { for (const [pid, info] of room) { if (info.ws.readyState === WebSocket.OPEN) info.ws.send(m); } }-- }---- /* ── Public helpers ────────────────────────────────────────── */---- getParticipantCount(channelId) { const r = this.rooms.get(channelId); return r ? r.size : 0; }---- async getParticipants(channelId) {-- const r = this.rooms.get(channelId); if (!r) return [];-- const l = []; for (const [pid, info] of r) l.push(await this._buildParticipantInfo(pid, info)); return l;-- }---- getCallBans(channelId) { const b = this.callBans.get(channelId); return b ? [...b] : []; }---- endCall(channelId) {-- const r = this.rooms.get(channelId); if (!r) return;-- const entries = [...r.values()];-- this.rooms.delete(channelId);-- const m = JSON.stringify({ type: 'call-ended' });-- for (const info of entries) {-- this.clients.delete(info.ws);-- if (info.ws.readyState === WebSocket.OPEN) { info.ws.send(m); info.ws.close(); }-- }-- }---- /** Legacy compat: end call by stream ID */-- endStreamCall(streamId) { this.endCall(`stream-${streamId}`); }---- close() {-- for (const [cid] of this.rooms) this.endCall(cid);-- if (this._heartbeatInterval) { clearInterval(this._heartbeatInterval); this._heartbeatInterval = null; }-- if (this._inactivityTimer) { clearInterval(this._inactivityTimer); this._inactivityTimer = null; }-- if (this.wss) this.wss.close();-- }--}----module.exports = new CallServer();@@@ -1,22 +1,15 @@'use strict';/**-- * Who runs voice/video calls (roadmap WS-I task 1). CALLS_AUTHORITY is read here and nowhere else.-- *-- * live (default, anything but "chat"): Live's own call server (./call-server.js) serves /ws/call and-- * the voice-channel routes, and the stream lifecycle creates and removes stream voice channels in-- * it — exactly as before.-- * chat: OpenVibe.Chat owns calls (nginx sends /ws/call, /api/streams/voice-channels… and-- * /api/streams/:id/call there; Chat runs with CHAT_CALLS=1 — OpenVibe.Chat docs/calls-cutover.md).-- * Go-live with a call mode, stream end, the WHIP teardown and the admin force-end ask Chat instead-- * of the local call server:++ * Stream voice channels and calls are OpenVibe.Chat's (roadmap WS-I task 1, T3): nginx sends /ws/call,++ * /api/streams/voice-channels… and /api/streams/:id/call to Chat (Chat runs with CHAT_CALLS=1; OpenVibe.Chat++ * docs/calls-cutover.md). Live's own call server is gone (2026-10-10). What Live still does is tell Chat about a++ * stream's lifecycle: go-live with a call mode, stream end, the WHIP teardown and the admin force-end call* POST /internal/calls/stream-channel { stream_id, mode, user_id }* DELETE /internal/calls/stream-channel/:streamId-- * with Live's service token for audience openvibe.chat (grant chat.live_bridge.write, which these-- * calls still use now that Live's chat bridge is gone). Fire-and-forget for the-- * caller: a network error or a 5xx is retried after 1 s, 5 s and 15 s, unless a later call for the-- * same stream superseded it (a stream that ended while its go-live was being retried is not-- * given a channel afterwards); a 4xx is logged and dropped. Live's /ws/call and voice-channel-- * routes stay mounted for one release, unreached once nginx routes those paths to Chat.++ * with Live's service token for audience openvibe.chat (grant chat.live_bridge.write). Fire-and-forget for the++ * caller: a network error or a 5xx is retried after 1 s, 5 s and 15 s, unless a later call for the same stream++ * superseded it (a stream that ended while its go-live was being retried is not given a channel afterwards); a 4xx++ * is logged and dropped.*/const { CHAT_URL } = require('../chat/chat-authority');@@@ -24,13 +17,6 @@ const AUDIENCE = 'openvibe.chat';const RETRY_MS = [1000, 5000, 15000];const TIMEOUT_MS = 5000;--function authority() {-- return String(process.env.CALLS_AUTHORITY || '').trim().toLowerCase() === 'chat' ? 'chat' : 'live';--}--function isChat() { return authority() === 'chat'; }----const local = () => require('./call-server');--async function send(method, path, body, retried = false) {const principal = require('../net/network-principal');const res = await fetch(`${CHAT_URL}${path}`, {@@@ -84,14 +70,12 @@ async function deliver(streamId, method, path, body) {/** Go-live with a call mode, or the mode changed: the stream's voice channel. */async function createStreamChannel(streamId, mode, userId) {-- if (!isChat()) return local().createStreamChannel(streamId, mode, userId);return await deliver(Number(streamId), 'POST', '/internal/calls/stream-channel', { stream_id: Number(streamId), mode, user_id: Number(userId) });}/** The stream ended (or was force-ended): its call ends and its voice channel goes. */async function removeStreamChannel(streamId) {-- if (!isChat()) return local().removeStreamChannel(streamId);return await deliver(Number(streamId), 'DELETE', `/internal/calls/stream-channel/${Number(streamId)}`);}--module.exports = { authority, isChat, createStreamChannel, removeStreamChannel, stats, RETRY_MS, AUDIENCE };++module.exports = { createStreamChannel, removeStreamChannel, stats, RETRY_MS, AUDIENCE };@@@ -37,7 +37,7 @@ const chatRelayService = require('../integrations/chat-relay-service');// The six chat tables are OpenVibe.Chat's (roadmap T3); the few reads here go through its client.const moderation = require('../chat/moderation-client');const chatReads = require('../chat/chat-reads');--// Stream voice channels: Live's call server, or OpenVibe.Chat's with CALLS_AUTHORITY=chat.++// Stream voice channels are OpenVibe.Chat's: Live tells it when a stream's call starts or ends.const callsAuthority = require('./calls-authority');const { sanitizeOfflineHtml, sanitizeOfflineCss } = require('./offline-html-sanitize');const multer = require('multer');@@@ -1321,165 +1321,8 @@ router.get('/recent-vods', async (req, res) => {}});--/* ── Voice Channels (global, non-stream) ───────────────────── */----router.get('/voice-channels', optionalAuth, async (req, res) => {-- try {-- res.set('Cache-Control', 'no-store'); // private calls differ per viewer; the list is pushed anyway-- res.json({ channels: await callServer.listChannels(req.user || null) });-- } catch (err) {-- console.error('[Streaming]', err.message);-- res.status(500).json({ error: 'Failed to list voice channels' });-- }--});----router.get('/voice-channels/:channelId', optionalAuth, async (req, res) => {-- try {-- const ch = await callServer.getChannel(req.params.channelId, req.user || null);-- if (!ch) return res.status(404).json({ error: 'Channel not found' });-- res.json({ channel: ch });-- } catch (err) {-- console.error('[Streaming]', err.message);-- res.status(500).json({ error: 'Failed to get voice channel' });-- }--});----router.post('/voice-channels', requireAuth, (req, res) => {-- try {-- const { name, mode, maxParticipants } = req.body;-- const ch = callServer.createChannel({ name, mode, createdBy: req.user.id, maxParticipants });-- res.status(201).json({ channel: ch });-- } catch (err) {-- if (err.code === 'CHANNEL_LIMIT') return res.status(400).json({ error: err.message });-- console.error('[Streaming]', err.message);-- res.status(500).json({ error: 'Failed to create voice channel' });-- }--});----router.delete('/voice-channels/:channelId', requireAuth, async (req, res) => {-- try {-- const ok = await callServer.deleteChannel(req.params.channelId, req.user.id);-- if (!ok) return res.status(403).json({ error: 'Cannot delete this channel' });-- res.json({ deleted: true });-- } catch (err) {-- console.error('[Streaming]', err.message);-- res.status(500).json({ error: 'Failed to delete voice channel' });-- }--});----const _callUserRate = new Map(); // userId → [timestamps]--router.post('/voice-channels/call-user', requireAuth, async (req, res) => {-- try {-- // Six calls a minute per account: a ring is a notification on someone else's screen.-- const now = Date.now();-- const recent = (_callUserRate.get(req.user.id) || []).filter((t) => now - t < 60000);-- if (recent.length >= 6) return res.status(429).json({ error: 'Slow down — try again in a minute' });-- recent.push(now); _callUserRate.set(req.user.id, recent);-- const targetUserId = Number(req.body?.user_id || 0);-- const targetUsername = String(req.body?.username || '').trim();---- let targetUser = null;-- if (targetUserId > 0) targetUser = await db.getUserById(targetUserId);-- if (!targetUser && targetUsername) targetUser = await db.getUserByUsername(targetUsername);-- if (!targetUser) return res.status(404).json({ error: 'User not found' });-- if (targetUser.id === req.user.id) return res.status(400).json({ error: 'You cannot call yourself' });-- // Blocks live in Chat (dm_blocks is Chat's table), so the check goes through chat-reads. On an-- // unverifiable answer — a stale cache with Chat unreachable — the ring is REFUSED: ringing-- // someone who blocked the caller is the worse failure, and 'try again shortly' is honest.-- const blocked = await chatReads.dmBlocked(req.user.id, targetUser.id).catch(() => null);-- if (blocked !== false) {-- return blocked === true-- ? res.status(403).json({ error: 'You cannot call this user' })-- : res.status(503).json({ error: 'Could not check the block list — try again shortly' });-- }---- // Reuse caller's existing temp channel if present; otherwise create a private one — a-- // 1:1 call is not something the whole site should see listed and be able to walk into.-- const existing = (await callServer.listChannels(req.user) || []).find(ch => !ch.permanent && !ch.streamId && ch.createdBy === req.user.id) || null;-- const channel = existing || callServer.createChannel({-- name: `${req.user.display_name || req.user.username}'s call`,-- mode: 'mic+cam',-- createdBy: req.user.id,-- maxParticipants: 8,-- isPrivate: true,-- });-- callServer.invite(channel.id, targetUser.id);---- const callerName = req.user.display_name || req.user.username || 'Someone';-- const payload = {-- type: 'vc-call-invite',-- channelId: channel.id,-- channelName: channel.name,-- fromUserId: req.user.id,-- fromUsername: req.user.username,-- fromDisplayName: callerName,-- fromAvatarUrl: req.user.avatar_url || null,-- createdAt: Date.now(),-- };---- // Real-time invite for online users via existing chat WS connections.-- await require('../chat/chat-delivery').event({ kind: 'user', id: targetUser.id }, payload);---- // Persistent cross-site notification for offline users / later join.-- pushNotification({-- user_id: targetUser.id,-- type: 'VC_CALL_INVITE',-- title: `${callerName} is calling you`,-- message: `Join voice channel: ${channel.name}`,-- url: `${config.baseUrl}/?vcInvite=${encodeURIComponent(channel.id)}`,-- rich_content: {-- context: {-- channel_id: channel.id,-- channel_name: channel.name,-- caller_username: req.user.username,-- },-- },-- ...actorInfo(req.user, callerName),-- });---- return res.json({ invited: true, reusedChannel: !!existing, channel });-- } catch (err) {-- if (err.code === 'CHANNEL_LIMIT') return res.status(400).json({ error: err.message });-- console.error('[Streaming]', err.message);-- return res.status(500).json({ error: 'Failed to call user' });-- }--});----router.post('/voice-channels/call-user/respond', requireAuth, async (req, res) => {-- try {-- const callerUserId = Number(req.body?.caller_user_id || 0);-- const channelId = String(req.body?.channel_id || '').trim();-- const channelName = String(req.body?.channel_name || 'Voice Channel').trim() || 'Voice Channel';-- const status = String(req.body?.status || '').trim().toLowerCase();---- const allowed = new Set(['accepted', 'declined', 'busy', 'no-answer', 'canceled']);-- if (!callerUserId) return res.status(400).json({ error: 'caller_user_id is required' });-- if (!channelId) return res.status(400).json({ error: 'channel_id is required' });-- if (!allowed.has(status)) return res.status(400).json({ error: 'Invalid response status' });-- if (callerUserId === req.user.id) return res.status(400).json({ error: 'Invalid caller target' });-- // Only an invited user can answer, and only to the caller who owns that channel.-- const ch = await callServer.getChannel(channelId, req.user);-- if (!ch || ch.createdBy !== callerUserId || !callServer.hasInvite(channelId, req.user.id)) return res.status(403).json({ error: 'No such invite' });---- const fromDisplayName = req.user.display_name || req.user.username || 'Someone';-- await require('../chat/chat-delivery').event({ kind: 'user', id: callerUserId }, {-- type: 'vc-call-response',-- status,-- channelId,-- channelName,-- fromUserId: req.user.id,-- fromUsername: req.user.username,-- fromDisplayName,-- fromAvatarUrl: req.user.avatar_url || null,-- createdAt: Date.now(),-- });---- return res.json({ ok: true });-- } catch (err) {-- console.error('[Streaming]', err.message);-- return res.status(500).json({ error: 'Failed to send call response' });-- }--});++/* Voice channels and stream group calls are OpenVibe.Chat's: nginx sends /api/streams/voice-channels…++ and /api/streams/:id/call there. Live only tells Chat when a stream's call starts or ends (./calls-authority.js). */// ── Broadcast Settings ──────────────────────────────────────router.get('/broadcast-settings', requireAuth, async (req, res) => {@@@ -2465,66 +2308,6 @@ router.post('/channel/:username/follow', requireAuth, async (req, res) => {}});--// ── Group Call: Enable / Disable / Get Status ────────────────--const callServer = require('./call-server');----router.put('/:id/call', requireAuth, async (req, res) => {-- try {-- const stream = await db.getStreamById(req.params.id);-- if (!stream) return res.status(404).json({ error: 'Stream not found' });-- if (stream.user_id !== req.user.id) {-- return res.status(403).json({ error: 'Not your stream' });-- }-- if (!stream.is_live) {-- return res.status(400).json({ error: 'Stream is not live' });-- }---- const { call_mode } = req.body;-- const validModes = ['mic', 'mic+cam', 'cam+mic', null];-- if (!validModes.includes(call_mode)) {-- return res.status(400).json({ error: 'Invalid call mode. Use: mic, mic+cam, cam+mic, or null to disable' });-- }---- await db.run('UPDATE streams SET call_mode = ? WHERE id = ?', [call_mode, stream.id]);---- // Create or remove stream voice channel-- const channelId = `stream-${stream.id}`;-- if (call_mode) {-- await callServer.createStreamChannel(stream.id, call_mode, stream.user_id);-- } else {-- callServer.removeStreamChannel(stream.id);-- }---- res.json({-- call_mode,-- channelId: call_mode ? channelId : null,-- participants: await callServer.getParticipants(channelId),-- participant_count: callServer.getParticipantCount(channelId),-- });-- } catch (err) {-- console.error('[Streams] Call mode error:', err.message);-- res.status(500).json({ error: 'Failed to update call mode' });-- }--});----router.get('/:id/call', optionalAuth, async (req, res) => {-- try {-- const stream = await db.getStreamById(req.params.id);-- if (!stream) return res.status(404).json({ error: 'Stream not found' });-- const channelId = `stream-${stream.id}`;---- res.json({-- call_mode: stream.call_mode || null,-- channelId: stream.call_mode ? channelId : null,-- participants: await callServer.getParticipants(channelId),-- participant_count: callServer.getParticipantCount(channelId),-- });-- } catch (err) {-- console.error('[Streaming]', err.message);-- res.status(500).json({ error: 'Failed to get call status' });-- }--});--// ── RTMP FLV Proxy ───────────────────────────────────────────// Proxies HTTP-FLV from the internal NMS server so the browser fetches// from the same HTTPS origin, avoiding CSP / mixed-content issues.@@@ -1,12 +1,10 @@'use strict';--// Who runs calls (WS-I task 1, server/streaming/calls-authority.js). CALLS_AUTHORITY unset (or--// anything but "chat"): stream voice channels are made and removed by Live's own call server and--// nothing is sent anywhere — as before. "chat": the same hooks become POST/DELETE--// /internal/calls/stream-channel on OpenVibe.Chat with Live's service token for audience--// openvibe.chat, retried after a network error or a 5xx, never after a 4xx, and a retry that a later--// call for the same stream superseded is dropped. The go-live, stream-end, WHIP and admin force-end--// hooks all go through it. Against a stub Network + Chat.++// Calls are OpenVibe.Chat's (WS-I task 1, T3; server/streaming/calls-authority.js). Live's own call server is gone:++// a stream's call hooks are POST/DELETE /internal/calls/stream-channel on OpenVibe.Chat with Live's service token for++// audience openvibe.chat, retried after a network error or a 5xx, never after a 4xx, and a retry that a later call for++// the same stream superseded is dropped. The go-live, stream-end, WHIP and admin force-end hooks all go through it, and++// Live serves no /ws/call or voice-channel route of its own. Against a stub Network + Chat.const assert = require('assert');const fs = require('fs');@@@ -15,7 +13,6 @@ const http = require('http');process.env.OV_OAUTH_CLIENT_ID = 'live';process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';--delete process.env.CALLS_AUTHORITY;const quiet = console.log;console.log = () => {};console.warn = () => {};@@@ -54,39 +51,19 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));const db = require('../server/db/database');db.getStreamById = (id) => ({ id: Number(id), user_id: 7, title: `Stream ${id} title`, is_live: 1 });const calls = require('../server/streaming/calls-authority');-- const callServer = require('../server/streaming/call-server');calls.RETRY_MS.splice(0, calls.RETRY_MS.length, 30, 30, 30);let exit = 0;try {-- // 1. The flag.-- for (const v of [undefined, '', 'live', 'LIVE', 'other']) {-- if (v === undefined) delete process.env.CALLS_AUTHORITY; else process.env.CALLS_AUTHORITY = v;-- assert.strictEqual(calls.authority(), 'live', String(v));-- }-- for (const v of ['chat', 'CHAT', ' chat ']) { process.env.CALLS_AUTHORITY = v; assert.strictEqual(calls.authority(), 'chat', v); }-- delete process.env.CALLS_AUTHORITY;---- // 2. Default: Live's call server, nothing sent.-- const ch = await calls.createStreamChannel(41, 'mic', 7);-- assert.deepStrictEqual([ch.id, ch.mode, ch.name, ch.createdBy], ['stream-41', 'mic', 'Stream 41 title', 7]);-- assert.ok(callServer.channels.has('stream-41'));-- await calls.removeStreamChannel(41);-- assert.ok(!callServer.channels.has('stream-41'));-- await sleep(50);-- assert.deepStrictEqual(seen, [], 'CALLS_AUTHORITY unset sends nothing to Chat');---- // 3. chat: the hooks go to Chat's internal endpoints with Live's service token; nothing local.-- process.env.CALLS_AUTHORITY = 'chat';++ // 1. The hooks go to Chat's internal endpoints with Live's service token.const created = await calls.createStreamChannel(42, 'mic+cam', 7);assert.deepStrictEqual(created, { ok: true });assert.deepStrictEqual(seen[0], { method: 'POST', url: '/internal/calls/stream-channel', auth: 'Bearer svc-live-for-openvibe.chat', body: { stream_id: 42, mode: 'mic+cam', user_id: 7 } });assert.ok(tokenAudiences.includes('openvibe.chat'));-- assert.ok(!callServer.channels.has('stream-42'), 'Live\'s call server is not used');await calls.removeStreamChannel(42);assert.deepStrictEqual([seen[1].method, seen[1].url, seen[1].body], ['DELETE', '/internal/calls/stream-channel/42', null]);-- // 4. A 5xx (or Chat down) is retried; a 4xx is not.++ // 2. A 5xx (or Chat down) is retried; a 4xx is not.seen.length = 0;let n = 0;answer = () => (++n === 1 ? [503, { error: 'restarting' }] : [200, { ok: true }]);@@@ -98,7 +75,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));assert.strictEqual(seen.length, 1, 'a refusal is not retried');assert.ok(/409/.test(calls.stats.lastError));-- // 5. A retry that a later call for the same stream superseded is dropped: a stream that ended++ // 3. A retry that a later call for the same stream superseded is dropped: a stream that ended// while its go-live was being retried does not get its channel back.seen.length = 0;answer = (r) => (r.method === 'POST' ? [502, { error: 'bad gateway' }] : [200, { ok: true, removed: false }]);@@@ -110,7 +87,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));assert.deepStrictEqual(seen.map((r) => r.method), ['POST', 'DELETE'], 'the go-live was not retried after the stream ended');assert.ok(calls.stats.superseded >= 1);-- // 6. Every stream hook goes through the authority, not straight to Live's call server.++ // 4. Every stream hook goes through the authority, not straight to Live's call server.const src = (f) => fs.readFileSync(path.join(__dirname, '..', 'server', f), 'utf8');const routes = src('streaming/routes.js');assert.ok(routes.includes('callsAuthority.createStreamChannel(streamId, callMode, req.user.id)'), 'go-live');@@@ -119,16 +96,18 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));assert.ok(/calls-authority'\)\.removeStreamChannel\(/.test(src(f)), f);assert.ok(!/call-server'\)\.removeStreamChannel\(/.test(src(f)), `${f} no longer calls Live's call server directly`);}-- assert.strictEqual((src('streaming/calls-authority.js').match(/process\.env\.CALLS_AUTHORITY/g) || []).length, 1);-- for (const f of ['index.js', 'streaming/routes.js', 'streaming/whip-handler.js', 'admin/routes.js']) {-- assert.ok(!src(f).includes('process.env.CALLS_AUTHORITY'), `${f} does not read the flag itself`);++ // 5. Live's own call server is gone: no module, no /ws/call upgrade, no voice-channel or group-call routes, no flag.++ assert.ok(!fs.existsSync(path.join(__dirname, '..', 'server', 'streaming', 'call-server.js')), 'call-server.js is deleted');++ assert.ok(!src('index.js').includes("'/ws/call'"), 'index.js upgrades no /ws/call');++ assert.ok(!/router\.(get|post|put|delete)\('\/(voice-channels|:id\/call)/.test(routes), 'no voice-channel or group-call routes in Live');++ for (const f of ['index.js', 'streaming/routes.js', 'streaming/calls-authority.js', 'streaming/whip-handler.js', 'admin/routes.js']) {++ assert.ok(!src(f).includes('CALLS_AUTHORITY'), `${f} reads no CALLS_AUTHORITY`);}} catch (err) {exit = 1;console.error(err);}stub.close();-- try { callServer.close(); } catch { /* */ }console.log = quiet;console.log(exit ? 'calls authority: FAILED' : 'calls authority: all checks passed');process.exit(exit);@@@ -1027,35 +1027,6 @@ const waitFor = async (pred, what, ms = 3000) => {assert.deepStrictEqual(deleted, ['/assets/555111'], 'the Media asset id in the request is the one deleted');}-- // (4) The call invite asks Chat for the block state: blocked refuses, an unknown state-- // (Chat down past the cache) refuses too, never rings through.-- {-- const callerToken = jwt.sign({ sub: String(streamer), username: 'streamer', role: 'streamer' }, keys.privateKey, { algorithm: 'RS256', issuer: ISS, expiresIn: 300 });-- const callUser = (body) => call('POST', '/api/streams/voice-channels/call-user', { token: callerToken, body });-- chatReads._reset();-- readState.dmBlocked = true;-- try {-- const blocked = await callUser({ user_id: viewer });-- assert.strictEqual(blocked.status, 403, 'a blocked pair cannot ring');-- assert.match(blocked.body.error, /cannot call this user/i);---- readState.dmBlocked = false;-- chatReads._reset();-- const allowed = await callUser({ user_id: viewer });-- assert.strictEqual(allowed.status, 200, `an unblocked pair rings (${allowed.text})`);-- assert.strictEqual(allowed.body.invited, true);---- readState.down = true;-- chatReads._reset();-- const unknown = await callUser({ user_id: viewer });-- assert.strictEqual(unknown.status, 503, 'an unverifiable block state refuses the ring');-- assert.match(unknown.body.error, /try again shortly/i);-- } finally {-- readState.dmBlocked = false;-- readState.down = false;-- chatReads._reset();-- }-- }}// 12. Robot commands from chat pass the control panel's gate: control mode, anonymous switch, whitelist, cooldown.@@@ -23,8 +23,10 @@ const ISSUER = 'https://openvibe.network';// Live's chat widget and messenger are Chat's N-1 clients, checked there. /api/emotes is Chat's too// (plan T3; nginx sends it there), and the channel-moderation router Live used to answer at// /api/channels/* moved to Chat's /api/chat/channels/* — so an N-1 tab's calls to those are--// Chat's to answer, and this harness neither records nor replays them against Live.--const CHAT_PATHS = /^\/(api\/(chat|dm|tts|emotes|channels)(\/|$)|api\/sounds(\/|$)|ws\/)/;++// Chat's to answer, and this harness neither records nor replays them against Live. Calls are Chat's too since Live's++// call server was deleted (T3): nginx sends /api/streams/voice-channels… and /api/streams/:id/call there (Chat's++// deploy/nginx/calls/openvibe.live-calls.locations.conf).++const CHAT_PATHS = /^\/(api\/(chat|dm|tts|emotes|channels)(\/|$)|api\/sounds(\/|$)|api\/streams\/voice-channels(\/|$)|api\/streams\/[^/]+\/call$|ws\/)/;function freePort() {return new Promise((resolve) => { const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); });
@@ -177,12 +177,8 @@ OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
@@ -177,12 +177,8 @@ OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
# Bot's origin for the embed URL (<origin>/panel/<robot id>/embed): a bare https origin, or
# Bot's origin for the embed URL (<origin>/panel/<robot id>/embed): a bare https origin, or
# http://localhost* outside production; anything else falls back to the default with a warning.
# http://localhost* outside production; anything else falls back to the default with a warning.
#LIVE_BOT_URL=https://openvibe.bot
#LIVE_BOT_URL=https://openvibe.bot
-# Voice/video calls run in OpenVibe.Chat when this is "chat" (WS-I task 1;
-# server/streaming/calls-authority.js): go-live, stream end, WHIP teardown and the admin force-end
-# create/remove stream voice channels in Chat (POST/DELETE /internal/calls/stream-channel, the chat
-# bridge's service token) instead of Live's call server. Set it together with the nginx include that
-# sends /ws/call and the call routes to Chat (OpenVibe.Chat docs/calls-cutover.md). Unset = Live.
-#CALLS_AUTHORITY=chat
+# Voice/video calls are OpenVibe.Chat's (no setting): nginx sends /ws/call and the call routes there, and Live
+# creates/removes stream voice channels in Chat on go-live and stream end (server/streaming/calls-authority.js).
# Money authority (roadmap Wave 8, ADR-012). live (default): Live's own Vibes columns, exactly as
# Money authority (roadmap Wave 8, ADR-012). live (default): Live's own Vibes columns, exactly as
# before. billing: OpenVibe.Billing is the ledger — Live calls it for checkouts, donations, cashouts,
# before. billing: OpenVibe.Billing is the ledger — Live calls it for checkouts, donations, cashouts,
@@ -96,13 +96,13 @@ public/js/app-*.js route code split out of app.js: home, channel, media, c
@@ -96,13 +96,13 @@ public/js/app-*.js route code split out of app.js: home, channel, media, c
page. Auto-clips made before Media kept the flag are marked from Live's `auto_clip_log`
page. Auto-clips made before Media kept the flag are marked from Live's `auto_clip_log`
(`syncAutoClipFlags` in `server/ai/auto-clip-job.js`).
(`syncAutoClipFlags` in `server/ai/auto-clip-job.js`).
- **Voice channels.** `public/js/call.js` + `voice-channels.js` (feature `voice`, chat route): full-mesh
- **Voice channels.** `public/js/call.js` + `voice-channels.js` (feature `voice`, chat route): full-mesh
- WebRTC, signalling over `/ws/call` (`server/streaming/call-server.js`). The newcomer offers, existing
- members answer; the server pushes the channel list to every chat socket (`voice-channels` message);
- TURN credentials come from `server/net/turn.js` (short-lived with `TURN_AUTH_SECRET`). OpenVibe.Chat
- runs the same protocol and routes (its `server/calls/`); `CALLS_AUTHORITY=chat`
- (`server/streaming/calls-authority.js`) plus an nginx include moves calls there, and then go-live,
- stream end, WHIP teardown and the admin force-end create/remove stream voice channels in Chat
- (OpenVibe.Chat `docs/calls-cutover.md`).
+ WebRTC, signalling over `/ws/call`. Calls are OpenVibe.Chat's (its `server/calls/`): nginx sends `/ws/call`,
+ `/api/streams/voice-channels…` and `/api/streams/:id/call` to Chat (the include in `deploy/nginx/openvibe.live.conf`),
+ and Live's own call server was deleted on 2026-10-10. The newcomer offers, existing members answer; Chat pushes the
+ channel list to every chat socket (`voice-channels` message); TURN credentials come from `server/net/turn.js`
+ (short-lived with `TURN_AUTH_SECRET`). Live tells Chat about a stream's call through
+ `server/streaming/calls-authority.js`: go-live, stream end, WHIP teardown and the admin force-end create or remove the
+ stream's voice channel (POST/DELETE `/internal/calls/stream-channel`; OpenVibe.Chat `docs/calls-cutover.md`).
- **Budgets.** `scripts/perf/check-budgets.js` (in `npm test`) fails if the home page's HTML, eager
- **Budgets.** `scripts/perf/check-budgets.js` (in `npm test`) fails if the home page's HTML, eager
JavaScript or blocking CSS grow past recorded limits, or if route code returns to the home page.
JavaScript or blocking CSS grow past recorded limits, or if route code returns to the home page.
@@ -66,7 +66,6 @@ router.get('/diagnostics', async (req, res) => {
@@ -66,7 +66,6 @@ router.get('/diagnostics', async (req, res) => {
db,
db,
chatDelivery: require('../chat/chat-delivery'),
chatDelivery: require('../chat/chat-delivery'),
broadcastServer: require('../streaming/broadcast-server'),
broadcastServer: require('../streaming/broadcast-server'),
- callServer: (() => { try { return require('../streaming/call-server'); } catch { return null; } })(),
restreamManager: (() => { try { return require('../streaming/restream-manager'); } catch { return null; } })(),
restreamManager: (() => { try { return require('../streaming/restream-manager'); } catch { return null; } })(),
}));
}));
});
});
@@ -26,8 +26,8 @@
@@ -26,8 +26,8 @@
*
*
* Synchronous reads came from Chat's presence snapshot (GET /internal/chat/presence), polled every few
* Synchronous reads came from Chat's presence snapshot (GET /internal/chat/presence), polled every few
* seconds by init()/close() (called from server/index.js at boot and drain): connection and viewer counts,
* seconds by init()/close() (called from server/index.js at boot and drain): connection and viewer counts,
- * slow modes, a connected user's address. Live's own address/anon helpers live here too (call-server,
- * Chat's /internal/chat-effects/anon), along with Live's writes to data Chat caches (IP approvals, bans)
+ * slow modes, a connected user's address. Live's own address/anon helpers live here too (Chat's
+ * /internal/chat-effects/anon), along with Live's writes to data Chat caches (IP approvals, bans)
* which send Chat a cache hint instead of waiting for its cache to expire.
* which send Chat a cache hint instead of waiting for its cache to expire.
*/
*/
const db = require('../db/database');
const db = require('../db/database');
@@ -189,7 +189,7 @@ function findClientByAnonId(anonId, streamId) {
@@ -189,7 +189,7 @@ function findClientByAnonId(anonId, streamId) {
return hit ? { anonId: hit.anon_id, ip: hit.ip, streamId: hit.stream_id || null, user: null } : null;
return hit ? { anonId: hit.anon_id, ip: hit.ip, streamId: hit.stream_id || null, user: null } : null;
}
}
-// ── Live's own address / anon helpers (Chat's /internal/chat-effects/anon, call-server) ──
+// ── Live's own address / anon helpers (Chat's /internal/chat-effects/anon) ──
const _openvibeToolsUrl = process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:3100';
const _openvibeToolsUrl = process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:3100';
const anonMap = new Map(); // IP → number
const anonMap = new Map(); // IP → number
const pendingResolves = new Map(); // IP → pending resolve promise (dedup concurrent)
const pendingResolves = new Map(); // IP → pending resolve promise (dedup concurrent)
@@ -48,7 +48,6 @@ async function snapshot(services = {}) {
@@ -48,7 +48,6 @@ async function snapshot(services = {}) {
connections: {
connections: {
chat: await safe(() => services.chatDelivery.getTotalConnections()),
chat: await safe(() => services.chatDelivery.getTotalConnections()),
broadcast: await safe(() => services.broadcastServer.clients.size),
broadcast: await safe(() => services.broadcastServer.clients.size),
- call: await safe(() => services.callServer.clients.size),
liveEventStreams: await safe(() => require('./streaming/live-events').clientCount()),
liveEventStreams: await safe(() => require('./streaming/live-events').clientCount()),
},
},
streams: {
streams: {
@@ -102,7 +102,6 @@ const rtmpServer = require('./streaming/rtmp-server');
@@ -102,7 +102,6 @@ const rtmpServer = require('./streaming/rtmp-server');
const chatDelivery = require('./chat/chat-delivery');
const chatDelivery = require('./chat/chat-delivery');
const controlServer = require('./controls/control-server');
const controlServer = require('./controls/control-server');
const broadcastServer = require('./streaming/broadcast-server');
const broadcastServer = require('./streaming/broadcast-server');
-const callServer = require('./streaming/call-server');
const VibeCodingPublishServer = require('./vibe-coding/publish-server');
const VibeCodingPublishServer = require('./vibe-coding/publish-server');
// Routes
// Routes
@@ -197,7 +196,6 @@ const stopper = gracefulStop({
@@ -197,7 +196,6 @@ const stopper = gracefulStop({
try { restreamManager.stopViewerCountPolling(); } catch { /* */ }
try { restreamManager.stopViewerCountPolling(); } catch { /* */ }
try { restreamManager.stopAll(); } catch { /* */ }
try { restreamManager.stopAll(); } catch { /* */ }
try { recorder.stopAll(); } catch { /* */ }
try { recorder.stopAll(); } catch { /* */ }
- try { callServer.close(); } catch { /* */ }
try { chatDelivery.close(); } catch { /* */ }
try { chatDelivery.close(); } catch { /* */ }
try { controlServer.close(); } catch { /* */ }
try { controlServer.close(); } catch { /* */ }
try { broadcastServer.close(); } catch { /* */ }
try { broadcastServer.close(); } catch { /* */ }
@@ -856,7 +854,7 @@ app.get('/api/ready', readiness.handler);
@@ -856,7 +854,7 @@ app.get('/api/ready', readiness.handler);
observability.registerDomainGauges(metricsRegistry, {
observability.registerDomainGauges(metricsRegistry, {
liveStreams: async () => (await db.getLiveStreams()).length,
liveStreams: async () => (await db.getLiveStreams()).length,
- wsServers: { broadcast: broadcastServer, control: controlServer, call: callServer },
+ wsServers: { broadcast: broadcastServer, control: controlServer },
outboxStatus: async () => await require('./events/stream-events').counts(),
outboxStatus: async () => await require('./events/stream-events').counts(),
});
});
@@ -1144,8 +1142,6 @@ server.on('upgrade', async (req, socket, head) => {
@@ -1144,8 +1142,6 @@ server.on('upgrade', async (req, socket, head) => {
broadcastServer.handleUpgrade(req, socket, head);
broadcastServer.handleUpgrade(req, socket, head);
} else if (url.startsWith('/ws/control')) {
} else if (url.startsWith('/ws/control')) {
controlServer.handleUpgrade(req, socket, head);
controlServer.handleUpgrade(req, socket, head);
- } else if (url.startsWith('/ws/call')) {
- callServer.handleUpgrade(req, socket, head);
} else if (url.startsWith('/ws/robotstreamer-publish')) {
} else if (url.startsWith('/ws/robotstreamer-publish')) {
robotStreamerService.handleUpgrade(req, socket, head);
robotStreamerService.handleUpgrade(req, socket, head);
} else {
} else {
@@ -1217,11 +1213,7 @@ async function start() {
@@ -1217,11 +1213,7 @@ async function start() {
// 4b. Initialize broadcast server
// 4b. Initialize broadcast server
broadcastServer.init(server);
broadcastServer.init(server);
- // 4d. Initialize group call signaling server
- callServer.init(server);
- if (require('./streaming/calls-authority').isChat()) {
- console.log('[Calls] CALLS_AUTHORITY=chat — stream voice channels are created and removed in OpenVibe.Chat; Live\'s /ws/call stays up, unreached once nginx routes it to Chat');
- }
+ // Group calls and voice channels are OpenVibe.Chat's (/ws/call is routed there by nginx).
for (const stream of await db.getLiveStreams()) {
for (const stream of await db.getLiveStreams()) {
robotStreamerService.startForStream(stream).catch((err) => {
robotStreamerService.startForStream(stream).catch((err) => {
@@ -1344,7 +1336,6 @@ async function start() {
@@ -1344,7 +1336,6 @@ async function start() {
console.log(`[Server] WebSocket: ws://${config.host}:${config.port}/ws/chat`);
console.log(`[Server] WebSocket: ws://${config.host}:${config.port}/ws/chat`);
console.log(`[Server] WebSocket: ws://${config.host}:${config.port}/ws/broadcast`);
console.log(`[Server] WebSocket: ws://${config.host}:${config.port}/ws/broadcast`);
console.log(`[Server] WebSocket: ws://${config.host}:${config.port}/ws/control`);
console.log(`[Server] WebSocket: ws://${config.host}:${config.port}/ws/control`);
- console.log(`[Server] WebSocket: ws://${config.host}:${config.port}/ws/call`);
console.log(`[Server] Environment: ${config.nodeEnv}`);
console.log(`[Server] Environment: ${config.nodeEnv}`);
console.log(`[Server] BASE_URL: ${config.baseUrl}`);
console.log(`[Server] BASE_URL: ${config.baseUrl}`);
console.log(`[Server] WHIP_PUBLIC_URL: ${config.whip?.publicUrl}`);
console.log(`[Server] WHIP_PUBLIC_URL: ${config.whip?.publicUrl}`);
deleted file mode 100644
deleted file mode 100644
@@ -1,548 +0,0 @@
@@ -1,548 +0,0 @@
-/**
- * OpenVibe.Live — Voice Channel WebSocket Server
- *
- * Discord-style voice/video channels accessible from the Chat tab.
- * Uses full-mesh WebRTC peer connections (each participant ↔ every other).
- * Signaling only — no media passes through the server.
- *
- * A permanent "Public" lobby channel always exists. Users can create
- * temporary channels. Stream-linked channels are auto-created when a
- * streamer enables voice on their stream.
- *
- * WebSocket path: /ws/call?channelId=<id>&token=X
- *
- * Channel modes:
- * 'mic' — Microphone only
- * 'mic+cam' — Mic + optional camera
- * 'cam+mic' — Both mic and camera
- */
-
-const WebSocket = require('ws');
-const db = require('../db/database');
-const { extractWsToken, authenticateWs } = require('../auth/auth');
-const permissions = require('../auth/permissions');
-const chatDelivery = require('../chat/chat-delivery');
-const cosmetics = require('../monetization/cosmetics');
-
-const WS_HEARTBEAT_MS = 30000;
-const MAX_PARTICIPANTS = 8;
-const MAX_SOCKETS_PER_IP = 3; // one household in one channel; never one host filling it
-const KICK_COOLDOWN_MS = 60 * 1000;
-const COSMETIC_TTL_MS = 60 * 1000;
-const PUBLIC_CHANNEL_ID = 'public';
-
-class CallServer {
- constructor() {
- this.wss = null;
- /** channelId → Map<peerId, clientInfo> */
- this.rooms = new Map();
- /** ws → { channelId, peerId } */
- this.clients = new Map();
- /** channelId → Set<userId> */
- this.callBans = new Map();
- /** channelId → channel metadata { id, name, mode, createdBy, streamId?, permanent, createdAt, maxParticipants } */
- this.channels = new Map();
- this._nextPeerId = 1;
- this._heartbeatInterval = null;
- this._inactivityTimer = null;
- /** `${channelId}:${identity}` → until (ms): a kicked participant waits before rejoining */
- this.kickCooldown = new Map();
- /** userId → { at, profile } */
- this._cosmeticCache = new Map();
- this._notifyTimer = null;
-
- // Seed the permanent Public channel
- this.channels.set(PUBLIC_CHANNEL_ID, {
- id: PUBLIC_CHANNEL_ID, name: 'Public', mode: 'mic+cam', createdBy: null,
- streamId: null, permanent: true, createdAt: Date.now(), maxParticipants: MAX_PARTICIPANTS,
- });
- }
-
- init(server) {
- this.wss = new WebSocket.Server({ noServer: true, maxPayload: 256 * 1024, perMessageDeflate: false });
- if (this._heartbeatInterval) clearInterval(this._heartbeatInterval);
- this._heartbeatInterval = setInterval(() => {
- if (!this.wss) return;
- this.wss.clients.forEach((ws) => {
- if (ws.isAlive === false) { try { ws.terminate(); } catch {} return; }
- ws.isAlive = false;
- try { ws.ping(); } catch {}
- });
- }, WS_HEARTBEAT_MS);
- this.wss.on('connection', (ws, req) => this._handleConnection(ws, req).catch((e) => console.warn('[Call] connection failed:', e && e.message)));
-
- // Cleanup user-created channels that have been empty for over an hour
- if (this._inactivityTimer) clearInterval(this._inactivityTimer);
- this._inactivityTimer = setInterval(() => this._cleanupInactiveChannels(), 5 * 60 * 1000);
-
- console.log('[CallServer] Voice channels initialized');
- }
-
- _cleanupInactiveChannels() {
- const ONE_HOUR = 60 * 60 * 1000;
- const now = Date.now();
- for (const [k, until] of this.kickCooldown) if (until <= now) this.kickCooldown.delete(k);
- for (const [channelId, ch] of this.channels) {
- if (ch.permanent || ch.streamId) continue;
- const room = this.rooms.get(channelId);
- const isEmpty = !room || room.size === 0;
- if (isEmpty && ch.lastEmptiedAt && (now - ch.lastEmptiedAt) >= ONE_HOUR) {
- console.log(`[CallServer] Auto-deleting inactive channel "${ch.name}" (${channelId}) — empty for ${Math.round((now - ch.lastEmptiedAt) / 60000)}m`);
- this.channels.delete(channelId);
- this.callBans.delete(channelId);
- }
- }
- }
-
- handleUpgrade(req, socket, head) {
- if (!req.url.startsWith('/ws/call')) return false;
- this.wss.handleUpgrade(req, socket, head, (ws) => { this.wss.emit('connection', ws, req); });
- return true;
- }
-
- /* ── Channel management ──────────────────────────────────── */
-
- /** Channels a viewer may see: a private call only for its creator, its invitees and staff. */
- async listChannels(viewer = null) {
- const result = [];
- for (const [id, ch] of this.channels) {
- if (ch.private && !this._canSeePrivate(ch, viewer)) continue;
- const room = this.rooms.get(id);
- const participants = [];
- if (room) { for (const [pid, info] of room) participants.push(await this._buildParticipantInfo(pid, info)); }
- result.push({ ...this._publicChannel(ch), participantCount: room ? room.size : 0, participants });
- }
- return result;
- }
- _publicChannel(ch) { const { invited, ...rest } = ch; return rest; }
- _canSeePrivate(ch, viewer) {
- if (!ch.private) return true;
- if (!viewer) return false;
- if (permissions.can(viewer, 'staff.moderation.calls')) return true;
- return ch.createdBy === viewer.id || !!(ch.invited && ch.invited.has(viewer.id));
- }
- /** A caller invited this user to their call (POST /voice-channels/call-user). */
- invite(channelId, userId) {
- const ch = this.channels.get(channelId);
- if (!ch) return false;
- if (!ch.invited) ch.invited = new Set();
- ch.invited.add(userId);
- return true;
- }
- hasInvite(channelId, userId) {
- const ch = this.channels.get(channelId);
- return !!(ch && ch.invited && ch.invited.has(userId));
- }
- /** Debounced "the list changed" for every chat socket (the sidebar used to poll every 4 s). */
- _notifyChannelsChanged() {
- if (this._notifyTimer) return;
- this._notifyTimer = setTimeout(async () => {
- this._notifyTimer = null;
- try { await require('../chat/chat-delivery').event({ kind: 'all' }, { type: 'voice-channels', channels: await this.listChannels(null) }); } catch { /* */ }
- }, 400);
- }
-
- async getChannel(channelId, viewer = null) {
- const ch = this.channels.get(channelId);
- if (!ch || !this._canSeePrivate(ch, viewer)) return null;
- const room = this.rooms.get(channelId);
- const participants = [];
- if (room) { for (const [pid, info] of room) participants.push(await this._buildParticipantInfo(pid, info)); }
- return { ...this._publicChannel(ch), participantCount: room ? room.size : 0, participants };
- }
-
- createChannel({ name, mode, createdBy, maxParticipants, isPrivate = false }) {
- // One user-created channel per user
- for (const [, ch] of this.channels) {
- if (!ch.permanent && !ch.streamId && ch.createdBy === createdBy) {
- throw Object.assign(new Error('You already have a voice channel. Delete it first.'), { code: 'CHANNEL_LIMIT' });
- }
- }
- const id = `user-${createdBy}-${Date.now().toString(36)}`;
- const ch = {
- id, name: String(name || 'Voice Channel').slice(0, 40),
- mode: ['mic', 'mic+cam', 'cam+mic'].includes(mode) ? mode : 'mic+cam',
- createdBy, streamId: null, permanent: false, createdAt: Date.now(),
- maxParticipants: Math.min(Math.max(Number(maxParticipants) || MAX_PARTICIPANTS, 2), MAX_PARTICIPANTS),
- lastEmptiedAt: Date.now(), // starts empty; inactivity timer tracks from creation
- private: !!isPrivate,
- };
- this.channels.set(id, ch);
- this._notifyChannelsChanged();
- return this._publicChannel(ch);
- }
-
- async createStreamChannel(streamId, mode, streamerId) {
- const id = `stream-${streamId}`;
- const existing = this.channels.get(id);
- if (existing) { const old = existing.mode; existing.mode = mode; if (old !== mode) this.endCall(id); return existing; }
- const stream = await db.getStreamById(streamId);
- const ch = {
- id, name: stream ? (stream.title || `Stream ${streamId}`) : `Stream ${streamId}`,
- mode: ['mic', 'mic+cam', 'cam+mic'].includes(mode) ? mode : 'mic',
- createdBy: streamerId, streamId, permanent: false, createdAt: Date.now(), maxParticipants: MAX_PARTICIPANTS,
- };
- this.channels.set(id, ch);
- return ch;
- }
-
- removeStreamChannel(streamId) {
- const id = `stream-${streamId}`;
- this.endCall(id);
- this.channels.delete(id);
- this.callBans.delete(id);
- }
-
- async deleteChannel(channelId, userId) {
- const ch = this.channels.get(channelId);
- if (!ch || ch.permanent) return false;
- if (ch.createdBy !== userId) {
- const user = await db.getUserById(userId);
- if (!user || !permissions.can(user, 'staff.moderation.calls')) return false;
- }
- this.endCall(channelId);
- this.channels.delete(channelId);
- this.callBans.delete(channelId);
- this._notifyChannelsChanged();
- return true;
- }
-
- /* ── WebRTC signaling ──────────────────────────────────────── */
-
- _generatePeerId() { return `call-peer-${this._nextPeerId++}-${Date.now().toString(36)}`; }
-
- async _buildParticipantInfo(peerId, info) {
- let cosmeticProfile = {};
- if (info.user?.id) {
- // One query per participant per minute, not one per participant per list request.
- const c = this._cosmeticCache.get(info.user.id);
- if (c && Date.now() - c.at < COSMETIC_TTL_MS) cosmeticProfile = c.profile;
- else { try { cosmeticProfile = await cosmetics.getCosmeticProfile(info.user.id) || {}; } catch {} this._cosmeticCache.set(info.user.id, { at: Date.now(), profile: cosmeticProfile }); }
- }
- return {
- peerId, username: info.user ? info.user.username : null,
- anonId: info.anonId || null,
- displayName: info.user ? (info.user.display_name || info.user.username) : info.anonId,
- userId: info.user ? info.user.id : null,
- avatarUrl: info.user ? info.user.avatar_url : null,
- profileColor: info.user ? info.user.profile_color : null,
- isChannelCreator: info.isChannelCreator || false,
- isStreamer: info.isStreamer || false,
- muted: info.muted, cameraOff: info.cameraOff,
- forceMuted: info.forceMuted || false, forceCameraOff: info.forceCameraOff || false,
- speaking: info.speaking || false,
- nameFX: cosmeticProfile.nameFX || null, particleFX: cosmeticProfile.particleFX || null, hatFX: cosmeticProfile.hatFX || null,
- };
- }
-
- async _handleConnection(ws, req) {
- const url = new URL(req.url, 'http://localhost');
- const channelId = url.searchParams.get('channelId') || url.searchParams.get('streamId');
- const token = extractWsToken(req);
- const ip = chatDelivery.normalizeIp(req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress);
-
- ws.isAlive = true;
- ws.on('pong', () => { ws.isAlive = true; });
- try { ws._socket?.setNoDelay(true); } catch {}
-
- if (!channelId) { ws.send(JSON.stringify({ type: 'error', message: 'Missing channelId' })); ws.close(); return; }
-
- // Legacy: bare number → stream-<N>
- const resolvedId = /^\d+$/.test(channelId) ? `stream-${channelId}` : channelId;
-
- const channel = this.channels.get(resolvedId);
- if (!channel) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }
-
- // If stream-linked, verify stream is live
- if (channel.streamId) {
- const stream = await db.getStreamById(channel.streamId);
- if (!stream || !stream.is_live) { ws.send(JSON.stringify({ type: 'error', message: 'Stream not live' })); ws.close(); return; }
- }
-
- const user = await authenticateWs(token);
- if (channel.private && !this._canSeePrivate(channel, user)) { ws.send(JSON.stringify({ type: 'error', message: 'This is a private call' })); ws.close(); return; }
-
- const peerId = this._generatePeerId();
- const anonId = user ? null : await chatDelivery.getAnonIdForConnection(ip, resolvedId);
- const identity = user ? `u:${user.id}` : (anonId ? `a:${anonId}` : `ip:${ip}`);
- const cooled = this.kickCooldown.get(`${resolvedId}:${identity}`);
- if (cooled && cooled > Date.now()) { ws.send(JSON.stringify({ type: 'error', message: 'You were removed from this channel; try again in a minute' })); ws.close(); return; }
- const isChannelCreator = user && channel.createdBy === user.id;
- const isStreamer = channel.streamId ? (user && (await db.getStreamById(channel.streamId))?.user_id === user.id) : false;
- const canModerate = await this._canModerate(user, resolvedId);
-
- const bans = this.callBans.get(resolvedId);
- if (bans) {
- const banned = user
- ? bans.has(user.id) || bans.has(`u:${user.id}`)
- : bans.has(`a:${anonId}`) || bans.has(`ip:${ip}`);
- if (banned) {
- ws.send(JSON.stringify({ type: 'error', message: 'You are banned from this voice channel' })); ws.close(); return;
- }
- }
-
- if (!this.rooms.has(resolvedId)) this.rooms.set(resolvedId, new Map());
- const room = this.rooms.get(resolvedId);
- // The same account again (a second tab, or a reconnect before the old socket timed out)
- // replaces its older socket instead of standing next to it as a ghost for a minute.
- if (user) {
- for (const [pid, info] of room) {
- if (info.user && info.user.id === user.id) {
- try { info.ws.send(JSON.stringify({ type: 'replaced' })); } catch { /* */ }
- await this._handleDisconnect(info.ws, resolvedId, pid);
- try { info.ws.close(); } catch { /* */ }
- }
- }
- }
- let fromIp = 0;
- for (const [, info] of room) if (info.ip === ip) fromIp++;
- if (fromIp >= MAX_SOCKETS_PER_IP && !permissions.can(user, 'staff.limits.exempt')) { ws.send(JSON.stringify({ type: 'error', message: 'Too many connections from your network' })); ws.close(); return; }
- const maxP = channel.maxParticipants || MAX_PARTICIPANTS;
- if (room.size >= maxP) { ws.send(JSON.stringify({ type: 'error', message: `Channel full (max ${maxP})` })); ws.close(); return; }
-
- const clientInfo = { ws, user, anonId, ip, peerId, identity, muted: false, cameraOff: true, forceMuted: false, forceCameraOff: false, speaking: false, isChannelCreator, isStreamer, _msgCount: 0, _msgResetTime: Date.now() };
- this.clients.set(ws, { channelId: resolvedId, peerId });
- room.set(peerId, clientInfo);
-
- // Mark channel as active (reset inactivity timer)
- if (channel && !channel.permanent && !channel.streamId) {
- channel.lastEmptiedAt = null;
- }
-
- const participants = [];
- for (const [pid, info] of room) participants.push(await this._buildParticipantInfo(pid, info));
-
- ws.send(JSON.stringify({
- type: 'welcome',
- peerId,
- channelId: resolvedId,
- channelName: channel.name,
- callMode: channel.mode,
- participants,
- isStreamer: isStreamer || isChannelCreator,
- canModerate,
- }));
-
- const joinMsg = JSON.stringify({ type: 'peer-joined', ...(await this._buildParticipantInfo(peerId, clientInfo)) });
- for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(joinMsg); }
- this._broadcastParticipantCount(resolvedId);
- this._notifyChannelsChanged();
-
- ws.on('message', async (data) => {
- // Rate limit: 200/s. A newcomer to a full room sends seven offers and their candidates in
- // a burst; the old 50/s silently dropped some of them and the join stalled.
- const now = Date.now();
- if (now - clientInfo._msgResetTime > 1000) { clientInfo._msgCount = 0; clientInfo._msgResetTime = now; }
- if (++clientInfo._msgCount > 200) return;
- try { await this._handleMessage(ws, JSON.parse(data), resolvedId, peerId); } catch (err) { console.warn('[Call] Message error for peer', peerId, ':', err.message); }
- });
- ws.on('close', () => this._handleDisconnect(ws, resolvedId, peerId).catch(() => {}));
- ws.on('error', () => this._handleDisconnect(ws, resolvedId, peerId).catch(() => {}));
- }
-
- async _canModerate(user, channelId) {
- if (!user) return false;
- if (permissions.can(user, 'staff.moderation.calls')) return true;
- const ch = this.channels.get(channelId);
- if (ch?.createdBy === user.id) return true;
- if (ch?.streamId) return await permissions.canModerateCallSync(user, ch.streamId);
- return false;
- }
-
- async _handleMessage(ws, msg, channelId, peerId) {
- const room = this.rooms.get(channelId);
- if (!room) return;
-
- switch (msg.type) {
- case 'offer': case 'answer': case 'ice-candidate': {
- // Validate SDP/candidate size to prevent abuse
- if (msg.sdp && (typeof msg.sdp.sdp !== 'string' || msg.sdp.sdp.length > 16384)) break;
- if (msg.candidate && (typeof msg.candidate.candidate !== 'string' || msg.candidate.candidate.length > 2048)) break;
- const tp = room.get(msg.targetPeerId);
- if (tp && tp.ws.readyState === WebSocket.OPEN) tp.ws.send(JSON.stringify({ type: msg.type, fromPeerId: peerId, sdp: msg.sdp, candidate: msg.candidate }));
- break;
- }
- case 'mute': {
- const c = room.get(peerId); if (!c) break;
- c.muted = !!msg.muted;
- const m = JSON.stringify({ type: 'peer-muted', peerId, muted: c.muted });
- for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }
- this._notifyChannelsChanged();
- break;
- }
- case 'camera-off': {
- const c = room.get(peerId); if (!c) break;
- c.cameraOff = !!msg.cameraOff;
- const m = JSON.stringify({ type: 'peer-camera', peerId, cameraOff: c.cameraOff });
- for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }
- break;
- }
- case 'speaking': {
- const c = room.get(peerId); if (!c) break;
- const s = !!msg.speaking; if (c.speaking === s) break; c.speaking = s;
- const m = JSON.stringify({ type: 'peer-speaking', peerId, speaking: s });
- for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }
- break;
- }
- case 'auth-update': {
- const c = room.get(peerId); if (!c) break;
- let user = c.user || null;
- if (typeof msg.token === 'string' && msg.token.trim()) {
- const nextUser = await authenticateWs(msg.token);
- if (!nextUser) {
- console.warn('[Call] auth-update rejected for peer', peerId, '(invalid or expired token)');
- } else if (user && user.id !== nextUser.id) {
- console.warn(`[Call] auth-update identity mismatch for peer ${peerId}: keeping existing user ${user.username}, ignoring ${nextUser.username}`);
- } else {
- user = nextUser;
- }
- }
- if (user && this.callBans.has(channelId) && this.callBans.get(channelId).has(user.id)) {
- if (c.ws.readyState === WebSocket.OPEN) { c.ws.send(JSON.stringify({ type: 'error', message: 'Banned' })); c.ws.close(); } break;
- }
- const ch = this.channels.get(channelId);
- c.user = user; c.anonId = user ? null : await chatDelivery.getAnonIdForConnection(c.ip, channelId);
- c.isChannelCreator = !!(user && ch?.createdBy === user.id);
- c.isStreamer = ch?.streamId ? !!(user && (await db.getStreamById(ch.streamId))?.user_id === user.id) : false;
- const pInfo = await this._buildParticipantInfo(peerId, c);
- if (c.ws.readyState === WebSocket.OPEN) c.ws.send(JSON.stringify({
- type: 'self-updated',
- isStreamer: c.isStreamer || c.isChannelCreator,
- canModerate: await this._canModerate(user, channelId),
- participant: pInfo,
- }));
- const u = JSON.stringify({ type: 'peer-updated', ...pInfo });
- for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(u); }
- break;
- }
- case 'force-mute': {
- const sender = room.get(peerId); if (!sender || !(await this._canModerate(sender.user, channelId))) break;
- const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;
- target.forceMuted = !!msg.forceMuted;
- if (target.ws.readyState === WebSocket.OPEN) target.ws.send(JSON.stringify({ type: 'force-muted', forceMuted: target.forceMuted }));
- const m = JSON.stringify({ type: 'peer-force-muted', peerId: msg.targetPeerId, forceMuted: target.forceMuted });
- for (const [pid, info] of room) { if (info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }
- break;
- }
- case 'force-camera-off': {
- const sender = room.get(peerId); if (!sender || !(await this._canModerate(sender.user, channelId))) break;
- const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;
- target.forceCameraOff = !!msg.forceCameraOff;
- if (target.ws.readyState === WebSocket.OPEN) target.ws.send(JSON.stringify({ type: 'force-camera-off', forceCameraOff: target.forceCameraOff }));
- const m = JSON.stringify({ type: 'peer-force-camera-off', peerId: msg.targetPeerId, forceCameraOff: target.forceCameraOff });
- for (const [pid, info] of room) { if (info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }
- break;
- }
- case 'kick': {
- const sender = room.get(peerId); if (!sender || !(await this._canModerate(sender.user, channelId))) break;
- const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;
- if (target.identity) this.kickCooldown.set(`${channelId}:${target.identity}`, Date.now() + KICK_COOLDOWN_MS);
- if (target.ws.readyState === WebSocket.OPEN) { target.ws.send(JSON.stringify({ type: 'kicked' })); target.ws.close(); }
- room.delete(msg.targetPeerId); this.clients.delete(target.ws);
- const m = JSON.stringify({ type: 'peer-left', peerId: msg.targetPeerId, reason: 'kicked' });
- for (const [pid, info] of room) { if (info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }
- this._broadcastParticipantCount(channelId);
- break;
- }
- case 'ban': {
- const sender = room.get(peerId); if (!sender || !(await this._canModerate(sender.user, channelId))) break;
- const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;
- if (!this.callBans.has(channelId)) this.callBans.set(channelId, new Set());
- const banSet = this.callBans.get(channelId);
- if (target.user?.id) {
- banSet.add(target.user.id); // backward compatibility with existing entries
- banSet.add(`u:${target.user.id}`);
- } else {
- if (target.anonId) banSet.add(`a:${target.anonId}`);
- if (target.ip) banSet.add(`ip:${target.ip}`);
- }
- if (target.ws.readyState === WebSocket.OPEN) { target.ws.send(JSON.stringify({ type: 'banned' })); target.ws.close(); }
- room.delete(msg.targetPeerId); this.clients.delete(target.ws);
- const m = JSON.stringify({ type: 'peer-left', peerId: msg.targetPeerId, reason: 'banned' });
- for (const [pid, info] of room) { if (info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }
- this._broadcastParticipantCount(channelId);
- break;
- }
- case 'unban': {
- const sender = room.get(peerId); if (!sender || !(await this._canModerate(sender.user, channelId))) break;
- const uid = parseInt(msg.userId);
- if (uid && this.callBans.has(channelId)) {
- this.callBans.get(channelId).delete(uid);
- this.callBans.get(channelId).delete(`u:${uid}`);
- }
- break;
- }
- case 'end-call': case 'end-channel': {
- const c = room.get(peerId); if (!c) break;
- const ch = this.channels.get(channelId);
- const canEnd = (ch?.createdBy && c.user?.id === ch.createdBy) || c.isStreamer || permissions.can(c.user, 'staff.moderation.calls');
- if (canEnd) { this.endCall(channelId); if (ch && !ch.permanent) this.channels.delete(channelId); }
- break;
- }
- }
- }
-
- async _handleDisconnect(ws, channelId, peerId) {
- if (!this.clients.has(ws)) return; // already handled (error+close fire back-to-back)
- this.clients.delete(ws);
- const room = this.rooms.get(channelId);
- if (!room) return;
- const leaving = room.get(peerId);
- const leftInfo = leaving ? await this._buildParticipantInfo(peerId, leaving) : { peerId };
- room.delete(peerId);
- const m = JSON.stringify({ type: 'peer-left', ...leftInfo, reason: 'disconnect' });
- for (const [pid, info] of room) { if (info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }
- this._broadcastParticipantCount(channelId);
- this._notifyChannelsChanged();
- if (room.size === 0) {
- this.rooms.delete(channelId);
- const ch = this.channels.get(channelId);
- // For user-created channels: don't delete immediately; track when they
- // became empty so the inactivity timer can clean up after 1 hour.
- if (ch && !ch.permanent && !ch.streamId) {
- ch.lastEmptiedAt = Date.now();
- }
- }
- }
-
- _broadcastParticipantCount(channelId) {
- const room = this.rooms.get(channelId);
- const count = room ? room.size : 0;
- const m = JSON.stringify({ type: 'participant-count', count, channelId });
- if (room) { for (const [pid, info] of room) { if (info.ws.readyState === WebSocket.OPEN) info.ws.send(m); } }
- }
-
- /* ── Public helpers ────────────────────────────────────────── */
-
- getParticipantCount(channelId) { const r = this.rooms.get(channelId); return r ? r.size : 0; }
-
- async getParticipants(channelId) {
- const r = this.rooms.get(channelId); if (!r) return [];
- const l = []; for (const [pid, info] of r) l.push(await this._buildParticipantInfo(pid, info)); return l;
- }
-
- getCallBans(channelId) { const b = this.callBans.get(channelId); return b ? [...b] : []; }
-
- endCall(channelId) {
- const r = this.rooms.get(channelId); if (!r) return;
- const entries = [...r.values()];
- this.rooms.delete(channelId);
- const m = JSON.stringify({ type: 'call-ended' });
- for (const info of entries) {
- this.clients.delete(info.ws);
- if (info.ws.readyState === WebSocket.OPEN) { info.ws.send(m); info.ws.close(); }
- }
- }
-
- /** Legacy compat: end call by stream ID */
- endStreamCall(streamId) { this.endCall(`stream-${streamId}`); }
-
- close() {
- for (const [cid] of this.rooms) this.endCall(cid);
- if (this._heartbeatInterval) { clearInterval(this._heartbeatInterval); this._heartbeatInterval = null; }
- if (this._inactivityTimer) { clearInterval(this._inactivityTimer); this._inactivityTimer = null; }
- if (this.wss) this.wss.close();
- }
-}
-
-module.exports = new CallServer();
@@ -1,22 +1,15 @@
@@ -1,22 +1,15 @@
'use strict';
'use strict';
/**
/**
- * Who runs voice/video calls (roadmap WS-I task 1). CALLS_AUTHORITY is read here and nowhere else.
- *
- * live (default, anything but "chat"): Live's own call server (./call-server.js) serves /ws/call and
- * the voice-channel routes, and the stream lifecycle creates and removes stream voice channels in
- * it — exactly as before.
- * chat: OpenVibe.Chat owns calls (nginx sends /ws/call, /api/streams/voice-channels… and
- * /api/streams/:id/call there; Chat runs with CHAT_CALLS=1 — OpenVibe.Chat docs/calls-cutover.md).
- * Go-live with a call mode, stream end, the WHIP teardown and the admin force-end ask Chat instead
- * of the local call server:
+ * Stream voice channels and calls are OpenVibe.Chat's (roadmap WS-I task 1, T3): nginx sends /ws/call,
+ * /api/streams/voice-channels… and /api/streams/:id/call to Chat (Chat runs with CHAT_CALLS=1; OpenVibe.Chat
+ * docs/calls-cutover.md). Live's own call server is gone (2026-10-10). What Live still does is tell Chat about a
+ * stream's lifecycle: go-live with a call mode, stream end, the WHIP teardown and the admin force-end call
* POST /internal/calls/stream-channel { stream_id, mode, user_id }
* POST /internal/calls/stream-channel { stream_id, mode, user_id }
* DELETE /internal/calls/stream-channel/:streamId
* DELETE /internal/calls/stream-channel/:streamId
- * with Live's service token for audience openvibe.chat (grant chat.live_bridge.write, which these
- * calls still use now that Live's chat bridge is gone). Fire-and-forget for the
- * caller: a network error or a 5xx is retried after 1 s, 5 s and 15 s, unless a later call for the
- * same stream superseded it (a stream that ended while its go-live was being retried is not
- * given a channel afterwards); a 4xx is logged and dropped. Live's /ws/call and voice-channel
- * routes stay mounted for one release, unreached once nginx routes those paths to Chat.
+ * with Live's service token for audience openvibe.chat (grant chat.live_bridge.write). Fire-and-forget for the
+ * caller: a network error or a 5xx is retried after 1 s, 5 s and 15 s, unless a later call for the same stream
+ * superseded it (a stream that ended while its go-live was being retried is not given a channel afterwards); a 4xx
+ * is logged and dropped.
*/
*/
const { CHAT_URL } = require('../chat/chat-authority');
const { CHAT_URL } = require('../chat/chat-authority');
@@ -24,13 +17,6 @@ const AUDIENCE = 'openvibe.chat';
@@ -24,13 +17,6 @@ const AUDIENCE = 'openvibe.chat';
const RETRY_MS = [1000, 5000, 15000];
const RETRY_MS = [1000, 5000, 15000];
const TIMEOUT_MS = 5000;
const TIMEOUT_MS = 5000;
-function authority() {
- return String(process.env.CALLS_AUTHORITY || '').trim().toLowerCase() === 'chat' ? 'chat' : 'live';
-}
-function isChat() { return authority() === 'chat'; }
-
-const local = () => require('./call-server');
-
async function send(method, path, body, retried = false) {
async function send(method, path, body, retried = false) {
const principal = require('../net/network-principal');
const principal = require('../net/network-principal');
const res = await fetch(`${CHAT_URL}${path}`, {
const res = await fetch(`${CHAT_URL}${path}`, {
@@ -84,14 +70,12 @@ async function deliver(streamId, method, path, body) {
@@ -84,14 +70,12 @@ async function deliver(streamId, method, path, body) {
/** Go-live with a call mode, or the mode changed: the stream's voice channel. */
/** Go-live with a call mode, or the mode changed: the stream's voice channel. */
async function createStreamChannel(streamId, mode, userId) {
async function createStreamChannel(streamId, mode, userId) {
- if (!isChat()) return local().createStreamChannel(streamId, mode, userId);
return await deliver(Number(streamId), 'POST', '/internal/calls/stream-channel', { stream_id: Number(streamId), mode, user_id: Number(userId) });
return await deliver(Number(streamId), 'POST', '/internal/calls/stream-channel', { stream_id: Number(streamId), mode, user_id: Number(userId) });
}
}
/** The stream ended (or was force-ended): its call ends and its voice channel goes. */
/** The stream ended (or was force-ended): its call ends and its voice channel goes. */
async function removeStreamChannel(streamId) {
async function removeStreamChannel(streamId) {
- if (!isChat()) return local().removeStreamChannel(streamId);
return await deliver(Number(streamId), 'DELETE', `/internal/calls/stream-channel/${Number(streamId)}`);
return await deliver(Number(streamId), 'DELETE', `/internal/calls/stream-channel/${Number(streamId)}`);
}
}
-module.exports = { authority, isChat, createStreamChannel, removeStreamChannel, stats, RETRY_MS, AUDIENCE };
+module.exports = { createStreamChannel, removeStreamChannel, stats, RETRY_MS, AUDIENCE };
@@ -37,7 +37,7 @@ const chatRelayService = require('../integrations/chat-relay-service');
@@ -37,7 +37,7 @@ const chatRelayService = require('../integrations/chat-relay-service');
// The six chat tables are OpenVibe.Chat's (roadmap T3); the few reads here go through its client.
// The six chat tables are OpenVibe.Chat's (roadmap T3); the few reads here go through its client.
const moderation = require('../chat/moderation-client');
const moderation = require('../chat/moderation-client');
const chatReads = require('../chat/chat-reads');
const chatReads = require('../chat/chat-reads');
-// Stream voice channels: Live's call server, or OpenVibe.Chat's with CALLS_AUTHORITY=chat.
+// Stream voice channels are OpenVibe.Chat's: Live tells it when a stream's call starts or ends.
const callsAuthority = require('./calls-authority');
const callsAuthority = require('./calls-authority');
const { sanitizeOfflineHtml, sanitizeOfflineCss } = require('./offline-html-sanitize');
const { sanitizeOfflineHtml, sanitizeOfflineCss } = require('./offline-html-sanitize');
const multer = require('multer');
const multer = require('multer');
@@ -1321,165 +1321,8 @@ router.get('/recent-vods', async (req, res) => {
@@ -1321,165 +1321,8 @@ router.get('/recent-vods', async (req, res) => {
}
}
});
});
-/* ── Voice Channels (global, non-stream) ───────────────────── */
-
-router.get('/voice-channels', optionalAuth, async (req, res) => {
- try {
- res.set('Cache-Control', 'no-store'); // private calls differ per viewer; the list is pushed anyway
- res.json({ channels: await callServer.listChannels(req.user || null) });
- } catch (err) {
- console.error('[Streaming]', err.message);
- res.status(500).json({ error: 'Failed to list voice channels' });
- }
-});
-
-router.get('/voice-channels/:channelId', optionalAuth, async (req, res) => {
- try {
- const ch = await callServer.getChannel(req.params.channelId, req.user || null);
- if (!ch) return res.status(404).json({ error: 'Channel not found' });
- res.json({ channel: ch });
- } catch (err) {
- console.error('[Streaming]', err.message);
- res.status(500).json({ error: 'Failed to get voice channel' });
- }
-});
-
-router.post('/voice-channels', requireAuth, (req, res) => {
- try {
- const { name, mode, maxParticipants } = req.body;
- const ch = callServer.createChannel({ name, mode, createdBy: req.user.id, maxParticipants });
- res.status(201).json({ channel: ch });
- } catch (err) {
- if (err.code === 'CHANNEL_LIMIT') return res.status(400).json({ error: err.message });
- console.error('[Streaming]', err.message);
- res.status(500).json({ error: 'Failed to create voice channel' });
- }
-});
-
-router.delete('/voice-channels/:channelId', requireAuth, async (req, res) => {
- try {
- const ok = await callServer.deleteChannel(req.params.channelId, req.user.id);
- if (!ok) return res.status(403).json({ error: 'Cannot delete this channel' });
- res.json({ deleted: true });
- } catch (err) {
- console.error('[Streaming]', err.message);
- res.status(500).json({ error: 'Failed to delete voice channel' });
- }
-});
-
-const _callUserRate = new Map(); // userId → [timestamps]
-router.post('/voice-channels/call-user', requireAuth, async (req, res) => {
- try {
- // Six calls a minute per account: a ring is a notification on someone else's screen.
- const now = Date.now();
- const recent = (_callUserRate.get(req.user.id) || []).filter((t) => now - t < 60000);
- if (recent.length >= 6) return res.status(429).json({ error: 'Slow down — try again in a minute' });
- recent.push(now); _callUserRate.set(req.user.id, recent);
- const targetUserId = Number(req.body?.user_id || 0);
- const targetUsername = String(req.body?.username || '').trim();
-
- let targetUser = null;
- if (targetUserId > 0) targetUser = await db.getUserById(targetUserId);
- if (!targetUser && targetUsername) targetUser = await db.getUserByUsername(targetUsername);
- if (!targetUser) return res.status(404).json({ error: 'User not found' });
- if (targetUser.id === req.user.id) return res.status(400).json({ error: 'You cannot call yourself' });
- // Blocks live in Chat (dm_blocks is Chat's table), so the check goes through chat-reads. On an
- // unverifiable answer — a stale cache with Chat unreachable — the ring is REFUSED: ringing
- // someone who blocked the caller is the worse failure, and 'try again shortly' is honest.
- const blocked = await chatReads.dmBlocked(req.user.id, targetUser.id).catch(() => null);
- if (blocked !== false) {
- return blocked === true
- ? res.status(403).json({ error: 'You cannot call this user' })
- : res.status(503).json({ error: 'Could not check the block list — try again shortly' });
- }
-
- // Reuse caller's existing temp channel if present; otherwise create a private one — a
- // 1:1 call is not something the whole site should see listed and be able to walk into.
- const existing = (await callServer.listChannels(req.user) || []).find(ch => !ch.permanent && !ch.streamId && ch.createdBy === req.user.id) || null;
- const channel = existing || callServer.createChannel({
- name: `${req.user.display_name || req.user.username}'s call`,
- mode: 'mic+cam',
- createdBy: req.user.id,
- maxParticipants: 8,
- isPrivate: true,
- });
- callServer.invite(channel.id, targetUser.id);
-
- const callerName = req.user.display_name || req.user.username || 'Someone';
- const payload = {
- type: 'vc-call-invite',
- channelId: channel.id,
- channelName: channel.name,
- fromUserId: req.user.id,
- fromUsername: req.user.username,
- fromDisplayName: callerName,
- fromAvatarUrl: req.user.avatar_url || null,
- createdAt: Date.now(),
- };
-
- // Real-time invite for online users via existing chat WS connections.
- await require('../chat/chat-delivery').event({ kind: 'user', id: targetUser.id }, payload);
-
- // Persistent cross-site notification for offline users / later join.
- pushNotification({
- user_id: targetUser.id,
- type: 'VC_CALL_INVITE',
- title: `${callerName} is calling you`,
- message: `Join voice channel: ${channel.name}`,
- url: `${config.baseUrl}/?vcInvite=${encodeURIComponent(channel.id)}`,
- rich_content: {
- context: {
- channel_id: channel.id,
- channel_name: channel.name,
- caller_username: req.user.username,
- },
- },
- ...actorInfo(req.user, callerName),
- });
-
- return res.json({ invited: true, reusedChannel: !!existing, channel });
- } catch (err) {
- if (err.code === 'CHANNEL_LIMIT') return res.status(400).json({ error: err.message });
- console.error('[Streaming]', err.message);
- return res.status(500).json({ error: 'Failed to call user' });
- }
-});
-
-router.post('/voice-channels/call-user/respond', requireAuth, async (req, res) => {
- try {
- const callerUserId = Number(req.body?.caller_user_id || 0);
- const channelId = String(req.body?.channel_id || '').trim();
- const channelName = String(req.body?.channel_name || 'Voice Channel').trim() || 'Voice Channel';
- const status = String(req.body?.status || '').trim().toLowerCase();
-
- const allowed = new Set(['accepted', 'declined', 'busy', 'no-answer', 'canceled']);
- if (!callerUserId) return res.status(400).json({ error: 'caller_user_id is required' });
- if (!channelId) return res.status(400).json({ error: 'channel_id is required' });
- if (!allowed.has(status)) return res.status(400).json({ error: 'Invalid response status' });
- if (callerUserId === req.user.id) return res.status(400).json({ error: 'Invalid caller target' });
- // Only an invited user can answer, and only to the caller who owns that channel.
- const ch = await callServer.getChannel(channelId, req.user);
- if (!ch || ch.createdBy !== callerUserId || !callServer.hasInvite(channelId, req.user.id)) return res.status(403).json({ error: 'No such invite' });
-
- const fromDisplayName = req.user.display_name || req.user.username || 'Someone';
- await require('../chat/chat-delivery').event({ kind: 'user', id: callerUserId }, {
- type: 'vc-call-response',
- status,
- channelId,
- channelName,
- fromUserId: req.user.id,
- fromUsername: req.user.username,
- fromDisplayName,
- fromAvatarUrl: req.user.avatar_url || null,
- createdAt: Date.now(),
- });
-
- return res.json({ ok: true });
- } catch (err) {
- console.error('[Streaming]', err.message);
- return res.status(500).json({ error: 'Failed to send call response' });
- }
-});
+/* Voice channels and stream group calls are OpenVibe.Chat's: nginx sends /api/streams/voice-channels…
+ and /api/streams/:id/call there. Live only tells Chat when a stream's call starts or ends (./calls-authority.js). */
// ── Broadcast Settings ──────────────────────────────────────
// ── Broadcast Settings ──────────────────────────────────────
router.get('/broadcast-settings', requireAuth, async (req, res) => {
router.get('/broadcast-settings', requireAuth, async (req, res) => {
@@ -2465,66 +2308,6 @@ router.post('/channel/:username/follow', requireAuth, async (req, res) => {
@@ -2465,66 +2308,6 @@ router.post('/channel/:username/follow', requireAuth, async (req, res) => {
}
}
});
});
-// ── Group Call: Enable / Disable / Get Status ────────────────
-const callServer = require('./call-server');
-
-router.put('/:id/call', requireAuth, async (req, res) => {
- try {
- const stream = await db.getStreamById(req.params.id);
- if (!stream) return res.status(404).json({ error: 'Stream not found' });
- if (stream.user_id !== req.user.id) {
- return res.status(403).json({ error: 'Not your stream' });
- }
- if (!stream.is_live) {
- return res.status(400).json({ error: 'Stream is not live' });
- }
-
- const { call_mode } = req.body;
- const validModes = ['mic', 'mic+cam', 'cam+mic', null];
- if (!validModes.includes(call_mode)) {
- return res.status(400).json({ error: 'Invalid call mode. Use: mic, mic+cam, cam+mic, or null to disable' });
- }
-
- await db.run('UPDATE streams SET call_mode = ? WHERE id = ?', [call_mode, stream.id]);
-
- // Create or remove stream voice channel
- const channelId = `stream-${stream.id}`;
- if (call_mode) {
- await callServer.createStreamChannel(stream.id, call_mode, stream.user_id);
- } else {
- callServer.removeStreamChannel(stream.id);
- }
-
- res.json({
- call_mode,
- channelId: call_mode ? channelId : null,
- participants: await callServer.getParticipants(channelId),
- participant_count: callServer.getParticipantCount(channelId),
- });
- } catch (err) {
- console.error('[Streams] Call mode error:', err.message);
- res.status(500).json({ error: 'Failed to update call mode' });
- }
-});
-
-router.get('/:id/call', optionalAuth, async (req, res) => {
- try {
- const stream = await db.getStreamById(req.params.id);
- if (!stream) return res.status(404).json({ error: 'Stream not found' });
- const channelId = `stream-${stream.id}`;
-
- res.json({
- call_mode: stream.call_mode || null,
- channelId: stream.call_mode ? channelId : null,
- participants: await callServer.getParticipants(channelId),
- participant_count: callServer.getParticipantCount(channelId),
- });
- } catch (err) {
- console.error('[Streaming]', err.message);
- res.status(500).json({ error: 'Failed to get call status' });
- }
-});
-
// ── RTMP FLV Proxy ───────────────────────────────────────────
// ── RTMP FLV Proxy ───────────────────────────────────────────
// Proxies HTTP-FLV from the internal NMS server so the browser fetches
// Proxies HTTP-FLV from the internal NMS server so the browser fetches
// from the same HTTPS origin, avoiding CSP / mixed-content issues.
// from the same HTTPS origin, avoiding CSP / mixed-content issues.
@@ -1,12 +1,10 @@
@@ -1,12 +1,10 @@
'use strict';
'use strict';
-// Who runs calls (WS-I task 1, server/streaming/calls-authority.js). CALLS_AUTHORITY unset (or
-// anything but "chat"): stream voice channels are made and removed by Live's own call server and
-// nothing is sent anywhere — as before. "chat": the same hooks become POST/DELETE
-// /internal/calls/stream-channel on OpenVibe.Chat with Live's service token for audience
-// openvibe.chat, retried after a network error or a 5xx, never after a 4xx, and a retry that a later
-// call for the same stream superseded is dropped. The go-live, stream-end, WHIP and admin force-end
-// hooks all go through it. Against a stub Network + Chat.
+// Calls are OpenVibe.Chat's (WS-I task 1, T3; server/streaming/calls-authority.js). Live's own call server is gone:
+// a stream's call hooks are POST/DELETE /internal/calls/stream-channel on OpenVibe.Chat with Live's service token for
+// audience openvibe.chat, retried after a network error or a 5xx, never after a 4xx, and a retry that a later call for
+// the same stream superseded is dropped. The go-live, stream-end, WHIP and admin force-end hooks all go through it, and
+// Live serves no /ws/call or voice-channel route of its own. Against a stub Network + Chat.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -15,7 +13,6 @@ const http = require('http');
@@ -15,7 +13,6 @@ const http = require('http');
process.env.OV_OAUTH_CLIENT_ID = 'live';
process.env.OV_OAUTH_CLIENT_ID = 'live';
process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
-delete process.env.CALLS_AUTHORITY;
const quiet = console.log;
const quiet = console.log;
console.log = () => {};
console.log = () => {};
console.warn = () => {};
console.warn = () => {};
@@ -54,39 +51,19 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -54,39 +51,19 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const db = require('../server/db/database');
const db = require('../server/db/database');
db.getStreamById = (id) => ({ id: Number(id), user_id: 7, title: `Stream ${id} title`, is_live: 1 });
db.getStreamById = (id) => ({ id: Number(id), user_id: 7, title: `Stream ${id} title`, is_live: 1 });
const calls = require('../server/streaming/calls-authority');
const calls = require('../server/streaming/calls-authority');
- const callServer = require('../server/streaming/call-server');
calls.RETRY_MS.splice(0, calls.RETRY_MS.length, 30, 30, 30);
calls.RETRY_MS.splice(0, calls.RETRY_MS.length, 30, 30, 30);
let exit = 0;
let exit = 0;
try {
try {
- // 1. The flag.
- for (const v of [undefined, '', 'live', 'LIVE', 'other']) {
- if (v === undefined) delete process.env.CALLS_AUTHORITY; else process.env.CALLS_AUTHORITY = v;
- assert.strictEqual(calls.authority(), 'live', String(v));
- }
- for (const v of ['chat', 'CHAT', ' chat ']) { process.env.CALLS_AUTHORITY = v; assert.strictEqual(calls.authority(), 'chat', v); }
- delete process.env.CALLS_AUTHORITY;
-
- // 2. Default: Live's call server, nothing sent.
- const ch = await calls.createStreamChannel(41, 'mic', 7);
- assert.deepStrictEqual([ch.id, ch.mode, ch.name, ch.createdBy], ['stream-41', 'mic', 'Stream 41 title', 7]);
- assert.ok(callServer.channels.has('stream-41'));
- await calls.removeStreamChannel(41);
- assert.ok(!callServer.channels.has('stream-41'));
- await sleep(50);
- assert.deepStrictEqual(seen, [], 'CALLS_AUTHORITY unset sends nothing to Chat');
-
- // 3. chat: the hooks go to Chat's internal endpoints with Live's service token; nothing local.
- process.env.CALLS_AUTHORITY = 'chat';
+ // 1. The hooks go to Chat's internal endpoints with Live's service token.
const created = await calls.createStreamChannel(42, 'mic+cam', 7);
const created = await calls.createStreamChannel(42, 'mic+cam', 7);
assert.deepStrictEqual(created, { ok: true });
assert.deepStrictEqual(created, { ok: true });
assert.deepStrictEqual(seen[0], { method: 'POST', url: '/internal/calls/stream-channel', auth: 'Bearer svc-live-for-openvibe.chat', body: { stream_id: 42, mode: 'mic+cam', user_id: 7 } });
assert.deepStrictEqual(seen[0], { method: 'POST', url: '/internal/calls/stream-channel', auth: 'Bearer svc-live-for-openvibe.chat', body: { stream_id: 42, mode: 'mic+cam', user_id: 7 } });
assert.ok(tokenAudiences.includes('openvibe.chat'));
assert.ok(tokenAudiences.includes('openvibe.chat'));
- assert.ok(!callServer.channels.has('stream-42'), 'Live\'s call server is not used');
await calls.removeStreamChannel(42);
await calls.removeStreamChannel(42);
assert.deepStrictEqual([seen[1].method, seen[1].url, seen[1].body], ['DELETE', '/internal/calls/stream-channel/42', null]);
assert.deepStrictEqual([seen[1].method, seen[1].url, seen[1].body], ['DELETE', '/internal/calls/stream-channel/42', null]);
- // 4. A 5xx (or Chat down) is retried; a 4xx is not.
+ // 2. A 5xx (or Chat down) is retried; a 4xx is not.
seen.length = 0;
seen.length = 0;
let n = 0;
let n = 0;
answer = () => (++n === 1 ? [503, { error: 'restarting' }] : [200, { ok: true }]);
answer = () => (++n === 1 ? [503, { error: 'restarting' }] : [200, { ok: true }]);
@@ -98,7 +75,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -98,7 +75,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
assert.strictEqual(seen.length, 1, 'a refusal is not retried');
assert.strictEqual(seen.length, 1, 'a refusal is not retried');
assert.ok(/409/.test(calls.stats.lastError));
assert.ok(/409/.test(calls.stats.lastError));
- // 5. A retry that a later call for the same stream superseded is dropped: a stream that ended
+ // 3. A retry that a later call for the same stream superseded is dropped: a stream that ended
// while its go-live was being retried does not get its channel back.
// while its go-live was being retried does not get its channel back.
seen.length = 0;
seen.length = 0;
answer = (r) => (r.method === 'POST' ? [502, { error: 'bad gateway' }] : [200, { ok: true, removed: false }]);
answer = (r) => (r.method === 'POST' ? [502, { error: 'bad gateway' }] : [200, { ok: true, removed: false }]);
@@ -110,7 +87,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -110,7 +87,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
assert.deepStrictEqual(seen.map((r) => r.method), ['POST', 'DELETE'], 'the go-live was not retried after the stream ended');
assert.deepStrictEqual(seen.map((r) => r.method), ['POST', 'DELETE'], 'the go-live was not retried after the stream ended');
assert.ok(calls.stats.superseded >= 1);
assert.ok(calls.stats.superseded >= 1);
- // 6. Every stream hook goes through the authority, not straight to Live's call server.
+ // 4. Every stream hook goes through the authority, not straight to Live's call server.
const src = (f) => fs.readFileSync(path.join(__dirname, '..', 'server', f), 'utf8');
const src = (f) => fs.readFileSync(path.join(__dirname, '..', 'server', f), 'utf8');
const routes = src('streaming/routes.js');
const routes = src('streaming/routes.js');
assert.ok(routes.includes('callsAuthority.createStreamChannel(streamId, callMode, req.user.id)'), 'go-live');
assert.ok(routes.includes('callsAuthority.createStreamChannel(streamId, callMode, req.user.id)'), 'go-live');
@@ -119,16 +96,18 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -119,16 +96,18 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
assert.ok(/calls-authority'\)\.removeStreamChannel\(/.test(src(f)), f);
assert.ok(/calls-authority'\)\.removeStreamChannel\(/.test(src(f)), f);
assert.ok(!/call-server'\)\.removeStreamChannel\(/.test(src(f)), `${f} no longer calls Live's call server directly`);
assert.ok(!/call-server'\)\.removeStreamChannel\(/.test(src(f)), `${f} no longer calls Live's call server directly`);
}
}
- assert.strictEqual((src('streaming/calls-authority.js').match(/process\.env\.CALLS_AUTHORITY/g) || []).length, 1);
- for (const f of ['index.js', 'streaming/routes.js', 'streaming/whip-handler.js', 'admin/routes.js']) {
- assert.ok(!src(f).includes('process.env.CALLS_AUTHORITY'), `${f} does not read the flag itself`);
+ // 5. Live's own call server is gone: no module, no /ws/call upgrade, no voice-channel or group-call routes, no flag.
+ assert.ok(!fs.existsSync(path.join(__dirname, '..', 'server', 'streaming', 'call-server.js')), 'call-server.js is deleted');
+ assert.ok(!src('index.js').includes("'/ws/call'"), 'index.js upgrades no /ws/call');
+ assert.ok(!/router\.(get|post|put|delete)\('\/(voice-channels|:id\/call)/.test(routes), 'no voice-channel or group-call routes in Live');
+ for (const f of ['index.js', 'streaming/routes.js', 'streaming/calls-authority.js', 'streaming/whip-handler.js', 'admin/routes.js']) {
+ assert.ok(!src(f).includes('CALLS_AUTHORITY'), `${f} reads no CALLS_AUTHORITY`);
}
}
} catch (err) {
} catch (err) {
exit = 1;
exit = 1;
console.error(err);
console.error(err);
}
}
stub.close();
stub.close();
- try { callServer.close(); } catch { /* */ }
console.log = quiet;
console.log = quiet;
console.log(exit ? 'calls authority: FAILED' : 'calls authority: all checks passed');
console.log(exit ? 'calls authority: FAILED' : 'calls authority: all checks passed');
process.exit(exit);
process.exit(exit);
@@ -1027,35 +1027,6 @@ const waitFor = async (pred, what, ms = 3000) => {
@@ -1027,35 +1027,6 @@ const waitFor = async (pred, what, ms = 3000) => {
assert.deepStrictEqual(deleted, ['/assets/555111'], 'the Media asset id in the request is the one deleted');
assert.deepStrictEqual(deleted, ['/assets/555111'], 'the Media asset id in the request is the one deleted');
}
}
- // (4) The call invite asks Chat for the block state: blocked refuses, an unknown state
- // (Chat down past the cache) refuses too, never rings through.
- {
- const callerToken = jwt.sign({ sub: String(streamer), username: 'streamer', role: 'streamer' }, keys.privateKey, { algorithm: 'RS256', issuer: ISS, expiresIn: 300 });
- const callUser = (body) => call('POST', '/api/streams/voice-channels/call-user', { token: callerToken, body });
- chatReads._reset();
- readState.dmBlocked = true;
- try {
- const blocked = await callUser({ user_id: viewer });
- assert.strictEqual(blocked.status, 403, 'a blocked pair cannot ring');
- assert.match(blocked.body.error, /cannot call this user/i);
-
- readState.dmBlocked = false;
- chatReads._reset();
- const allowed = await callUser({ user_id: viewer });
- assert.strictEqual(allowed.status, 200, `an unblocked pair rings (${allowed.text})`);
- assert.strictEqual(allowed.body.invited, true);
-
- readState.down = true;
- chatReads._reset();
- const unknown = await callUser({ user_id: viewer });
- assert.strictEqual(unknown.status, 503, 'an unverifiable block state refuses the ring');
- assert.match(unknown.body.error, /try again shortly/i);
- } finally {
- readState.dmBlocked = false;
- readState.down = false;
- chatReads._reset();
- }
- }
}
}
// 12. Robot commands from chat pass the control panel's gate: control mode, anonymous switch, whitelist, cooldown.
// 12. Robot commands from chat pass the control panel's gate: control mode, anonymous switch, whitelist, cooldown.
@@ -23,8 +23,10 @@ const ISSUER = 'https://openvibe.network';
@@ -23,8 +23,10 @@ const ISSUER = 'https://openvibe.network';
// Live's chat widget and messenger are Chat's N-1 clients, checked there. /api/emotes is Chat's too
// Live's chat widget and messenger are Chat's N-1 clients, checked there. /api/emotes is Chat's too
// (plan T3; nginx sends it there), and the channel-moderation router Live used to answer at
// (plan T3; nginx sends it there), and the channel-moderation router Live used to answer at
// /api/channels/* moved to Chat's /api/chat/channels/* — so an N-1 tab's calls to those are
// /api/channels/* moved to Chat's /api/chat/channels/* — so an N-1 tab's calls to those are
-// Chat's to answer, and this harness neither records nor replays them against Live.
-const CHAT_PATHS = /^\/(api\/(chat|dm|tts|emotes|channels)(\/|$)|api\/sounds(\/|$)|ws\/)/;
+// Chat's to answer, and this harness neither records nor replays them against Live. Calls are Chat's too since Live's
+// call server was deleted (T3): nginx sends /api/streams/voice-channels… and /api/streams/:id/call there (Chat's
+// deploy/nginx/calls/openvibe.live-calls.locations.conf).
+const CHAT_PATHS = /^\/(api\/(chat|dm|tts|emotes|channels)(\/|$)|api\/sounds(\/|$)|api\/streams\/voice-channels(\/|$)|api\/streams\/[^/]+\/call$|ws\/)/;
function freePort() {
function freePort() {
return new Promise((resolve) => { const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); });
return new Promise((resolve) => { const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); });