feat: BizGaze Connect home, BizGaze login, modular backend, /api/v1

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>
This commit is contained in:
2026-06-12 00:40:07 +05:30
parent f6ebaa7bfb
commit ba8bfc3f46
21 changed files with 2085 additions and 803 deletions
+19 -592
View File
@@ -1,610 +1,37 @@
// Remote Access Platform — backend server
// HTTP JSON API (auth, MFA, machines, audit) + authenticated WebSocket signaling.
// BizGaze Connect — backend entry point.
// Thin wiring layer: HTTP request dispatch + WebSocket attach + listeners.
// All logic lives in focused modules:
// repos.js data-access (all SQL)
// bizgaze.js BizGaze identity provider
// lib.js HTTP helpers (json/readBody/parseCookies/now)
// session.js currentUser / audit
// presence.js shared in-memory live state (agents/sessions/shares)
// routes.js HTTP JSON API (/api/*, /sso)
// static.js static files + authenticated downloads (GET fallback)
// signaling.js WebSocket signaling (consent + SDP/ICE relay)
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const { WebSocketServer } = require('ws');
const db = require('./db');
const A = require('./auth');
const { PORT, HTTPS_PORT } = require('./config');
const { json } = require('./lib');
const routes = require('./routes');
const { handleGet } = require('./static');
const { onConnection } = require('./signaling');
const PORT = process.env.PORT || 8090;
const HTTPS_PORT = process.env.HTTPS_PORT || 8443;
const PUBLIC_DIR = path.join(__dirname, 'public');
const REC_DIR = path.join(__dirname, 'recordings');
try { fs.mkdirSync(REC_DIR, { recursive: true }); } catch (e) {}
const TRANS_DIR = path.join(__dirname, 'transcripts');
try { fs.mkdirSync(TRANS_DIR, { recursive: true }); } catch (e) {}
const SESSION_TTL = 1000 * 60 * 60 * 24; // 24h auto-logout
// ---------- helpers ----------
const now = () => Date.now();
const json = (res, code, body) => {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
};
function readBody(req) {
return new Promise((resolve) => {
let data = '';
req.on('data', (c) => (data += c));
req.on('end', () => {
try { resolve(data ? JSON.parse(data) : {}); } catch { resolve({}); }
});
});
}
function parseCookies(req) {
const out = {};
(req.headers.cookie || '').split(';').forEach((c) => {
const [k, ...v] = c.trim().split('=');
if (k) out[k] = decodeURIComponent(v.join('='));
});
return out;
}
function audit(entry) {
db.prepare(
`INSERT INTO audit_log (team_id,user_id,user_email,machine_id,machine_name,action,detail,at)
VALUES (@team_id,@user_id,@user_email,@machine_id,@machine_name,@action,@detail,@at)`
).run({
team_id: entry.team_id, user_id: entry.user_id || null, user_email: entry.user_email || null,
machine_id: entry.machine_id || null, machine_name: entry.machine_name || null,
action: entry.action, detail: entry.detail || null, at: now(),
});
}
// Resolve the logged-in user from the session cookie. Returns user row (with mfa state) or null.
function currentUser(req, { requireMfa = true } = {}) {
const tok = parseCookies(req).sid;
if (!tok) return null;
const s = db.prepare('SELECT * FROM sessions_auth WHERE token=?').get(tok);
if (!s || s.expires_at < now()) return null;
if (requireMfa && !s.mfa_passed) return null;
const u = db.prepare('SELECT * FROM users WHERE id=?').get(s.user_id);
if (!u || u.active === 0) return null;
return { ...u, _session: s };
}
// ---------- HTTP API ----------
const routes = {};
const route = (method, p, fn) => (routes[`${method} ${p}`] = fn);
// Register: creates a team + admin user. MFA must be set up before full access.
route('POST', '/api/register', async (req, res) => {
const anyUser = db.prepare('SELECT 1 FROM users LIMIT 1').get();
if (anyUser && process.env.ALLOW_REGISTRATION !== '1')
return json(res, 403, { error: 'Registration is closed. Contact your administrator.' });
const { email, password, teamName } = await readBody(req);
if (!email || !password) return json(res, 400, { error: 'email and password required' });
if (db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email))
return json(res, 409, { error: 'email already registered' });
const teamId = A.id(), userId = A.id();
const { hash, salt } = A.hashPassword(password);
const mfaSecret = A.newMfaSecret();
db.prepare('INSERT INTO teams (id,name,created_at) VALUES (?,?,?)')
.run(teamId, teamName || `${email}'s team`, now());
db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,mfa_secret,mfa_enabled,created_at)
VALUES (?,?,?,?,?,?,?,0,?)`)
.run(userId, teamId, email, hash, salt, 'admin', mfaSecret, now());
audit({ team_id: teamId, user_id: userId, user_email: email, action: 'user_registered' });
json(res, 200, { ok: true });
});
// Verify MFA enrollment (confirm the user scanned the QR / entered code)
route('POST', '/api/mfa/enable', async (req, res) => {
const { email, code } = await readBody(req);
const u = db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email);
if (!u) return json(res, 404, { error: 'no such user' });
if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' });
db.prepare('UPDATE users SET mfa_enabled=1 WHERE id=?').run(u.id);
json(res, 200, { ok: true });
});
// Login step 1: email + password -> sets a session cookie (mfa not yet passed)
route('POST', '/api/login', async (req, res) => {
const { email, password, remember } = await readBody(req);
const u = db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email);
if (!u || !A.verifyPassword(password, u.pw_salt, u.pw_hash))
return json(res, 401, { error: 'invalid credentials' });
if (u.active === 0) return json(res, 403, { error: 'This account has been deactivated' });
const tok = A.token();
const ttl = remember ? 1000 * 60 * 60 * 24 * 30 : SESSION_TTL; // 30 days if remembered, else 24h
db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,1,?,?)')
.run(tok, u.id, now(), now() + ttl);
res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${ttl / 1000}`);
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
json(res, 200, { ok: true, mfaRequired: false });
});
// Login step 2: TOTP code -> marks session mfa_passed
route('POST', '/api/login/mfa', async (req, res) => {
const { code } = await readBody(req);
const tok = parseCookies(req).sid;
const s = tok && db.prepare('SELECT * FROM sessions_auth WHERE token=?').get(tok);
if (!s) return json(res, 401, { error: 'no session' });
const u = db.prepare('SELECT * FROM users WHERE id=?').get(s.user_id);
if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' });
db.prepare('UPDATE sessions_auth SET mfa_passed=1 WHERE token=?').run(tok);
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
json(res, 200, { ok: true });
});
route('POST', '/api/logout', async (req, res) => {
const tok = parseCookies(req).sid;
if (tok) db.prepare('DELETE FROM sessions_auth WHERE token=?').run(tok);
res.setHeader('Set-Cookie', 'sid=; HttpOnly; Path=/; Max-Age=0');
json(res, 200, { ok: true });
});
route('GET', '/api/setup-state', async (req, res) => {
const anyUser = db.prepare('SELECT 1 FROM users LIMIT 1').get();
json(res, 200, { registrationOpen: !anyUser || process.env.ALLOW_REGISTRATION === '1' });
});
// ICE servers for WebRTC. Always includes a public STUN; adds managed TURN if
// configured via env (TURN_URLS comma-separated, TURN_USERNAME, TURN_CREDENTIAL).
// Sign up for a managed TURN service (Cloudflare / Metered / Twilio) and set these
// three env vars — nothing to install or run on your side.
route('GET', '/api/ice', async (req, res) => {
const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
if (process.env.TURN_URLS) {
iceServers.push({
urls: process.env.TURN_URLS.split(',').map((u) => u.trim()).filter(Boolean),
username: process.env.TURN_USERNAME || '',
credential: process.env.TURN_CREDENTIAL || '',
});
}
json(res, 200, { iceServers });
});
route('GET', '/api/me', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null });
});
// ---------- BizGaze SSO: agent arrives already logged in ----------
route('GET', '/sso', async (req, res) => {
if (!process.env.SSO_SECRET) { res.writeHead(503); return res.end('SSO not configured'); }
const q = new URLSearchParams(req.url.split('?')[1] || '');
const token = q.get('token') || '';
const [payloadB64, sig] = token.split('.');
const fail = (msg) => { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end(msg); };
if (!payloadB64 || !sig) return fail('Invalid SSO token');
const crypto = require('crypto');
const expect = crypto.createHmac('sha256', process.env.SSO_SECRET).update(payloadB64).digest('base64url');
const sigBuf = Buffer.from(sig), expBuf = Buffer.from(expect);
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) return fail('Invalid SSO signature');
let p; try { p = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); } catch { return fail('Invalid SSO payload'); }
if (!p.email || !p.exp || p.exp < Math.floor(now() / 1000)) return fail('SSO token expired');
let u = db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(p.email);
if (!u) {
const team = db.prepare('SELECT * FROM teams LIMIT 1').get();
if (!team) return fail('No team configured');
const userId = A.id();
const { hash, salt } = A.hashPassword(A.token());
const role = (p.role === 'admin' || p.role === 'viewer') ? p.role : 'technician';
db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at)
VALUES (?,?,?,?,?,?,?,?,0,?)`)
.run(userId, team.id, p.email, hash, salt, role, (p.name || null), A.newMfaSecret(), now());
u = db.prepare('SELECT * FROM users WHERE id=?').get(userId);
audit({ team_id: team.id, user_id: userId, user_email: p.email, action: 'sso_user_created', detail: p.name || '' });
} else if (p.name && p.name !== u.name) {
db.prepare('UPDATE users SET name=? WHERE id=?').run(p.name, u.id);
}
if (u.active === 0) return fail('Account deactivated');
const tok = A.token();
db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,1,?,?)')
.run(tok, u.id, now(), now() + SESSION_TTL);
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login', detail: 'via BizGaze SSO' });
const dest = '/connect' + (p.ticket ? ('?ticket=' + encodeURIComponent(p.ticket)) : '');
res.writeHead(302, { 'Set-Cookie': `sid=${tok}; HttpOnly; Path=/; Max-Age=${SESSION_TTL / 1000}`, Location: dest });
res.end();
});
// Admin adds an agent login to their team
route('POST', '/api/users', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
if (u.role !== 'admin') return json(res, 403, { error: 'only admins can add agents' });
const { email, password, name, role } = await readBody(req);
if (!email || !password) return json(res, 400, { error: 'email and temporary password required' });
if (db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email))
return json(res, 409, { error: 'email already registered' });
const userId = A.id();
const { hash, salt } = A.hashPassword(password);
const mfaSecret = A.newMfaSecret();
const r = (role === 'admin' || role === 'viewer') ? role : 'technician';
db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at)
VALUES (?,?,?,?,?,?,?,?,0,?)`)
.run(userId, u.team_id, email, hash, salt, r, (name || null), mfaSecret, now());
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_added', detail: email + ' (' + r + ')' });
json(res, 200, { ok: true, id: userId, email, role: r });
});
// List the team's agents
route('GET', '/api/users', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const rows = db.prepare('SELECT id,email,name,role,active,created_at FROM users WHERE team_id=?').all(u.team_id);
json(res, 200, rows);
});
// First-login MFA self-setup: a logged-in (password ok) user who hasn't enabled MFA yet
route('GET', '/api/mfa/setup', async (req, res) => {
const u = currentUser(req, { requireMfa: false });
if (!u) return json(res, 401, { error: 'unauthorized' });
if (u.mfa_enabled) return json(res, 400, { error: 'MFA already enabled' });
json(res, 200, { secret: u.mfa_secret, otpauthUrl: A.otpauthUrl(u.mfa_secret, u.email) });
});
// Admin manages an agent: reset password, rename, deactivate/activate, delete.
// (Display names are owned by the admin/BizGaze app — agents cannot edit them.)
route('POST', '/api/users/manage', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage agents' });
const { id, action, password, name } = await readBody(req);
const target = db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, u.team_id);
if (!target) return json(res, 404, { error: 'no such agent' });
switch (action) {
case 'reset-password': {
if (!password || String(password).length < 8) return json(res, 400, { error: 'new password must be at least 8 characters' });
const { hash, salt } = A.hashPassword(password);
db.prepare('UPDATE users SET pw_hash=?, pw_salt=? WHERE id=?').run(hash, salt, target.id);
db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(target.id); // force re-login
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_password_reset', detail: target.email });
return json(res, 200, { ok: true });
}
case 'rename': {
const clean = String(name || '').trim().slice(0, 60);
if (!clean) return json(res, 400, { error: 'name required' });
db.prepare('UPDATE users SET name=? WHERE id=?').run(clean, target.id);
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_renamed', detail: target.email + ' -> ' + clean });
return json(res, 200, { ok: true, name: clean });
}
case 'deactivate': {
if (target.id === u.id) return json(res, 400, { error: 'you cannot deactivate your own account' });
db.prepare('UPDATE users SET active=0 WHERE id=?').run(target.id);
db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(target.id);
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deactivated', detail: target.email });
return json(res, 200, { ok: true });
}
case 'activate': {
db.prepare('UPDATE users SET active=1 WHERE id=?').run(target.id);
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_activated', detail: target.email });
return json(res, 200, { ok: true });
}
case 'delete': {
if (target.id === u.id) return json(res, 400, { error: 'you cannot delete your own account' });
db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(target.id);
db.prepare('DELETE FROM users WHERE id=?').run(target.id);
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deleted', detail: target.email });
return json(res, 200, { ok: true });
}
default: return json(res, 400, { error: 'unknown action' });
}
});
// Session report: one row per session, filterable by agent and date period
route('GET', '/api/report', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const q = new URLSearchParams(req.url.split('?')[1] || '');
let sql = 'SELECT * FROM sessions_log WHERE team_id=?';
const args = [u.team_id];
if (q.get('agent')) { sql += ' AND agent_email=?'; args.push(q.get('agent')); }
if (q.get('from')) { sql += ' AND started_at>=?'; args.push(new Date(q.get('from') + 'T00:00:00').getTime()); }
if (q.get('to')) { sql += ' AND started_at<=?'; args.push(new Date(q.get('to') + 'T23:59:59').getTime()); }
sql += ' ORDER BY started_at DESC LIMIT 500';
json(res, 200, db.prepare(sql).all(...args));
});
// List machines for the team (with live online status from signaling layer)
route('GET', '/api/machines', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const rows = db.prepare('SELECT id,name,unattended,last_seen FROM machines WHERE team_id=?').all(u.team_id);
json(res, 200, rows.map((m) => ({ ...m, online: onlineAgents.has(m.id) })));
});
// Create a machine enrollment token (admin/technician). Agent uses it to come online.
route('POST', '/api/machines', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
if (u.role === 'viewer') return json(res, 403, { error: 'forbidden' });
const { name, unattended } = await readBody(req);
const mId = A.id(), enroll = A.token();
db.prepare('INSERT INTO machines (id,team_id,name,enroll_token,unattended,created_at) VALUES (?,?,?,?,?,?)')
.run(mId, u.team_id, name || 'Unnamed PC', enroll, unattended ? 1 : 0, now());
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, machine_id: mId, machine_name: name, action: 'machine_enrolled' });
json(res, 200, { id: mId, enrollToken: enroll });
});
route('GET', '/api/audit', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const rows = db.prepare("SELECT * FROM audit_log WHERE team_id=? OR team_id='adhoc' ORDER BY at DESC LIMIT 200").all(u.team_id);
json(res, 200, rows);
});
// ---------- session recording: upload (agent) + download (team) ----------
const MAX_REC_BYTES = 500 * 1024 * 1024; // 500 MB safety cap
route('POST', '/api/recording', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const sid = new URLSearchParams(req.url.split('?')[1] || '').get('sessionId');
if (!sid) return json(res, 400, { error: 'sessionId required' });
const row = db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(sid, u.team_id);
if (!row) return json(res, 404, { error: 'no such session' });
const chunks = []; let total = 0, aborted = false;
req.on('data', (c) => { total += c.length; if (total > MAX_REC_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); });
req.on('end', () => {
if (aborted) return json(res, 413, { error: 'recording too large' });
const fname = sid + '.webm';
try {
fs.writeFileSync(path.join(REC_DIR, fname), Buffer.concat(chunks));
db.prepare('UPDATE sessions_log SET recording=? WHERE id=?').run(fname, sid);
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'recording_saved', detail: 'session ' + sid });
json(res, 200, { ok: true });
} catch (e) { json(res, 500, { error: 'could not save recording' }); }
});
req.on('error', () => { try { res.end(); } catch (e) {} });
});
route('POST', '/api/transcript', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const sid = new URLSearchParams(req.url.split('?')[1] || '').get('sessionId');
if (!sid) return json(res, 400, { error: 'sessionId required' });
const row = db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(sid, u.team_id);
if (!row) return json(res, 404, { error: 'no such session' });
const chunks = []; let total = 0, aborted = false;
req.on('data', (c) => { total += c.length; if (total > 5 * 1024 * 1024) { aborted = true; req.destroy(); return; } chunks.push(c); });
req.on('end', () => {
if (aborted) return json(res, 413, { error: 'transcript too large' });
const fname = sid + '.txt';
try {
fs.writeFileSync(path.join(TRANS_DIR, fname), Buffer.concat(chunks));
db.prepare('UPDATE sessions_log SET transcript=? WHERE id=?').run(fname, sid);
json(res, 200, { ok: true });
} catch (e) { json(res, 500, { error: 'could not save transcript' }); }
});
req.on('error', () => { try { res.end(); } catch (e) {} });
});
// ---------- static + router ----------
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' };
function serveStatic(req, res) {
let p = req.url.split('?')[0];
if (p === '/') p = '/index.html';
if (p === '/console') p = '/console.html';
if (p === '/share') p = '/share.html';
if (p === '/connect') p = '/connect.html';
const fp = path.join(PUBLIC_DIR, path.normalize(p));
if (!fp.startsWith(PUBLIC_DIR)) return json(res, 403, { error: 'forbidden' });
fs.readFile(fp, (err, data) => {
if (err) return json(res, 404, { error: 'not found' });
const ct = MIME[path.extname(fp)] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': 'no-cache' });
res.end(data);
});
}
const server = http.createServer(async (req, res) => {
// ---------- HTTP request dispatch ----------
const server = http.createServer((req, res) => {
const key = `${req.method} ${req.url.split('?')[0]}`;
if (routes[key]) return routes[key](req, res);
if (req.method === 'GET' && req.url.split('?')[0].startsWith('/transcripts/')) {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const name = path.basename(decodeURIComponent(req.url.split('?')[0]));
const sid = name.replace(/\.txt$/i, '');
const row = db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(sid, u.team_id);
if (!row || !row.transcript) return json(res, 404, { error: 'not found' });
const fp = path.join(TRANS_DIR, row.transcript);
if (!fp.startsWith(TRANS_DIR)) return json(res, 403, { error: 'forbidden' });
return fs.stat(fp, (err, st) => {
if (err) return json(res, 404, { error: 'not found' });
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="transcript-' + sid + '.txt"', 'Cache-Control': 'no-store' });
const rs = fs.createReadStream(fp);
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
rs.pipe(res);
});
}
if (req.method === 'GET' && req.url.split('?')[0].startsWith('/recordings/')) {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const name = path.basename(decodeURIComponent(req.url.split('?')[0]));
const sid = name.replace(/\.webm$/i, '');
const row = db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(sid, u.team_id);
if (!row || !row.recording) return json(res, 404, { error: 'not found' });
const fp = path.join(REC_DIR, row.recording);
if (!fp.startsWith(REC_DIR)) return json(res, 403, { error: 'forbidden' });
return fs.stat(fp, (err, st) => {
if (err) return json(res, 404, { error: 'not found' });
res.writeHead(200, { 'Content-Type': 'video/webm', 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="session-' + sid + '.webm"', 'Cache-Control': 'no-store', 'Accept-Ranges': 'bytes' });
const rs = fs.createReadStream(fp);
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
rs.pipe(res);
});
}
if (req.method === 'GET') return serveStatic(req, res);
if (req.method === 'GET') return handleGet(req, res); // downloads + static
json(res, 404, { error: 'not found' });
});
// ---------- 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 onlineAgents = new Map(); // machineId -> { ws, machine }
const liveSessions = new Map(); // sessionId -> { agentWs, viewerWs, machine, user }
const pendingShares = new Map(); // code -> { sharerWs, sessionId } (no-install ad-hoc shares)
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); });
}
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', onConnection);
function handle(ws, m, req) {
switch (m.type) {
// --- Agent comes online ---
case 'agent-hello': {
const machine = db.prepare('SELECT * FROM machines WHERE enroll_token=?').get(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 });
db.prepare('UPDATE machines SET last_seen=? WHERE id=?').run(now(), 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 = db.prepare('SELECT * FROM machines WHERE id=? AND team_id=?').get(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 {
db.prepare('INSERT INTO sessions_log (id,team_id,agent_email,agent_name,ticket,started_at) VALUES (?,?,?,?,?,?)')
.run(m.sessionId, sess.machine.team_id, sess.user.email, (sess.agentName || sess.user.email), sess.ticket || null, now());
} 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 = db.prepare('SELECT * FROM sessions_log WHERE id=?').get(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 { db.prepare('UPDATE sessions_log SET ended_at=? WHERE id=? AND ended_at IS NULL').run(now(), 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);
}
}
}
server.listen(PORT, () => {
console.log(`HTTP on http://localhost:${PORT}`);
});