// HTTP JSON API routes (auth, MFA, users, machines, report, audit, media uploads, SSO). // Returns a { "METHOD /path": handler } map consumed by server.js. const fs = require('fs'); const path = require('path'); const R = require('./repos'); const A = require('./auth'); const BZ = require('./bizgaze'); const { now, json, readBody, parseCookies } = require('./lib'); const { audit, currentUser } = require('./session'); const { onlineAgents } = require('./presence'); const { REC_DIR, TRANS_DIR, SESSION_TTL } = require('./config'); 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 = R.users.anyExists(); 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 (R.users.emailExists(email)) return json(res, 409, { error: 'email already registered' }); const { hash, salt } = A.hashPassword(password); const team = R.teams.create(teamName || `${email}'s team`); const userId = R.users.create({ tenantId: team.id, email, hash, salt, role: 'admin', name: null, mfaSecret: A.newMfaSecret() }); audit({ team_id: team.id, 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 = R.users.byEmail(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' }); R.users.enableMfa(u.id); json(res, 200, { ok: true }); }); // Provision (or refresh) a local user from a successful BizGaze identity check. // The local row exists so sessions, audit, and team-scoped data work; BizGaze stays // the source of truth for credentials (the local password is random + unused). // Emails that must always be admins regardless of what BizGaze returns (safety net so an // admin can't be locked out of the report if BizGaze doesn't flag them isAdmin). Optional. const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); function provisionFromBizgaze(email, bz) { const role = (bz.isAdmin || ADMIN_EMAILS.includes(String(email).toLowerCase())) ? 'admin' : 'technician'; const existing = R.users.byEmail(email); if (!existing) { const team = R.teams.first() || R.teams.create('BizGaze'); const { hash, salt } = A.hashPassword(A.token()); const id = R.users.create({ tenantId: team.id, email, hash, salt, role, name: bz.name || null, mfaSecret: A.newMfaSecret() }); audit({ team_id: team.id, user_id: id, user_email: email, action: 'sso_user_created', detail: 'via BizGaze' }); return R.users.byId(id); } // BizGaze is the source of truth: keep name + role in sync on each login. if (bz.name && bz.name !== existing.name) R.users.setName(existing.id, bz.name); if (existing.role !== role) R.users.setRole(existing.id, role); return R.users.byId(existing.id); } // Login: when BizGaze (BIZGAZE_LOGIN_URL) is configured it is the ONLY authority — the // credentials are verified against BizGaze and the user is provisioned/synced locally // (local passwords are not accepted). Without it (dev/tests) the local password is // checked. Sets a session cookie. route('POST', '/api/login', async (req, res) => { const { email, password, remember } = await readBody(req); if (!email || !password) return json(res, 400, { error: 'email and password required' }); const existing = R.users.byEmail(email); if (existing && existing.active === 0) return json(res, 403, { error: 'This account has been deactivated' }); let u = null; if (BZ.isEnabled()) { // BizGaze is the identity provider: credentials are ALWAYS verified against BizGaze. // Local passwords are NOT accepted, so stale in-app accounts can't shadow a BizGaze // login and everyone provisions into the same tenant (admins then see all sessions). const bz = await BZ.validateLogin(email, password); if (bz.error) return json(res, 503, { error: bz.error }); if (!bz.ok) return json(res, 401, { error: bz.message || 'Username or password do not match.' }); u = provisionFromBizgaze(email, bz); if (u && u.active === 0) return json(res, 403, { error: 'This account has been deactivated' }); } else { // No identity provider configured (local/dev/tests): verify the local password. u = (existing && A.verifyPassword(password, existing.pw_salt, existing.pw_hash)) ? existing : null; if (!u) return json(res, existing ? 401 : 404, { error: existing ? 'Incorrect password. Please try again.' : 'This email is not registered.' }); } const tok = A.token(); const ttl = remember ? 1000 * 60 * 60 * 24 * 30 : SESSION_TTL; // 30 days if remembered, else 24h R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, 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' }); // Cookie for the web app; token in the body for native desktop/mobile clients // (they send it back as `Authorization: Bearer `). json(res, 200, { ok: true, mfaRequired: false, token: tok, expiresAt: now() + ttl }); }); // 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 && R.authSessions.byToken(tok); if (!s) return json(res, 401, { error: 'no session' }); const u = R.users.byId(s.user_id); if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' }); R.authSessions.markMfaPassed(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) R.authSessions.deleteByToken(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 = R.users.anyExists(); json(res, 200, { registrationOpen: !anyUser || process.env.ALLOW_REGISTRATION === '1' }); }); // ICE servers for WebRTC. Always includes a public STUN; adds our TURN relay if // configured. Two credential modes: // - Shared secret (recommended, coturn `use-auth-secret`): set TURN_SECRET and we mint // time-limited credentials per request (no permanent password is ever handed out, so // outsiders can't reuse your relay). Optional TURN_TTL seconds (default 24h). // - Static: set TURN_USERNAME + TURN_CREDENTIAL for a fixed long-term credential. route('GET', '/api/ice', async (req, res) => { const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }]; if (process.env.TURN_URLS) { const urls = process.env.TURN_URLS.split(',').map((u) => u.trim()).filter(Boolean); let username = process.env.TURN_USERNAME || ''; let credential = process.env.TURN_CREDENTIAL || ''; if (process.env.TURN_SECRET) { const ttl = parseInt(process.env.TURN_TTL || '86400', 10); username = String(Math.floor(Date.now() / 1000) + ttl); // coturn expects "" credential = require('crypto').createHmac('sha1', process.env.TURN_SECRET).update(username).digest('base64'); } iceServers.push({ urls, username, 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 = R.users.byEmail(p.email); if (!u) { const team = R.teams.first(); if (!team) return fail('No team configured'); const { hash, salt } = A.hashPassword(A.token()); const role = (p.role === 'admin' || p.role === 'viewer') ? p.role : 'technician'; const userId = R.users.create({ tenantId: team.id, email: p.email, hash, salt, role, name: p.name || null, mfaSecret: A.newMfaSecret() }); u = R.users.byId(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) { R.users.setName(u.id, p.name); } if (u.active === 0) return fail('Account deactivated'); const tok = A.token(); R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl: 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' }); // With BizGaze as the identity provider, logins are created in BizGaze — not here. // (Creating local accounts is what previously shadowed BizGaze and split tenants.) if (BZ.isEnabled()) return json(res, 400, { error: 'Logins are managed in BizGaze. Add the user in BizGaze; they appear here on first sign-in.' }); const { email, password, name, role } = await readBody(req); if (!email || !password) return json(res, 400, { error: 'email and temporary password required' }); if (R.users.emailExists(email)) return json(res, 409, { error: 'email already registered' }); const { hash, salt } = A.hashPassword(password); const r = (role === 'admin' || role === 'viewer') ? role : 'technician'; const userId = R.users.create({ tenantId: u.team_id, email, hash, salt, role: r, name: name || null, mfaSecret: A.newMfaSecret() }); 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 = R.users.listByTenant(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. 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 = R.users.inTenant(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); R.users.setPassword(target.id, hash, salt); R.authSessions.deleteByUser(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' }); R.users.setName(target.id, clean); 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' }); R.users.setActive(target.id, false); R.authSessions.deleteByUser(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': { R.users.setActive(target.id, true); 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' }); R.authSessions.deleteByUser(target.id); R.users.remove(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] || ''); // Admins see the whole team (and may filter by agent); everyone else sees only // their own sessions, regardless of any agent filter passed. const agentEmail = u.role !== 'admin' ? u.email : (q.get('agent') || null); const from = q.get('from') ? new Date(q.get('from') + 'T00:00:00').getTime() : null; const to = q.get('to') ? new Date(q.get('to') + 'T23:59:59').getTime() : null; json(res, 200, R.sessionsLog.report({ tenantId: u.team_id, agentEmail, from, to })); }); // 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 = R.machines.listByTenant(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 enroll = A.token(); const mId = R.machines.create({ tenantId: u.team_id, name: name || 'Unnamed PC', enrollToken: enroll, unattended: !!unattended }); 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 = R.audit.listByTenant(u.team_id); json(res, 200, rows); }); // ---------- session recording: upload (agent) ---------- 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 params = new URLSearchParams(req.url.split('?')[1] || ''); const sid = params.get('sessionId'); const ext = params.get('ext') === 'mp4' ? 'mp4' : 'webm'; // container chosen by the recorder if (!sid) return json(res, 400, { error: 'sessionId required' }); const row = R.sessionsLog.byIdInTenant(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 + '.' + ext; try { fs.writeFileSync(path.join(REC_DIR, fname), Buffer.concat(chunks)); R.sessionsLog.setRecording(sid, fname); 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 = R.sessionsLog.byIdInTenant(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)); R.sessionsLog.setTranscript(sid, fname); json(res, 200, { ok: true }); } catch (e) { json(res, 500, { error: 'could not save transcript' }); } }); req.on('error', () => { try { res.end(); } catch (e) {} }); }); // API versioning: alias every /api/* route under /api/v1/* — a frozen contract for // native desktop/mobile clients. The web app keeps using the unversioned paths, and // both share the same handlers. (/sso is a browser redirect, intentionally unversioned.) for (const key of Object.keys(routes)) { const m = key.match(/^(\S+) \/api\/(.+)$/); if (m) routes[`${m[1]} /api/v1/${m[2]}`] = routes[key]; } module.exports = routes;