// WebSocket signaling. Two kinds of WS clients: // agent -> authenticates with machine enroll_token, waits for session requests // viewer -> authenticated technician, requests a session to a machine // The server brokers consent and relays SDP/ICE. Media never traverses the server. const R = require('./repos'); const A = require('./auth'); const { currentUser, audit } = require('./session'); const { onlineAgents, liveSessions, pendingShares } = require('./presence'); function onConnection(ws, req) { const hb = setInterval(() => { if (ws.readyState === 1) { try { ws.ping(); } catch {} } else { clearInterval(hb); } }, 25000); ws.on('message', (raw) => { let m; try { m = JSON.parse(raw); } catch { return; } handle(ws, m, req); }); ws.on('close', () => { clearInterval(hb); cleanup(ws); }); } function handle(ws, m, req) { switch (m.type) { // --- Agent comes online --- case 'agent-hello': { const machine = R.machines.byEnrollToken(m.enrollToken); if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'invalid enroll token' })); ws.kind = 'agent'; ws.machineId = machine.id; onlineAgents.set(machine.id, { ws, machine }); R.machines.touch(machine.id); ws.send(JSON.stringify({ type: 'agent-registered', machineId: machine.id, name: machine.name })); break; } // --- Technician requests control of a machine --- case 'viewer-connect': { const u = currentUser(req); // cookie sent on WS upgrade if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' })); const agent = onlineAgents.get(m.machineId); const machine = R.machines.inTenant(m.machineId, u.team_id); if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'no such machine' })); if (!agent) return ws.send(JSON.stringify({ type: 'error', message: 'machine offline' })); if (u.role === 'viewer' && false) {} // view-only still allowed to watch; control gated agent-side const sessionId = A.token(8); ws.kind = 'viewer'; ws.sessionId = sessionId; liveSessions.set(sessionId, { agentWs: agent.ws, viewerWs: ws, machine, user: u }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, machine_id: machine.id, machine_name: machine.name, action: 'session_requested' }); // Ask the agent for consent (or auto-grant if unattended policy is on) agent.ws.sessionId = sessionId; agent.ws.send(JSON.stringify({ type: 'session-request', sessionId, technician: u.email, unattended: !!machine.unattended, })); ws.send(JSON.stringify({ type: 'session-pending', sessionId, machineName: machine.name })); break; } // --- Agent grants/denies consent --- case 'consent': { const sess = liveSessions.get(m.sessionId); if (!sess) return; if (m.granted) { audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'consent_granted', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') }); try { R.sessionsLog.create({ id: m.sessionId, tenantId: sess.machine.team_id, agentEmail: sess.user.email, agentName: sess.agentName || sess.user.email, ticket: sess.ticket || null }); } catch (e) { /* duplicate consent */ } sess.viewerWs.send(JSON.stringify({ type: 'session-ready', sessionId: m.sessionId })); sess.agentWs.send(JSON.stringify({ type: 'start-stream', sessionId: m.sessionId })); } else { audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'consent_denied', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') }); sess.viewerWs.send(JSON.stringify({ type: 'session-denied', sessionId: m.sessionId })); liveSessions.delete(m.sessionId); } break; } // --- No-install: end user opens /share, gets a one-time code --- case 'share-create': { let code; do { code = A.numericCode(6); } while (pendingShares.has(code)); const sessionId = A.token(8); ws.kind = 'sharer'; ws.shareCode = code; ws.sessionId = sessionId; pendingShares.set(code, { sharerWs: ws, sessionId }); ws.send(JSON.stringify({ type: 'share-code', code })); break; } // --- Logged-in agent enters the code (+ ticket) to connect --- case 'code-connect': { const agent = currentUser(req); // identity from the agent's authenticated session if (!agent) { return ws.send(JSON.stringify({ type: 'error', message: 'Please sign in as an agent first' })); } const ticket = String(m.ticket || '').trim() || null; // optional: direct sessions have no ticket const pend = pendingShares.get(String(m.code || '').trim()); if (!pend || pend.sharerWs.readyState !== 1) { return ws.send(JSON.stringify({ type: 'error', message: 'Invalid or expired code' })); } pendingShares.delete(pend.sharerWs.shareCode); const sessionId = pend.sessionId; ws.kind = 'viewer'; ws.sessionId = sessionId; const agentName = agent.name || agent.email; const machine = { id: null, name: 'Support session ' + pend.sharerWs.shareCode, team_id: agent.team_id }; const user = { id: agent.id, email: agent.email, team_id: agent.team_id }; liveSessions.set(sessionId, { agentWs: pend.sharerWs, viewerWs: ws, machine, user, ticket, agentName }); pend.sharerWs.sessionId = sessionId; audit({ team_id: agent.team_id, user_id: agent.id, user_email: agent.email, machine_name: machine.name, action: 'code_session_requested', detail: (ticket ? 'Ticket ' + ticket : 'Direct session (no ticket)') + ' ยท agent ' + agentName }); pend.sharerWs.send(JSON.stringify({ type: 'share-request', sessionId, technician: agentName, ticket })); ws.send(JSON.stringify({ type: 'code-pending', sessionId })); break; } // --- Relay WebRTC signaling between the two peers --- case 'offer': case 'answer': case 'ice-candidate': { const sess = liveSessions.get(m.sessionId || ws.sessionId); if (!sess) return; const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs; if (peer && peer.readyState === 1) peer.send(JSON.stringify(m)); break; } case 'transcript': { const sess = liveSessions.get(m.sessionId || ws.sessionId); if (!sess) return; const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs; if (peer && peer.readyState === 1) peer.send(JSON.stringify(m)); break; } case 'recording': { const sess = liveSessions.get(m.sessionId || ws.sessionId); if (!sess) return; const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs; if (peer && peer.readyState === 1) peer.send(JSON.stringify(m)); break; } case 'end-session': { endSession(ws.sessionId, m.reason || null); break; } } } function notifyBizGaze(sessionId) { const url = process.env.BIZGAZE_WEBHOOK_URL; if (!url) return; try { const row = R.sessionsLog.byId(sessionId); if (!row) return; const body = JSON.stringify({ event: 'session.ended', sessionId: row.id, agent_email: row.agent_email, agent_name: row.agent_name, ticket: row.ticket, started_at: row.started_at, ended_at: row.ended_at, duration_ms: row.ended_at ? row.ended_at - row.started_at : null }); const crypto = require('crypto'); const sig = process.env.SSO_SECRET ? crypto.createHmac('sha256', process.env.SSO_SECRET).update(body).digest('base64url') : ''; fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-BizGaze-Signature': sig }, body }).catch(()=>{}); } catch (e) {} } function endSession(sessionId, reason) { const sess = liveSessions.get(sessionId); if (!sess) return; try { R.sessionsLog.end(sessionId); } catch (e) {} notifyBizGaze(sessionId); audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'session_ended', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') }); [sess.agentWs, sess.viewerWs].forEach((p) => { if (p && p.readyState === 1) p.send(JSON.stringify({ type: 'session-ended', sessionId, reason: reason || null })); }); liveSessions.delete(sessionId); } function cleanup(ws) { if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId); if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode); if (ws.sessionId) { for (const [sid, sess] of liveSessions) { if (sess.agentWs === ws || sess.viewerWs === ws) endSession(sid); } } } module.exports = { onConnection };