openvibers/openvibe.live Commits

Live: calls are Chat's only; Live's call server, voice-channel and group-call routes are deleted (T3) (#71)

OP OpenVibers committed 10 Oct 2026 · 324f1e5802 · 12 files, +45 −889

* Live: calls are Chat's only; Live's call server, voice-channel and group-call routes are deleted (T3)

Plan T3 ("Calls: … then delete Live's /ws/call, call server and voice routes"). Calls have been OpenVibe.Chat's in production since the cutover: `CALLS_AUTHORITY=chat`, and the nginx include sends `/ws/call`, `/api/streams/voice-channels…` and `/api/streams/:id/call` to Chat. Chat's call server logs "Voice channels initialized (serving)" on every boot. Live's copies were unreachable through nginx, and its boot log said so ("Live's /ws/call stays up, unreached").

**Deleted:**
- `server/streaming/call-server.js` (548 lines), the `/ws/call` upgrade branch, its init/close, the metrics gauge, diagnostics and admin entries;
- Live's six voice-channel routes and its `GET`/`PUT /api/streams/:id/call`;
- the `CALLS_AUTHORITY` switch. `calls-authority.js` now only tells Chat about a stream's call (POST/DELETE `/internal/calls/stream-channel`, with the same retry and supersede rules).

The front end is unchanged: its requests already go to Chat.

**Tests:** `calls-authority.test.js` is rewritten for the Chat-only path and asserts the module, upgrade, routes and flag are gone. `chat-context.test.js` drops the check of Live's old call-user route, which Chat runs now with its own block check. `ov test`: 45 passed. **Docs:** architecture.md and .env.example. After the deploy, `CALLS_AUTHORITY` comes out of live.env.

* test(n-1): calls are Chat's to answer (nginx routes /api/streams/voice-channels… and /api/streams/:id/call there)

Changes

ddiff --git a/.env.example b/.env.example
iindex 826a07f5..57c1a910 100644
---- a/.env.example
++++ b/.env.example
@@@ -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,
ddiff --git a/docs/architecture.md b/docs/architecture.md
iindex 9bc0c736..3c1c9d53 100644
---- a/docs/architecture.md
++++ b/docs/architecture.md
@@@ -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`
(`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, eager
JavaScript or blocking CSS grow past recorded limits, or if route code returns to the home page.
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 66cd451c..7c54d76a 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -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; } })(),
}));
});
ddiff --git a/server/chat/chat-delivery.js b/server/chat/chat-delivery.js
iindex 6254c50f..e65b54cd 100644
---- a/server/chat/chat-delivery.js
++++ b/server/chat/chat-delivery.js
@@@ -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 → number
const pendingResolves = new Map(); // IP → pending resolve promise (dedup concurrent)
ddiff --git a/server/diagnostics.js b/server/diagnostics.js
iindex ec9c8556..0d9a4048 100644
---- a/server/diagnostics.js
++++ b/server/diagnostics.js
@@@ -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: {
ddiff --git a/server/index.js b/server/index.js
iindex e5998492..905461ea 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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 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()) {
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}`);
ddiff --git a/server/streaming/call-server.js b/server/streaming/call-server.js
ddeleted file mode 100644
iindex 9e628913..00000000
---- a/server/streaming/call-server.js
++++ /dev/null
@@@ -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();
ddiff --git a/server/streaming/calls-authority.js b/server/streaming/calls-authority.js
iindex 9c36b52a..4443c7ca 100644
---- a/server/streaming/calls-authority.js
++++ b/server/streaming/calls-authority.js
@@@ -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 };
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex e39fe9c0..f88d7d0c 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -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.
ddiff --git a/test/calls-authority.test.js b/test/calls-authority.test.js
iindex 3bf6b7b1..d7907a02 100644
---- a/test/calls-authority.test.js
++++ b/test/calls-authority.test.js
@@@ -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);
ddiff --git a/test/chat-context.test.js b/test/chat-context.test.js
iindex b13ec148..0a7f29eb 100644
---- a/test/chat-context.test.js
++++ b/test/chat-context.test.js
@@@ -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.
ddiff --git a/test/n-1/service.js b/test/n-1/service.js
iindex 30132be7..ce2a9394 100644
---- a/test/n-1/service.js
++++ b/test/n-1/service.js
@@@ -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)); }); });