ba8bfc3f46
User-facing - New post-login home (/home): chat rail + Share/Connect (embedded) + Meeting; login lives here when logged out - Landing: "Log in with BizGaze" + no-login screen share - Console replaced by a role-scoped Dashboard (/dashboard): admins see all team sessions, others see only their own; stats + CSV/PDF export - Recordings saved as MP4 (H.264/AAC) with WebM fallback; old .webm still downloadable - Fix: duplicate "Sign in" on the login card Auth / integration - BizGaze as identity provider: /api/login validates against BIZGAZE_LOGIN_URL (env-gated) and provisions a local user - Phase 2 start: /api/v1 alias for all /api routes; Authorization: Bearer accepted across HTTP + WS; login returns a token (for native desktop/mobile clients) Backend refactor (Phase 1, behavior-preserving) - Split server.js into config/lib/session/presence/routes/static/signaling + repos (data-access) + bizgaze (service) - All SQL behind repos.js, tenant-scoped (tenantId == team_id for now) - e2e updated to current flow (21/21 pass before and after) Docs: ARCHITECTURE.md (target architecture + phased plan), CLAUDE.md repo layout, .env.example BIZGAZE_LOGIN_URL Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
174 rivejä
8.8 KiB
JavaScript
174 rivejä
8.8 KiB
JavaScript
// 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 };
|