feat(meetings): LiveKit SFU — phase 1 (server plumbing, config-gated)

Adds the server side of scaling meetings past the ~5-peer mesh:
- config.js: LIVEKIT_URL/API_KEY/API_SECRET + LIVEKIT_ENABLED flag. All optional;
  when unset the app keeps the built-in P2P mesh (fully additive, like push).
- routes.js: GET /api/meetings/config (tells the client sfu on/off + wss url) and
  POST /api/meetings/token (mints a per-user, per-room LiveKit join token — hand-rolled
  HS256 JWT like the FCM/APNs tokens, no new dependency; secret stays server-side).
- docker-compose.yml: optional livekit service (single-node, no Redis), keys injected
  via LIVEKIT_KEYS from the same .env; media over published UDP 50000-50100 + TCP 7881,
  signaling proxied by NPM.
- livekit.yaml + .env.example documented.

Client (mesh->LiveKit media swap, behind the flag) lands in phase 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 13:11:37 +05:30
parent 30e354d58f
commit f1dbcd0f86
5 changed files with 102 additions and 1 deletions
+13
View File
@@ -14,9 +14,22 @@ try { fs.mkdirSync(TRANS_DIR, { recursive: true }); } catch (e) {}
try { fs.mkdirSync(UPLOADS_DIR, { recursive: true }); } catch (e) {}
try { fs.mkdirSync(DOWNLOADS_DIR, { recursive: true }); } catch (e) {}
// LiveKit SFU (scales meetings past the ~5-peer mesh ceiling). Entirely optional and config-gated:
// when LIVEKIT_URL/API_KEY/API_SECRET are all set the client uses LiveKit for meeting media; when
// they're unset the app falls back to the built-in P2P mesh, unchanged. The API secret is used
// ONLY server-side to mint per-user join tokens — it never reaches the browser.
const LIVEKIT_URL = process.env.LIVEKIT_URL || ''; // wss://livekit.bizgaze.com
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || '';
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || '';
const LIVEKIT_ENABLED = !!(LIVEKIT_URL && LIVEKIT_API_KEY && LIVEKIT_API_SECRET);
module.exports = {
PORT: process.env.PORT || 8090,
HTTPS_PORT: process.env.HTTPS_PORT || 8443,
LIVEKIT_URL,
LIVEKIT_API_KEY,
LIVEKIT_API_SECRET,
LIVEKIT_ENABLED,
PUBLIC_DIR,
REC_DIR,
TRANS_DIR,
+40 -1
View File
@@ -83,9 +83,27 @@ const API_KEY_SCOPES = ['report:read', 'audit:read'];
const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence');
const CALLS = require('./calls');
require('./reminders'); // start the 10-minute meeting-reminder loop
const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL } = require('./config');
const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED } = require('./config');
const crypto = require('crypto');
const MAX_FILE_BYTES = 25 * 1024 * 1024; // 25 MB per chat attachment
// Mint a LiveKit access token (HS256 JWT signed with the API secret) — same hand-rolled JWT
// approach as push.js's FCM/APNs tokens, so no extra dependency. Grants the holder join+publish+
// subscribe on exactly one room, as one identity. Secret stays server-side.
const _b64u = (buf) => Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
function livekitToken(identity, name, room, metadata) {
const nowSec = Math.floor(Date.now() / 1000);
const header = _b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const payload = _b64u(JSON.stringify({
iss: LIVEKIT_API_KEY, sub: identity, name: name || identity,
nbf: nowSec, exp: nowSec + 6 * 3600, // 6h — long enough for any meeting
metadata: metadata || '',
video: { room, roomJoin: true, canPublish: true, canSubscribe: true, canPublishData: true },
}));
const sig = _b64u(crypto.createHmac('sha256', LIVEKIT_API_SECRET).update(header + '.' + payload).digest());
return header + '.' + payload + '.' + sig;
}
// Issue a refresh token (native clients), store only its hash, return the plaintext once.
function issueRefreshToken(userId) {
const rtok = A.token(32);
@@ -865,6 +883,27 @@ route('POST', '/api/calls/invite', async (req, res) => {
json(res, 200, { ok: true, invited: ids.length });
});
// Does this deployment use the LiveKit SFU for meeting media? The client asks on load; if sfu is
// false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…).
route('GET', '/api/meetings/config', (req, res) => {
json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '' });
});
// Mint a LiveKit join token for the signed-in user + a specific room (the 6-digit meeting code).
// The room-membership/host authorization already happens over the meeting WebSocket; this only
// hands the client a media-plane credential scoped to that room and its own identity.
route('POST', '/api/meetings/token', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' });
const { room } = await readBody(req);
const rm = String(room || '').trim();
if (!/^[A-Za-z0-9._-]{4,64}$/.test(rm)) return json(res, 400, { error: 'invalid room' });
const metadata = JSON.stringify({ avatarUrl: u.avatar_url || '' });
const token = livekitToken(u.id, u.name || u.email, rm, metadata);
json(res, 200, { token, url: LIVEKIT_URL, identity: u.id, name: u.name || u.email });
});
// Decline an incoming 1:1 call: drops the caller, posts a "Call declined" line, clears the call.
route('POST', '/api/calls/decline', async (req, res) => {
const u = currentUser(req);