openvibers/openvibe.live Commits

Review pass: private calls survive list pushes, featured box boots once, short-lived TURN credentials for the SFU too

OP OpenVibers committed 17 Sept 2026 · 17e27d0d8c · 10 files, +106 −50

- vcApplyChannels keeps a private call the viewer can see when the public
  list is pushed over it.
- homeFeatured.boot() is a no-op while running (the live poll called it
  every 20 s).
- server/net/turn.js: one place for TURN credentials (HMAC with
  TURN_AUTH_SECRET, else the static pair, never an unauthenticated turn:
  entry) used by /api/auth/ice-servers and the SFU signalling; one STUN
  server; test/turn-credentials.test.js. Docs and .env.example updated.

Changes

ddiff --git a/.env.example b/.env.example
iindex f70c24e3..41010f3d 100644
---- a/.env.example
++++ b/.env.example
@@@ -74,6 +74,9 @@ WEBRTC_PORT=4443
TURN_URL=turn:turn.openvibe.live
TURN_USERNAME=
TURN_CREDENTIAL=
++# Preferred: coturn `use-auth-secret` + `static-auth-secret=<this>` — every ICE list then carries a
++# credential that expires after an hour instead of the static pair above.
++TURN_AUTH_SECRET=
ALLOW_P2P_FALLBACK=false
# PayPal (for Vibes cashout — Vibes stay Live-local)
ddiff --git a/AGENTS.md b/AGENTS.md
iindex b1c7a1a3..54ebfff7 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -44,6 +44,7 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s
- **Auth middleware:** `requireAuth` from `auth.js`. Permission checks via `permissions.js`.
- **DB migrations:** Idempotent `CREATE … IF NOT EXISTS`/`ADD COLUMN` may stay inline; anything that transforms data goes in [server/db/migrations.js](server/db/migrations.js) (ledger, transaction, `adopt`, `DEFER`).
- **Public responses:** serialize through [server/web/serializers.js](server/web/serializers.js) — never return raw `managed_streams`/`users` rows.
++- **TURN:** ICE lists come from [server/net/turn.js](server/net/turn.js); set `TURN_AUTH_SECRET` (coturn `use-auth-secret`) for short-lived credentials.
- **Outbound fetches of user-chosen URLs:** [server/net/egress.js](server/net/egress.js) only. Background loops: `server/utils/jobs.js`.
- **WebSocket servers:** Each has `init(server)` and `handleUpgrade(req, socket, head)` methods.
- **Frontend globals:** `currentUser`, `api()`, `navigate()`, `handleLinkClick()`. Cross-component sync via `CustomEvent` (e.g., `openvibe-auth-changed`).
ddiff --git a/docs/architecture.md b/docs/architecture.md
iindex 7a29ec7e..15455f7d 100644
---- a/docs/architecture.md
++++ b/docs/architecture.md
@@@ -57,6 +57,13 @@ public/js/app-*.js route code split out of app.js: home, channel, media, c
- **Assets.** Every asset URL is content-hashed at serve time (see [deploy.md](deploy.md#assets-and-caching)).
- **Cached-first rendering.** `apiSWR()` (public/js/app-home.js) paints the home page's public data
from `localStorage` (versioned records, keyed by user id) and reconciles with the fresh response.
++- **Featured live stream.** `public/js/home-featured.js` (feature `featured`) loads only when someone is
++ live: `GET /api/home/featured` picks and rotates the stream; playback is FLV (RTMP) or the JSMPEG relay,
++ live frames for WebRTC. Off switch remembered in `localStorage` (`ov_home_featured_off`).
++- **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`).
- **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/public/js/home-featured.js b/public/js/home-featured.js
iindex bca9bbd3..8cbfba15 100644
---- a/public/js/home-featured.js
++++ b/public/js/home-featured.js
@@@ -235,6 +235,7 @@
if (t) { t.hidden = !(isOff() && st.hasLive); t.onclick = () => setOff(false); }
st.el = document.getElementById('home-featured');
if (!st.el || !st.hasLive || isOff()) { stop(); return; }
++ if (st.poll) return; // already running; the live-list poll calls this every 20 s
watchVisibility();
load(false);
clearInterval(st.poll);
ddiff --git a/public/js/voice-channels.js b/public/js/voice-channels.js
iindex 05d9cddc..07528d9c 100644
---- a/public/js/voice-channels.js
++++ b/public/js/voice-channels.js
@@@ -43,7 +43,11 @@ async function vcFetchChannels() {
}
/** The list from a fetch or a server push. */
function vcApplyChannels(channels) {
-- vcState.channels = Array.isArray(channels) ? channels : [];
++ const next = Array.isArray(channels) ? channels : [];
++ // The server's push is the public list; a private call this viewer can see (they were invited
++ // or created it) is only in their own fetch, so it is carried over rather than dropped.
++ const keep = vcState.channels.filter((c) => c.private && !next.some((n) => n.id === c.id));
++ vcState.channels = keep.length ? [...next, ...keep] : next;
vcRenderChannelList();
vcUpdateMiniBar();
}
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex e25e859f..439ec1f9 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -19,9 +19,10 @@ const multer = require('multer');
const path = require('path');
const fs = require('fs');
const db = require('../db/database');
--const { requireAuth } = require('./auth');
++const { requireAuth, optionalAuth } = require('./auth');
const permissions = require('./permissions');
const config = require('../config');
++const { turnEntries } = require('../net/turn');
const router = express.Router();
@@@ -802,27 +803,14 @@ router.delete('/tokens/:id', requireAuth, (req, res) => {
// ── ICE / TURN server config (public — needed by unauthenticated voice chat) ─
// One STUN server: with TURN present, extra STUN entries only slow candidate gathering (Chrome
--// warns at five or more servers). With TURN_AUTH_SECRET set (coturn `use-auth-secret`), every
--// response carries a credential that expires in an hour instead of the same static one forever.
--router.get('/ice-servers', (req, res) => {
++// warns at five or more servers). Credentials come from server/net/turn.js: short-lived when
++// TURN_AUTH_SECRET is configured, the static pair otherwise, nothing for an unauthenticated turn: URL.
++router.get('/ice-servers', optionalAuth, (req, res) => {
const servers = [{ urls: 'stun:stun.l.google.com:19302' }];
if (config.turn?.url) {
-- const secret = String(process.env.TURN_AUTH_SECRET || '').trim();
-- let username = config.turn.username, credential = config.turn.credential;
-- if (secret) {
-- const expires = Math.floor(Date.now() / 1000) + 3600;
-- username = `${expires}:${req.user ? req.user.id : 'anon'}`;
-- credential = require('crypto').createHmac('sha1', secret).update(username).digest('base64');
-- res.set('Cache-Control', 'private, max-age=300');
-- }
-- const hasTurnAuth = username && credential;
-- // A turn: URL without credentials makes RTCPeerConnection throw in the browser, so a box with
-- // no TURN secret configured gets STUN only.
-- if (hasTurnAuth) {
-- servers.push({ urls: config.turn.url, username, credential }, { urls: `${config.turn.url}?transport=tcp`, username, credential });
-- } else if (username || credential) {
-- console.warn('[ICE] Incomplete TURN credentials configured; TURN left out.');
-- }
++ const entries = turnEntries(config.turn.url, req.user ? `u${req.user.id}` : 'anon');
++ if (entries.length) { servers.push(...entries); if (process.env.TURN_AUTH_SECRET) res.set('Cache-Control', 'private, max-age=300'); }
++ else if (config.turn.username || config.turn.credential) console.warn('[ICE] Incomplete TURN credentials configured; TURN left out.');
}
res.json({ iceServers: servers });
});
ddiff --git a/server/net/turn.js b/server/net/turn.js
nnew file mode 100644
iindex 00000000..39141420
---- /dev/null
++++ b/server/net/turn.js
@@@ -0,0 +1,37 @@
++'use strict';
++/**
++ * TURN credentials for ICE server lists (voice channels, SFU viewers and broadcasters).
++ *
++ * With TURN_AUTH_SECRET set — coturn running `use-auth-secret` with the same `static-auth-secret` —
++ * every list carries a credential that expires (TURN REST API: username `<expiry>:<tag>`, credential
++ * HMAC-SHA1 of the username, base64). Without it the static TURN_USERNAME/TURN_CREDENTIAL pair is
++ * used, and nothing is emitted for a turn: URL that has no credentials at all (the browser refuses
++ * such an entry outright).
++ */
++const crypto = require('crypto');
++const config = require('../config');
++
++const TTL_SECONDS = 3600;
++
++function turnCredentials(tag = 'anon') {
++ const secret = String(process.env.TURN_AUTH_SECRET || '').trim();
++ if (secret) {
++ const username = `${Math.floor(Date.now() / 1000) + TTL_SECONDS}:${String(tag).replace(/[^A-Za-z0-9_-]/g, '')}`;
++ const credential = crypto.createHmac('sha1', secret).update(username).digest('base64');
++ return { username, credential, ephemeral: true };
++ }
++ const { username, credential } = config.turn || {};
++ if (username && credential) return { username, credential, ephemeral: false };
++ return null;
++}
++
++/** [{ urls, username, credential }] for a TURN URL (udp + tcp), or [] when it cannot be authenticated. */
++function turnEntries(url, tag) {
++ if (!url) return [];
++ const creds = turnCredentials(tag);
++ if (!creds) return [];
++ const tcp = url.includes('?') ? `${url}&transport=tcp` : `${url}?transport=tcp`;
++ return [{ urls: url, username: creds.username, credential: creds.credential }, { urls: tcp, username: creds.username, credential: creds.credential }];
++}
++
++module.exports = { turnCredentials, turnEntries, TTL_SECONDS };
ddiff --git a/server/streaming/broadcast-server.js b/server/streaming/broadcast-server.js
iindex e5ef3050..9d9861c7 100644
---- a/server/streaming/broadcast-server.js
++++ b/server/streaming/broadcast-server.js
@@@ -20,6 +20,7 @@ const webrtcSFU = require('./webrtc-sfu');
// Per-address cap on viewer sockets (a household or office behind one NAT watching several tabs fits).
const MAX_VIEWER_SOCKETS_PER_IP = 16;
const config = require('../config');
++const { turnEntries } = require('../net/turn');
const whipHandler = require('./whip-handler');
const WS_HEARTBEAT_MS = 30000;
@@@ -58,12 +59,6 @@ class BroadcastServer extends EventEmitter {
return normalized;
}
-- _appendTransportParam(url, transport) {
-- if (!url.includes('?')) return `${url}?transport=${transport}`;
-- if (/[?&]transport=/i.test(url)) return url;
-- return `${url}&transport=${transport}`;
-- }
--
_sanitizeIceServers(servers) {
if (!Array.isArray(servers)) return [];
const result = [];
@@@ -84,26 +79,14 @@ class BroadcastServer extends EventEmitter {
}
/** Build ICE servers array from config (STUN + optional TURN) */
-- _getIceServers() {
-- const servers = [
-- { urls: 'stun:stun.l.google.com:19302' },
-- { urls: 'stun:stun1.l.google.com:19302' },
-- ];
++ _getIceServers(tag = 'sfu') {
++ const servers = [{ urls: 'stun:stun.l.google.com:19302' }];
if (config.turn?.url) {
const turnUrl = this._normalizeTurnUrl(config.turn.url);
if (turnUrl) {
-- const hasTurnAuth = config.turn.username && config.turn.credential;
-- servers.push(
-- hasTurnAuth
-- ? { urls: turnUrl, username: config.turn.username, credential: config.turn.credential }
-- : { urls: turnUrl },
-- hasTurnAuth
-- ? { urls: this._appendTransportParam(turnUrl, 'tcp'), username: config.turn.username, credential: config.turn.credential }
-- : { urls: this._appendTransportParam(turnUrl, 'tcp') },
-- );
-- if (!hasTurnAuth && (config.turn.username || config.turn.credential)) {
-- console.warn('[ICE] Incomplete TURN credentials configured; emitting TURN URLs without auth.');
-- }
++ const entries = turnEntries(turnUrl, tag);
++ if (entries.length) servers.push(...entries);
++ else if (config.turn.username || config.turn.credential) console.warn('[ICE] Incomplete TURN credentials configured; TURN left out.');
} else {
console.warn('[ICE] Skipping invalid TURN_URL; only turn: or turns: URLs are accepted:', config.turn.url);
}
ddiff --git a/test/ice-server-sanitization.test.js b/test/ice-server-sanitization.test.js
iindex ff17039d..e6f5cab0 100644
---- a/test/ice-server-sanitization.test.js
++++ b/test/ice-server-sanitization.test.js
@@@ -13,7 +13,7 @@ try {
config.turn.username = 'user';
config.turn.credential = 'pass';
const iceServers = broadcastServer._getIceServers();
-- assert.strictEqual(iceServers.length, 2, 'Invalid TURN URL should be skipped and only STUN servers kept');
++ assert.strictEqual(iceServers.length, 1, 'Invalid TURN URL should be skipped and only the STUN server kept');
assert.ok(iceServers.every(s => {
const urls = Array.isArray(s.urls) ? s.urls : [s.urls];
return urls.every((u) => /^stun:/i.test(u));
@@@ -23,11 +23,11 @@ try {
config.turn.username = 'user';
config.turn.credential = 'pass';
const iceServers2 = broadcastServer._getIceServers();
-- assert.strictEqual(iceServers2.length, 4, 'Valid TURN URL should produce 2 TURN entries plus 2 STUN entries');
-- assert.strictEqual(iceServers2[2].urls, 'turn:turn.example.com:3478');
-- assert.strictEqual(iceServers2[2].username, 'user');
-- assert.strictEqual(iceServers2[2].credential, 'pass');
-- assert.strictEqual(iceServers2[3].urls, 'turn:turn.example.com:3478?transport=tcp');
++ assert.strictEqual(iceServers2.length, 3, 'Valid TURN URL should produce 2 TURN entries plus the STUN entry');
++ assert.strictEqual(iceServers2[1].urls, 'turn:turn.example.com:3478');
++ assert.strictEqual(iceServers2[1].username, 'user');
++ assert.strictEqual(iceServers2[1].credential, 'pass');
++ assert.strictEqual(iceServers2[2].urls, 'turn:turn.example.com:3478?transport=tcp');
const normalized = config._normalizeTurnUrl('turn://turn.example.com:3478');
assert.strictEqual(normalized, 'turn:turn.example.com:3478');
ddiff --git a/test/turn-credentials.test.js b/test/turn-credentials.test.js
nnew file mode 100644
iindex 00000000..584e5212
---- /dev/null
++++ b/test/turn-credentials.test.js
@@@ -0,0 +1,32 @@
++/**
++ * TURN credentials: short-lived HMAC pairs with TURN_AUTH_SECRET, the static pair without it, and
++ * never a turn: entry without credentials.
++ * node test/turn-credentials.test.js
++ */
++'use strict';
++const assert = require('assert');
++const crypto = require('crypto');
++process.env.NODE_ENV = 'test';
++delete process.env.TURN_AUTH_SECRET;
++const config = require('../server/config');
++const turn = require('../server/net/turn');
++
++config.turn = config.turn || {};
++config.turn.url = 'turn:turn.example.org';
++config.turn.username = 'static-user'; config.turn.credential = 'static-pass';
++let e = turn.turnEntries(config.turn.url, 'u7');
++assert.strictEqual(e.length, 2);
++assert.strictEqual(e[0].username, 'static-user');
++assert.strictEqual(e[1].urls, 'turn:turn.example.org?transport=tcp');
++
++config.turn.username = ''; config.turn.credential = '';
++assert.deepStrictEqual(turn.turnEntries(config.turn.url, 'u7'), [], 'no credentials → no turn: entry');
++
++process.env.TURN_AUTH_SECRET = 'top-secret';
++e = turn.turnEntries(config.turn.url, 'u7');
++const [expiry, tag] = e[0].username.split(':');
++assert.strictEqual(tag, 'u7');
++assert.ok(Number(expiry) > Date.now() / 1000 + 3000 && Number(expiry) < Date.now() / 1000 + 3700, 'expires in about an hour');
++assert.strictEqual(e[0].credential, crypto.createHmac('sha1', 'top-secret').update(e[0].username).digest('base64'));
++assert.strictEqual(turn.turnEntries(config.turn.url, 'bad tag/;').at(-1).username.split(':')[1], 'badtag');
++console.log('turn credentials: all checks passed');