diff --git a/deploy/coturn/README.md b/deploy/coturn/README.md new file mode 100644 index 0000000..5b6be56 --- /dev/null +++ b/deploy/coturn/README.md @@ -0,0 +1,44 @@ +# Self-hosted TURN (coturn) for BizGaze Connect + +Why: customers behind symmetric NAT / corporate firewalls / VPNs can't form a direct +WebRTC path, so screen share blanks out and disconnects. A TURN relay fixes it. We host +our own coturn on a VM we already own — flat cost, no per-GB billing. + +## 1. VM prerequisites +- A VM with a **public IP** (your data-center VM is fine). +- A DNS A record, e.g. `turn.yourdomain.com` -> that public IP. +- A TLS cert for that name (Let's Encrypt): `certbot certonly --standalone -d turn.yourdomain.com` + +## 2. Open firewall ports (on the VM and any edge firewall) +- `3478/udp` and `3478/tcp` (STUN/TURN) +- `5349/tcp` (TURN over TLS) — and `443/tcp` if you enable alt-tls +- `49152-65535/udp` (relay range) + +## 3. Configure +Edit `turnserver.conf`: +- `external-ip=` your VM's public IP +- `static-auth-secret=` a long random string (e.g. `openssl rand -hex 32`) +- `realm=` your domain +- `cert=` / `pkey=` paths to your Let's Encrypt cert + +## 4. Run +``` +docker compose up -d # uses docker-compose.yml here +# or native: apt install coturn; copy this file to /etc/turnserver.conf; enable in /etc/default/coturn; systemctl enable --now coturn +``` + +## 5. Point the app at it (production env) +``` +TURN_URLS=turn:turn.yourdomain.com:3478,turn:turn.yourdomain.com:3478?transport=tcp,turns:turn.yourdomain.com:5349?transport=tcp +TURN_SECRET= +TURN_TTL=86400 +``` +The app's `/api/ice` mints short-lived credentials from `TURN_SECRET` automatically — no +permanent password is exposed, and outsiders can't reuse your relay. Restart the app. + +## 6. Verify +- `GET https:///api/ice` should return a `turn:`/`turns:` entry with a username + credential. +- Test page: https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/ + Add your `turns:turn.yourdomain.com:5349?transport=tcp` with the username/credential from + `/api/ice`; you should see a candidate of type **relay**. If you do, restrictive networks + are covered. diff --git a/deploy/coturn/docker-compose.yml b/deploy/coturn/docker-compose.yml new file mode 100644 index 0000000..8d506f1 --- /dev/null +++ b/deploy/coturn/docker-compose.yml @@ -0,0 +1,12 @@ +# Run coturn on your VM: docker compose up -d +# host networking is required so the UDP relay port range works without per-port mapping. +services: + coturn: + image: coturn/coturn:latest + container_name: coturn + restart: unless-stopped + network_mode: host + volumes: + - ./turnserver.conf:/etc/coturn/turnserver.conf:ro + - /etc/letsencrypt:/etc/letsencrypt:ro # TLS cert for turns: + command: ["-c", "/etc/coturn/turnserver.conf"] diff --git a/deploy/coturn/turnserver.conf b/deploy/coturn/turnserver.conf new file mode 100644 index 0000000..c606eb6 --- /dev/null +++ b/deploy/coturn/turnserver.conf @@ -0,0 +1,45 @@ +# coturn config for BizGaze Connect self-hosted TURN. +# Put this on your VM (public IP) and run via Docker (see docker-compose.yml) or +# native coturn (apt install coturn). Replace every CHANGE_ME / placeholder. + +# --- listening --- +listening-port=3478 +tls-listening-port=5349 +# If this VM has a spare 443, also exposing TURNS on 443 gives the best traversal +# through strict corporate firewalls (uncomment + ensure nothing else uses 443): +# alt-tls-listening-port=443 + +# Public address clients reach. If the VM has a 1:1 NAT, use external-ip=PUBLIC/PRIVATE. +external-ip=CHANGE_ME_PUBLIC_IP + +# Relay port range (open these UDP ports in the firewall too). +min-port=49152 +max-port=65535 + +# --- auth: time-limited shared-secret credentials (matches the app's TURN_SECRET) --- +use-auth-secret +static-auth-secret=CHANGE_ME_LONG_RANDOM_SECRET +realm=connect.yourdomain.com + +# --- TLS (needed for turns: on 5349/443). Use a real cert for turn.yourdomain.com --- +cert=/etc/letsencrypt/live/turn.yourdomain.com/fullchain.pem +pkey=/etc/letsencrypt/live/turn.yourdomain.com/privkey.pem + +# --- hardening --- +fingerprint +no-cli +no-multicast-peers +no-tcp-relay +# Block relaying to private/internal ranges (prevents your relay being used to reach +# your own LAN / cloud metadata — important SSRF protection): +denied-peer-ip=0.0.0.0-0.255.255.255 +denied-peer-ip=10.0.0.0-10.255.255.255 +denied-peer-ip=100.64.0.0-100.127.255.255 +denied-peer-ip=169.254.0.0-169.254.255.255 +denied-peer-ip=172.16.0.0-172.31.255.255 +denied-peer-ip=192.168.0.0-192.168.255.255 +denied-peer-ip=::1 +denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff +# Optional: cap per-session bandwidth (bytes/sec) to protect the VM, e.g. 700000 = ~5.6 Mbps +# bps-capacity=0 +# total-quota=100 diff --git a/server/calls.js b/server/calls.js new file mode 100644 index 0000000..2a68ade --- /dev/null +++ b/server/calls.js @@ -0,0 +1,151 @@ +// Shared group calls: one live call per group. Members join without a code; the call +// ends (with a duration line in the chat) when the last participant's mesh room empties. +const fs = require('fs'); +const path = require('path'); +const R = require('./repos'); +const A = require('./auth'); +const CHAT = require('./chat'); +const { TRANS_DIR } = require('./config'); +const { meetingRooms, groupCalls, roomToGroupCall, dmCalls, roomToDmCall, roomHost, transcriptBuffers, transcriptSubs } = require('./presence'); +const now = () => Date.now(); +const pairKey = (a, b) => [a, b].sort().join('|'); + +// Resolve a room's meeting context (group / scheduled meeting / title) for labelling recordings. +function meetingContext(room) { + const ctx = { groupId: null, meetingId: null, title: 'Meeting' }; + try { + const sched = R.scheduledMeetings.byCode(room); + if (sched) { ctx.meetingId = sched.id; ctx.groupId = sched.group_id || null; ctx.title = sched.title || 'Meeting'; } + } catch (_) {} + if (!ctx.groupId) { const gid = roomToGroupCall.get(room); if (gid) ctx.groupId = gid; } + if (ctx.groupId && ctx.title === 'Meeting') { try { const g = R.conversations.byId(ctx.groupId); if (g) ctx.title = g.name || 'Group'; } catch (_) {} } + if (!ctx.groupId && !ctx.meetingId && roomToDmCall.has(room)) ctx.title = 'Direct Call'; + return ctx; +} + +// Save the FULL shared conversation transcript as a PRIVATE copy for each subscriber. onlyUserId +// finalizes just that subscriber (on their leave / opt-out); omit to flush all remaining (room end). +// Must run BEFORE endCallByRoom (which clears the room→meeting maps meetingContext relies on). +function finalizeTranscript(room, onlyUserId) { + const subs = transcriptSubs.get(room); if (!subs || !subs.size) { if (!onlyUserId) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } return; } + const buf = transcriptBuffers.get(room) || []; + const ids = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs]; + if (ids.length && buf.length) { + const ctx = meetingContext(room); + const lines = buf.map((s) => { const ts = new Date(s.t); const hh = String(ts.getHours()).padStart(2, '0'), mm = String(ts.getMinutes()).padStart(2, '0'); return '[' + hh + ':' + mm + '] ' + s.speaker + ': ' + s.text; }); + const body = ctx.title + ' — transcript\n' + new Date(buf[0].t).toLocaleString() + '\n\n' + lines.join('\n') + '\n'; + for (const uid of ids) { + let user = null; try { user = R.users.byId(uid); } catch (_) {} + if (!user) { subs.delete(uid); continue; } + const id = A.id(); const file = 'm_' + id + '.txt'; + try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; } + // groupId null → private to its creator (see canSeeRec / /mrec auth). + R.recordings.create({ id, teamId: user.team_id, room, groupId: null, meetingId: ctx.meetingId, title: ctx.title, kind: 'transcript', file, mime: 'text/plain', size: null, durationMs: null, createdBy: uid, createdByName: user.name || user.email }); + subs.delete(uid); + } + } else { ids.forEach((uid) => subs.delete(uid)); } + if (!subs.size) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } // last subscriber done +} + +function fmtDur(ms) { const s = Math.max(0, Math.round(ms / 1000)); const m = Math.floor(s / 60); return m ? (m + 'm ' + (s % 60) + 's') : (s + 's'); } + +function broadcast(group, evt) { try { for (const mid of R.conversations.members(group)) CHAT.pushToUser(mid, evt); } catch (_) {} } + +// Post a centered activity line into the group (system sender → no ping on clients). +function postSystem(group, teamId, text) { + const id = A.id(); + R.messages.send({ id, teamId, senderId: '__system__', recipientId: '', body: text, conversationId: group }); + const m = R.messages.byId(id); + broadcast(group, { type: 'chat-message', message: { id: m.id, from: '__system__', conversation_id: group, body: m.body, created_at: m.created_at, system: true } }); +} + +function startGroupCall(group, teamId, user) { + const existing = groupCalls.get(group); + if (existing) return { room: existing.room, active: true, already: true }; + let room; do { room = A.numericCode(6); } while (meetingRooms.has(room)); + meetingRooms.set(room, new Map()); + const call = { room, startedAt: now(), startedBy: user.id, startedByName: user.name || user.email }; + // Log the call as a meeting so it appears under Past meetings (history) with the group name. + try { const hid = A.id(); R.scheduledMeetings.create({ id: hid, teamId, groupId: group, roomCode: room, title: 'Group call', description: null, scheduledAt: now(), createdBy: user.id }); call.historyId = hid; call.teamId = teamId; } catch (_) {} + groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host + postSystem(group, teamId, '📞 ' + call.startedByName + ' started a group call'); + let gName = 'Group'; try { const g = R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {} + broadcast(group, { type: 'group-call', group, active: true, room, by: user.id, startedByName: call.startedByName, groupName: gName }); + return { room, active: true }; +} + +// Called from signaling when a mesh room empties — ends the group call if this room was one. +function endGroupCallByRoom(room) { + const group = roomToGroupCall.get(room); + if (!group) return; + const call = groupCalls.get(group); + roomToGroupCall.delete(room); groupCalls.delete(group); roomHost.delete(room); + if (call) { + let teamId = call.teamId; try { const g = R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, '📞 Group call ended · ' + fmtDur(now() - call.startedAt)); } } catch (_) {} + if (call.historyId && teamId) { try { R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past + broadcast(group, { type: 'group-call', group, active: false, room }); + } +} + +// 1:1 (DM) call. Notifies both parties (state + a chat line) so the callee sees "Join". +function startDmCall(me, otherId, teamId) { + const key = pairKey(me.id, otherId); + const existing = dmCalls.get(key); + if (existing) return { room: existing.room, active: true, already: true }; + let room; do { room = A.numericCode(6); } while (meetingRooms.has(room)); + meetingRooms.set(room, new Map()); + const byName = me.name || me.email; + const call = { room, startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId }; + // Log to history (both participants) so the call shows under Past meetings with its transcript. + try { const hid = A.id(); R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {} + dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host + // A viewer-relative activity line: the caller sees "You started a call", the callee sees the name. + const mid = A.id(); + R.messages.send({ id: mid, teamId, senderId: me.id, recipientId: otherId, body: '📞 Started a call', msgType: 'call-start' }); + const m = R.messages.byId(mid); const dto = { id: m.id, from: me.id, to: otherId, conversation_id: null, body: m.body, created_at: m.created_at, system: true, evt: 'call-start', byName }; + try { CHAT.pushToUser(otherId, { type: 'chat-message', message: dto }); } catch (_) {} + try { CHAT.pushToUser(me.id, { type: 'chat-message', message: dto }); } catch (_) {} + try { CHAT.pushToUser(otherId, { type: 'dm-call', active: true, room, with: me.id, by: me.id, byName }); } catch (_) {} + try { CHAT.pushToUser(me.id, { type: 'dm-call', active: true, room, with: otherId, by: me.id, byName }); } catch (_) {} + return { room, active: true }; +} + +function endDmCallByRoom(room, silent) { + const key = roomToDmCall.get(room); if (!key) return; + const call = dmCalls.get(key); + roomToDmCall.delete(room); dmCalls.delete(key); roomHost.delete(room); + if (!call) return; + if (call.historyId && call.teamId) { try { R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past + // "Call ended · duration" activity line in the DM (shown to both) — skipped on decline. + if (!silent) try { + const mid = A.id(); const body = '📞 Call ended · ' + fmtDur(now() - call.startedAt); + R.messages.send({ id: mid, teamId: call.teamId, senderId: call.startedBy, recipientId: call.users.find((u) => u !== call.startedBy) || '', body, msgType: 'call-end' }); + const m = R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' }; + call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} }); + } catch (_) {} + call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, with: call.users[1 - i], room }); } catch (_) {} }); +} + +// Called from signaling when any mesh room empties. +function endCallByRoom(room) { endGroupCallByRoom(room); endDmCallByRoom(room); } + +// Callee declines a 1:1 call: post "Call declined" into the DM, drop the waiting caller, end it. +function declineDmCall(room, byUser) { + const key = roomToDmCall.get(room); if (!key) return { ok: false }; + const call = dmCalls.get(key); if (!call) return { ok: false }; + const callerId = call.users.find((id) => id !== byUser.id) || call.startedBy; + try { + const mid = A.id(); + R.messages.send({ id: mid, teamId: byUser.team_id, senderId: byUser.id, recipientId: callerId, body: '📞 Call declined', msgType: 'call-end' }); + const mm = R.messages.byId(mid); const dto = { id: mm.id, from: byUser.id, to: callerId, conversation_id: null, body: mm.body, created_at: mm.created_at, system: true, evt: 'call-end' }; + CHAT.pushToUser(callerId, { type: 'chat-message', message: dto }); + CHAT.pushToUser(byUser.id, { type: 'chat-message', message: dto }); + } catch (_) {} + // Drop the caller who's still waiting in the (otherwise empty) mesh room. + const peers = meetingRooms.get(room); + if (peers) { for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p._meetingRoom = null; } } meetingRooms.delete(room); } + endDmCallByRoom(room, true); // silent: we already posted "Call declined" + return { ok: true }; +} + +module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, finalizeTranscript, meetingContext, fmtDur, pairKey }; diff --git a/server/chat.js b/server/chat.js new file mode 100644 index 0000000..9009057 --- /dev/null +++ b/server/chat.js @@ -0,0 +1,31 @@ +// Chat presence + real-time delivery. A logged-in user opens a WebSocket and sends +// `chat-hello`; signaling.js registers the socket here. Messages are persisted over HTTP +// (routes.js) and pushed live to the recipient's sockets via pushToUser(). +const { chatClients } = require('./presence'); + +function register(userId, ws) { + if (!chatClients.has(userId)) chatClients.set(userId, new Set()); + chatClients.get(userId).add(ws); + ws._chatUserId = userId; +} + +function unregister(ws) { + const id = ws && ws._chatUserId; + if (!id) return; + const set = chatClients.get(id); + if (set) { set.delete(ws); if (!set.size) chatClients.delete(id); } +} + +function isOnline(userId) { + const s = chatClients.get(userId); + return !!(s && s.size); +} + +function pushToUser(userId, obj) { + const s = chatClients.get(userId); + if (!s) return; + const data = JSON.stringify(obj); + for (const ws of s) { if (ws.readyState === 1) { try { ws.send(data); } catch (_) {} } } +} + +module.exports = { register, unregister, isOnline, pushToUser }; diff --git a/server/directory.js b/server/directory.js new file mode 100644 index 0000000..71762fc --- /dev/null +++ b/server/directory.js @@ -0,0 +1,57 @@ +// BizGaze user-directory search (cross-tenant). The auth token is kept SERVER-SIDE only — the +// browser calls /api/directory/search and never sees the token. Configure via env in production: +// BIZGAZE_DIRECTORY_URL (base, the search term is appended url-encoded) +// BIZGAZE_DIRECTORY_TOKEN (the "stat ..." Authorization header value) +const DEFAULT_URL = 'https://app.bizgaze.com/apis/v4/bizgaze/integrations/users_chatsearch/get_usersforchatsearch/searchterm/'; +const DEFAULT_TOKEN = 'stat 3cd2e190b4db448496ae316b155d2441'; + +function baseUrl() { return process.env.BIZGAZE_DIRECTORY_URL || DEFAULT_URL; } +function token() { return process.env.BIZGAZE_DIRECTORY_TOKEN || DEFAULT_TOKEN; } +function enabled() { return !!(baseUrl() && token()); } + +// Pull a field from an object by any of several case-insensitive key names. +function field(o, names) { + const keys = Object.keys(o || {}); + for (const want of names) { for (const k of keys) { if (k.toLowerCase() === want) { const v = o[k]; if (v != null && v !== '') return String(v); } } } + return ''; +} + +// BizGaze responses vary (raw array, or wrapped in Result/data, sometimes a JSON string). Normalize. +function toArray(data) { + let d = data; + if (typeof d === 'string') { try { d = JSON.parse(d); } catch (_) { return []; } } + if (Array.isArray(d)) return d; + if (d && typeof d === 'object') { + for (const key of ['Result', 'result', 'data', 'Data', 'records', 'Records', 'items', 'Items']) { + if (d[key] != null) { let v = d[key]; if (typeof v === 'string') { try { v = JSON.parse(v); } catch (_) {} } if (Array.isArray(v)) return v; } + } + } + return []; +} + +function pick(o) { + return { + id: field(o, ['userid', 'id', 'contactid', 'partyid', 'recordid']), + name: field(o, ['fullname', 'name', 'displayname', 'username', 'contactname', 'firstname']), + email: field(o, ['email', 'emailaddress', 'emailid', 'mail']), + phone: field(o, ['mobile', 'mobilenumber', 'phone', 'phonenumber', 'contactno', 'contactnumber']), + avatar: field(o, ['photourl', 'photo', 'avatar', 'imageurl', 'profilepic', 'profileimage']), + org: field(o, ['organization', 'organisation', 'company', 'tenantname', 'orgname']), + }; +} + +async function search(term) { + if (!enabled() || !term || term.trim().length < 2) return []; + const url = baseUrl() + encodeURIComponent(term.trim()); + const ctrl = new AbortController(); + const to = setTimeout(() => ctrl.abort(), 8000); + try { + const r = await fetch(url, { headers: { Authorization: token(), Accept: 'application/json' }, signal: ctrl.signal }); + if (!r.ok) return []; + const data = await r.json().catch(() => null); + return toArray(data).map(pick).filter((x) => x.name || x.email || x.phone).slice(0, 25); + } catch (_) { return []; } + finally { clearTimeout(to); } +} + +module.exports = { search, enabled }; diff --git a/server/package-lock.json b/server/package-lock.json index 5bf0f15..f746c44 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,17 +1,30 @@ { - "name": "remote-access-server", - "version": "0.2.0", + "name": "bizgaze-support-server", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "remote-access-server", - "version": "0.2.0", + "name": "bizgaze-support-server", + "version": "2.0.0", "dependencies": { "ws": "^8.18.0" }, "engines": { "node": ">=22.5.0" + }, + "optionalDependencies": { + "nodemailer": "^6.9.14" + } + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "optional": true, + "engines": { + "node": ">=6.0.0" } }, "node_modules/ws": { diff --git a/server/public/share.html b/server/public/share.html index afe2f27..c294819 100644 --- a/server/public/share.html +++ b/server/public/share.html @@ -175,7 +175,8 @@ async function startStreaming(){ chatChannel.onmessage=(e)=>{try{addChat(JSON.parse(e.data));}catch(_){}}; const offer=await pc.createOffer(); await pc.setLocalDescription(offer); ws.send(JSON.stringify({type:'offer',sessionId,sdp:pc.localDescription})); - // Watchdog: clear failure message instead of a blank screen if no path establishes. + // Watchdog: if we can't establish the peer connection in time, show a clear reason + // instead of a blank screen (covers networks with no usable path even via TURN). clearTimeout(window.__connWatch); window.__connWatch=setTimeout(()=>{ if(pc && pc.connectionState!=='connected' && !sessionOver){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession("Couldn't connect on this network — it may be blocking screen sharing. Try a different network (e.g. mobile data / hotspot), then tap below for a new code."); } }, 20000); localStream.getVideoTracks()[0].onended=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));teardown();}; diff --git a/server/reminders.js b/server/reminders.js new file mode 100644 index 0000000..0b605db --- /dev/null +++ b/server/reminders.js @@ -0,0 +1,25 @@ +// Fires a one-shot "starts in ~10 minutes" reminder to a scheduled meeting's host, +// group members, and invited participants. Runs on a 60s tick; marks each meeting reminded. +const R = require('./repos'); +const CHAT = require('./chat'); + +function tick() { + try { + const now = Date.now(); + const due = R.scheduledMeetings.dueForReminder(now, now + 10 * 60 * 1000); // starting within 10 min + for (const s of due) { + const recipients = new Set([s.created_by]); + let invited = []; try { invited = JSON.parse(s.participants || '[]'); } catch (_) {} + invited.forEach((id) => recipients.add(id)); + if (s.group_id) { try { R.conversations.members(s.group_id).forEach((m) => recipients.add(m)); } catch (_) {} } + const evt = { type: 'meeting-reminder', meeting: { id: s.id, title: s.title, scheduledAt: s.scheduled_at, room: s.room_code } }; + recipients.forEach((uid) => { try { CHAT.pushToUser(uid, evt); } catch (_) {} }); + R.scheduledMeetings.markReminded(s.id); + } + } catch (_) { /* never let the timer die */ } +} + +let timer = null; +function start() { if (!timer) timer = setInterval(tick, 60 * 1000); } +start(); +module.exports = { start, tick }; diff --git a/server/routes.js b/server/routes.js index aee6a83..bf1d340 100644 --- a/server/routes.js +++ b/server/routes.js @@ -124,7 +124,8 @@ route('POST', '/api/mfa/enable', async (req, res) => { // 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 (lockout safety net). +// 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'; @@ -144,8 +145,10 @@ function provisionFromBizgaze(email, bz) { return R.users.byId(existing.id); } -// Login: validates locally first (seeded/bootstrap accounts), then against BizGaze -// (the identity provider) when BIZGAZE_LOGIN_URL is configured. Sets a session cookie. +// 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' }); @@ -154,7 +157,8 @@ route('POST', '/api/login', async (req, res) => { // Production: when BizGaze is the IdP, verify ONLY against BizGaze (no local-password // fallback) so stale in-app accounts can't shadow a BizGaze login and everyone lands in - // the same tenant. Local accounts stay usable for dev/testing via ALLOW_LOCAL_LOGIN=1. + // the same tenant (admins then see all sessions). Local accounts stay usable for + // dev/testing via ALLOW_LOCAL_LOGIN=1. const bizgazeOnly = BZ.isEnabled() && process.env.ALLOW_LOCAL_LOGIN !== '1'; let u = null, bzMsg = null; if (bizgazeOnly) { @@ -164,6 +168,8 @@ route('POST', '/api/login', async (req, res) => { u = provisionFromBizgaze(email, bz); if (u && u.active === 0) return json(res, 403, { error: 'This account has been deactivated' }); } else { + // Local/dev/tests, or ALLOW_LOCAL_LOGIN=1: verify the local password, then fall back + // to BizGaze if a local password isn't set/correct (so SSO users can still sign in). u = (existing && A.verifyPassword(password, existing.pw_salt, existing.pw_hash)) ? existing : null; if (!u) { const bz = await BZ.validateLogin(email, password); @@ -234,8 +240,11 @@ route('GET', '/api/setup-state', async (req, res) => { }); // ICE servers for WebRTC. Always includes a public STUN; adds our TURN relay if -// configured. TURN_SECRET (coturn use-auth-secret) -> time-limited HMAC credentials -// (no permanent password exposed); otherwise static TURN_USERNAME + TURN_CREDENTIAL. +// 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) { @@ -244,7 +253,7 @@ route('GET', '/api/ice', async (req, res) => { 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); + 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 }); @@ -299,7 +308,8 @@ route('POST', '/api/users', async (req, res) => { 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 sole IdP, logins are created in BizGaze, not here (creating local - // accounts is what previously shadowed BizGaze and split tenants). Allowed in dev. + // accounts is what previously shadowed BizGaze and split tenants). Allowed in dev via + // ALLOW_LOCAL_LOGIN=1. if (BZ.isEnabled() && process.env.ALLOW_LOCAL_LOGIN !== '1') return json(res, 400, { error: 'Logins are managed in BizGaze. Add the user there; 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' }); diff --git a/server/scripts/migrate-bizgaze-only.js b/server/scripts/migrate-bizgaze-only.js new file mode 100644 index 0000000..f65caf8 --- /dev/null +++ b/server/scripts/migrate-bizgaze-only.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +// One-time PRODUCTION migration for "BizGaze-only logins". +// +// Deletes the in-app (pre-BizGaze) local accounts. Combined with the BizGaze-only login +// change, every user then signs in through BizGaze and is provisioned into the same +// tenant — which restores the admin's "see all sessions" report. +// +// A "pre-BizGaze" account = a user with NO 'sso_user_created' audit entry for its email +// (i.e. created locally via register/console, not provisioned by a BizGaze login). +// +// SAFE BY DEFAULT: dry-run unless you pass --apply. BACK UP THE DB FIRST. +// Dry run : node scripts/migrate-bizgaze-only.js +// Apply : node scripts/migrate-bizgaze-only.js --apply +// Honors DB_PATH (same env var the server uses). + +const db = require('../db'); +const APPLY = process.argv.includes('--apply'); + +const tableExists = (name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name); + +const ssoEmails = new Set( + db.prepare("SELECT DISTINCT lower(user_email) AS e FROM audit_log WHERE action='sso_user_created' AND user_email IS NOT NULL") + .all().map((r) => r.e), +); +const users = db.prepare('SELECT id,email,name,role,team_id,active FROM users').all(); +const keep = users.filter((u) => ssoEmails.has(String(u.email).toLowerCase())); +const remove = users.filter((u) => !ssoEmails.has(String(u.email).toLowerCase())); + +console.log('=== Teams ==='); +for (const t of db.prepare('SELECT id,name FROM teams').all()) { + const uc = db.prepare('SELECT COUNT(*) AS c FROM users WHERE team_id=?').get(t.id).c; + console.log(` ${t.id} ${t.name} (${uc} users)`); +} +console.log('\n=== Users ==='); +console.log(` total: ${users.length} | BizGaze-provisioned (keep): ${keep.length} | local pre-BizGaze (delete): ${remove.length}`); +console.log('\n KEEP (already BizGaze-provisioned):'); +keep.forEach((u) => console.log(` ${u.email} [${u.role}] team ${u.team_id}`)); +console.log('\n DELETE (local / pre-BizGaze):'); +remove.forEach((u) => console.log(` ${u.email} [${u.role}] team ${u.team_id}`)); + +if (!remove.length) { console.log('\nNothing to delete. Done.'); process.exit(0); } + +if (!APPLY) { + console.log('\nDRY RUN — no changes made. Re-run with --apply to delete the local accounts above.'); + console.log('After deletion, those users sign in via BizGaze and are recreated automatically.'); + process.exit(0); +} + +const delAuth = db.prepare('DELETE FROM sessions_auth WHERE user_id=?'); +const delRefresh = tableExists('refresh_tokens') ? db.prepare('DELETE FROM refresh_tokens WHERE user_id=?') : null; +const delUser = db.prepare('DELETE FROM users WHERE id=?'); +let deleted = 0; +db.exec('BEGIN'); +try { + for (const u of remove) { + delAuth.run(u.id); // clear active sessions (FK) — also logs them out + if (delRefresh) delRefresh.run(u.id); + delUser.run(u.id); + deleted++; + } + db.exec('COMMIT'); +} catch (e) { + db.exec('ROLLBACK'); + console.error('FAILED — rolled back, no changes applied:', e.message); + process.exit(1); +} +console.log(`\nDONE. Deleted ${deleted} local account(s). They are recreated via BizGaze on next sign-in.`); diff --git a/server/webhooks.js b/server/webhooks.js new file mode 100644 index 0000000..315db7b --- /dev/null +++ b/server/webhooks.js @@ -0,0 +1,54 @@ +// Outbound webhook delivery. emit(event, tenantId, payload) fans the event out to every +// active per-tenant subscription registered for that event, plus the legacy global +// BIZGAZE_WEBHOOK_URL (back-compat). Each delivery is HMAC-signed and retried on failure. +// +// NOTE (roadmap): retries are in-memory/best-effort. For guaranteed delivery this should +// move to a persistent queue when the app scales to multiple instances (see ARCHITECTURE.md). +const R = require('./repos'); +const crypto = require('crypto'); + +const EVENTS = ['session.started', 'session.ended']; + +function sign(secret, body) { + return crypto.createHmac('sha256', secret || '').update(body).digest('base64url'); +} + +const RETRY_DELAYS = [2000, 10000, 30000]; // after the first attempt +function deliver(url, secret, body, onDone) { + let attempt = 0; + const go = async () => { + attempt++; + let ok = false, status = 0, err = null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-BizGaze-Signature': sign(secret, body), 'X-BizGaze-Event': (() => { try { return JSON.parse(body).event; } catch { return ''; } })() }, + body, + signal: AbortSignal.timeout(10000), + }); + status = res.status; ok = res.ok; + } catch (e) { err = (e && e.message) || 'delivery failed'; } + if (ok || attempt > RETRY_DELAYS.length) { if (onDone) onDone({ ok, status, err }); return; } + setTimeout(go, RETRY_DELAYS[attempt - 1]); + }; + go(); +} + +function emit(event, tenantId, payload) { + const body = JSON.stringify({ event, ...payload }); + // Per-tenant subscriptions + try { + for (const h of R.webhooks.activeForTenant(tenantId)) { + const subs = String(h.events || '').split(',').map((s) => s.trim()); + if (subs.includes('*') || subs.includes(event)) { + deliver(h.url, h.secret, body, (r) => { try { R.webhooks.setStatus(h.id, r.ok ? 1 : 0, r.err || ('HTTP ' + r.status)); } catch (_) {} }); + } + } + } catch (_) {} + // Legacy global webhook (back-compat): session.ended → BIZGAZE_WEBHOOK_URL, signed with SSO_SECRET. + if (event === 'session.ended' && process.env.BIZGAZE_WEBHOOK_URL) { + deliver(process.env.BIZGAZE_WEBHOOK_URL, process.env.SSO_SECRET || '', body); + } +} + +module.exports = { emit, sign, EVENTS };