diff --git a/.env.example b/.env.example index dd39fbc..1e93e00 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,10 @@ TURN_CREDENTIAL= # Optional: open self-registration of the first/any team (1 to enable). # ALLOW_REGISTRATION=1 +# Optional: BizGaze as the identity provider. When set, /api/login validates +# credentials against this endpoint (after a local check) and provisions the user. +# BIZGAZE_LOGIN_URL=https://c02.bizgaze.app/Account/ValidateAndLogin + # Optional: shared secret for BizGaze SSO + signed webhook delivery. # SSO_SECRET= diff --git a/.gitignore b/.gitignore index 0625426..d0131be 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,10 @@ dist/ build/ out/ +# Runtime media (created at startup by config.js) +server/recordings/ +server/transcripts/ + # OS files .DS_Store Thumbs.db diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..0ebd370 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,163 @@ +# BizGaze Connect — Architecture & Roadmap + +This document records the **current** architecture, the **target** architecture, and a +**phased migration plan** so that the three strategic goals can be added *additively* +rather than as rewrites. + +Strategic goals (see also `CLAUDE.md`): +1. **Native Android/iOS apps** +2. **Integration with any third-party application** +3. **Org-based licensing model** (Zoom-like: organizations buy seats/plans) + +--- + +## 1. Current architecture (as of 2026-06) + +``` +Single Node process (server/server.js, ~640 lines) +├── HTTP JSON API (/api/*, cookie-session auth) +├── WebSocket signaling (/ws — SDP/ICE relay, consent, share codes) +├── Static file serving (public/*.html, single-file pages, no build) +└── In-process state liveSessions / onlineAgents / pendingShares (Maps) + +Data: node:sqlite single file (server/data.db) + teams, users, sessions_auth, machines, audit_log, sessions_log +Media: WebRTC P2P (1:1). STUN + managed TURN. Media never traverses the server. +Auth: scrypt passwords, opaque session token in an HttpOnly `sid` cookie. TOTP code exists but + login currently marks sessions MFA-passed directly. +Integrations: outbound webhook (single env URL, `session.ended`, HMAC-signed); + inbound SSO (`/sso`, custom HMAC token). +Recordings/transcripts: written to local disk (server/recordings, server/transcripts). +``` + +### What is already future-proof (keep) +- **WebRTC + `/ws` signaling** — standards-based; reused as-is by native apps and an SFU. +- **P2P media** — no server media path for 1:1; cheap and private. +- **HMAC-signed webhooks** and **audit log** — right primitives, just need to scale out. +- **Team-scoped queries** — the seed of multi-tenancy is present. + +### Structural constraints that block the roadmap +| # | Constraint | Blocks | +|---|-----------|--------| +| C1 | Auth is **cookie-only** (`parseCookies(req).sid`); no `Authorization: Bearer`, no API keys | Mobile, Integrations | +| C2 | **No API versioning** (`/api/...`) | Mobile (shipped clients pin a contract) | +| C3 | **`team` is a thin tenant** `(id,name,created_at)`; app assumes one team | Licensing | +| C4 | **Session state in process memory** (Maps) | Horizontal scale, Meetings | +| C5 | **SQLite single-writer**, queries inline at ~100 call-sites | Scale, multi-tenant isolation | +| C6 | **P2P mesh only** — no SFU | Multi-party meetings (Zoom-like) | +| C7 | **Recordings on local disk** | Multi-instance, per-org storage quotas | +| C8 | **Monolithic `server.js`** mixes HTTP/WS/static/logic/DB | All (maintainability) | + +--- + +## 2. Target architecture (principles) + +1. **Organization is the top-level tenant.** Every row and every request resolves to an + `org_id`. Billing, seats, plan, and feature flags hang off the Organization. +2. **Data access goes through a repository layer**, never raw SQL in route handlers. + This is what makes the SQLite→Postgres migration and strict tenant-scoping feasible. +3. **The API is versioned and token-addressable.** `/api/v1`; auth accepted via cookie + (web) *or* `Authorization: Bearer` (mobile) *or* scoped API key (integrations). +4. **Shared runtime state lives outside the process** (Redis) so the app can run N instances. +5. **Multi-party media uses an SFU** (LiveKit/mediasoup); 1:1 may stay P2P. +6. **Entitlements are enforced centrally** — one middleware checks plan limits before + privileged actions (add seat, start meeting, record, call the API). + +``` + ┌──────────── clients ────────────┐ + │ web (HTML) mobile (native) 3rd-party (API key) │ + └───────┬───────────┬───────────────┬──────────────┘ + │ cookie │ Bearer │ API key + ┌───────▼───────────▼───────────────▼──────────────┐ + │ API v1 (routes → services → repository) │ + │ authN (cookie/Bearer/key) · authZ (RBAC) │ + │ entitlements middleware (plan/seat/feature) │ + └───────┬───────────────────────┬──────────────────┘ + │ │ + ┌─────────▼────────┐ ┌─────────▼─────────┐ + │ Repository layer │ │ Signaling (ws) │──── Redis (shared state, + │ (SQLite→Postgres)│ │ + SFU for meetings│ pub/sub across instances) + └─────────┬────────┘ └───────────────────┘ + │ + Postgres · Object storage (recordings) · usage-metering +``` + +--- + +## 3. Goal-by-goal requirements + +### Goal 1 — Native Android/iOS +- **Bearer-token auth** (C1) + refresh tokens; device registration. +- **`/api/v1`** (C2) — stable, documented contract. +- **Push notifications** (APNs/FCM) for incoming sessions/calls (mobile can't hold a background WS). +- Reuse WebRTC/`/ws` via `react-native-webrtc`/native SDKs; native screen capture + (ReplayKit / MediaProjection) for the phone-can't-share gap. + +### Goal 2 — Third-party integration +- **Scoped API keys / OAuth2 client-credentials** per org (C1). +- **Webhook subscriptions** per org: multiple endpoints, event types, signed payloads, retries. +- **OIDC/JWT SSO** to replace the custom HMAC `/sso`. +- Optional: embeddable JS widget / SDK. + +### Goal 3 — Org licensing (Zoom-like) +- **Organization** entity (C3): `plan`, `seats`, `status`, `trial_ends_at`, feature flags. +- **Entitlements + metering**: tables for plan limits and usage (minutes, sessions, storage); + central enforcement middleware. +- **SFU** for multi-party meetings (C6); per-org concurrent-meeting / minute caps. +- **Redis shared state** (C4) for multi-instance; **Postgres** (C5); **object storage** (C7). +- Billing provider integration (e.g. Stripe) driving subscription state. + +--- + +## 4. Phased plan + +> **Priority (set by the user 2026-06-11): mobile + integration first; licensing last.** +> Principle unchanged: do the shared groundwork first, so later work is additive. Because +> licensing is last, the full **Organization** entity moves to Phase 3 with it — Phase 1 keeps +> only a *tenant-id abstraction* (mapping to today's `team_id`) so Phase-2 auth/keys don't need +> reworking when the tenant is later elevated to a full Organization. + +### Phase 1 — Foundations (structural, no behavior change) ✅ DONE (2026-06-11) +- [x] Extracted a **data-access layer** (`repos.js`) — all SQL moved out of `server.js`. +- [x] **Modularized** `server.js` → `config / lib / session / presence / routes / static / signaling` + (plus `repos` data layer and `bizgaze` service). `server.js` is now a thin entry point. +- [x] Standardized a **tenant id** in the data layer (repo params named `tenantId`, == `team_id` today); + every query is tenant-scoped. *No Organization entity / plan-seats yet — that's Phase 3.* +- Verified behavior-preserving by `test/e2e.js` (21/21) before and after. + +### Phase 2 — API + access ← **PRIORITY** (mobile + desktop + integrations) +Target clients: web (cookie), native **Android/iOS**, a native **Windows desktop app where the +viewer CONTROLS the remote screen** (reuses the WebRTC `inputChannel` + OS input injection like +`agent/`), and third-party systems (API keys). All authenticate through this one access layer. +- [x] **`/api/v1`** — every `/api/*` route aliased under `/api/v1/*` (routes.js); web keeps unversioned paths. +- [x] **`Authorization: Bearer `** accepted in `currentUser()` across HTTP + WS, alongside the + cookie (session.js `tokenFromReq`); `/api/login` now also returns the `token` for native clients. + WS upgrades carry the token in the Authorization header (native) or `?access_token=` (browser fallback). +- [ ] **Refresh tokens** (short access token + long refresh) so native apps stay signed in safely. +- [ ] **API keys** table + middleware (scoped per *tenant*, hashed at rest). +- [ ] **Push-notification hooks** (APNs/FCM) for incoming sessions/calls on mobile. +- [ ] **OIDC/JWT** SSO; per-tenant **webhook subscriptions** with retries. + +### Phase 3 — Licensing + scale (last, per priority) +- [ ] Elevate **tenant → `organization`** (additive migration: add `organizations`, keep `team_id` + as alias/FK); add `plan`, `seats`, `status`, `features` columns. +- [ ] **Entitlements** module + central enforcement; **usage metering** (minutes/sessions/storage). +- [ ] **SFU** (LiveKit/mediasoup) for multi-party meetings; keep 1:1 P2P. +- [ ] **Redis** for `liveSessions/onlineAgents/pendingShares` + cross-instance pub/sub. +- [ ] **Postgres** migration (enabled by the Phase-1 data-access layer); **object storage** (S3) for recordings. +- [ ] Billing provider + subscription lifecycle webhooks. + +### Explicitly NOT yet +- Don't add an SFU or Postgres before the data-access layer exists — you'd rework them. +- Don't build the full Organization/plan model before Phase 2 ships — but *do* keep the tenant-id + abstraction consistent from Phase 1 so the elevation is additive. +- Don't shard or add microservices; a well-modularized monolith + Redis + Postgres scales far enough. + +--- + +## 5. Key decisions to confirm (when we reach them) +- **Auth tokens:** opaque (DB-backed, easy revoke) vs JWT (stateless, harder revoke). *Lean: opaque + access + refresh, since `sessions_auth` already works that way.* +- **SFU:** LiveKit (batteries-included, good mobile SDKs) vs mediasoup (lower-level, more control). +- **DB:** Postgres (recommended) — keep the repository layer DB-agnostic until the cutover. +- **Billing:** Stripe vs BizGaze's own billing (depends on how Connect sits inside the BizGaze suite). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3f18415 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,134 @@ +# BizGaze Connect — project brief + +Place this file at the repo root (`remote-access-app/CLAUDE.md`). Claude Code reads +it automatically each session. + +## What this is +**BizGaze Connect** — a no-install, browser-based remote support / screen-sharing +tool for the BizGaze ecosystem. A customer opens a page, gets a 6-digit code; a +signed-in BizGaze agent enters the code, the customer taps Allow, and the agent +sees the customer's screen with two-way voice + chat. Live at **remote.bizgaze.com**. +Roadmap: grow into a communication platform (meetings + persistent chat) for +registered BizGaze users. + +## Tech stack (intentionally minimal — keep it this way) +- **Node.js >= 22.5**, single npm dependency: `ws` (WebSocket). +- **Built-in `node:sqlite`** (no native modules). DB file: `server/data.db`. +- **WebRTC** peer-to-peer for media (screen video + voice + data channels). +- **No build step, no framework.** Each page is a single self-contained HTML file + with inline ` + +← Home
BizGaze Support
@@ -60,7 +71,10 @@ const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|M let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers&&IS_MOBILE)ICE=c;}).catch(()=>{});}catch(_){} async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;} function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));} -function profileHTML(name){return '
';} +// When embedded in the home shell, tell the parent when a session is live so the +// rail can show a "return here" indicator. +function bzcSession(active){try{if(window.parent&&window.parent!==window)window.parent.postMessage({type:'bzc-session',flow:'connect',active:!!active},location.origin);}catch(_){}} +function profileHTML(name){return '
';} function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};} function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});} makeBrandClickable(); @@ -99,13 +113,14 @@ function renderLogin(){ Password
-
`; +
`; { const doSignIn=async()=>{ + const errEl=document.getElementById('err'); errEl.textContent=''; errEl.classList.remove('show'); try{ await api('/api/login',{email:document.getElementById('email').value,password:document.getElementById('pw').value,remember:(document.getElementById('rememberMe')||{}).checked||false}); me=await api('/api/me',null,'GET'); renderAgent(); - }catch(e){ document.getElementById('err').textContent=e.message; } + }catch(e){ errEl.textContent=/invalid credentials/i.test(e.message)?'Incorrect email or password. Please try again.':e.message; errEl.classList.add('show'); } }; document.getElementById('loginBtn').onclick=doSignIn; onEnter(['email','pw'], doSignIn); @@ -173,11 +188,13 @@ function renderWaiting(){ } function renderEnded(msg){ + bzcSession(false); try{ stopRecording(); }catch(_){} removeSessionUI(); if(pc){ try{pc.close();}catch(e){} pc=null; } video.style.display='none'; bar.classList.remove('show'); topbar.style.display='flex'; wrap.style.display='grid'; + { const hl=document.getElementById('homeLink'); if(hl && !document.documentElement.classList.contains('embed')) hl.style.display=''; } card.innerHTML=`

Session ended

${esc(msg)}
@@ -251,12 +268,17 @@ function startRecording(){ const mixed=new MediaStream(); mixed.addTrack(remote.getVideoTracks()[0]); dest.stream.getAudioTracks().forEach(t=>mixed.addTrack(t)); - let mime='video/webm;codecs=vp8,opus'; if(!(window.MediaRecorder&&MediaRecorder.isTypeSupported(mime))) mime='video/webm'; + // Prefer MP4 (H.264/AAC) — playable by most tools (Windows Media Player, QuickTime, + // WhatsApp, etc.). Fall back to WebM only if the browser can't record MP4. + const REC_TYPES=['video/mp4;codecs=avc1.42E01E,mp4a.40.2','video/mp4;codecs=h264,aac','video/mp4','video/webm;codecs=vp8,opus','video/webm']; + let mime='video/webm'; for(const t of REC_TYPES){ if(window.MediaRecorder&&MediaRecorder.isTypeSupported(t)){ mime=t; break; } } + const recExt = mime.indexOf('mp4')!==-1 ? 'mp4' : 'webm'; + const recBlobType = mime.indexOf('mp4')!==-1 ? 'video/mp4' : 'video/webm'; recChunks=[]; mediaRecorder=new MediaRecorder(mixed,{mimeType:mime}); mediaRecorder.ondataavailable=(e)=>{ if(e.data&&e.data.size) recChunks.push(e.data); }; mediaRecorder.onstop=async()=>{ - try{ const blob=new Blob(recChunks,{type:'video/webm'}); if(blob.size) await fetch('/api/recording?sessionId='+encodeURIComponent(sessionId),{method:'POST',headers:{'Content-Type':'video/webm'},body:blob}); }catch(_){} + try{ const blob=new Blob(recChunks,{type:recBlobType}); if(blob.size) await fetch('/api/recording?sessionId='+encodeURIComponent(sessionId)+'&ext='+recExt,{method:'POST',headers:{'Content-Type':recBlobType},body:blob}); }catch(_){} try{recCtx&&recCtx.close();}catch(_){} recCtx=null; }; mediaRecorder.start(1000); @@ -277,6 +299,8 @@ function stopRecording(){ function _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.innerHTML=''+svg+''+label+'';b.style.cssText='display:inline-flex;align-items:center;gap:7px;border:none;border-radius:12px;padding:.62rem 1rem;font-weight:600;font-size:.9rem;cursor:pointer;color:#fff;background:'+bg+';transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-1px)';b.onmouseleave=()=>b.style.transform='none';return b;} function buildBar(){ if(document.getElementById('sessionBar'))return; + { const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; } + bzcSession(true); const bar=document.createElement('div'); bar.id='sessionBar'; bar.style.cssText='position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:2147483000;display:flex;gap:10px;align-items:center;background:rgba(15,23,42,.94);padding:8px 12px;border-radius:16px;box-shadow:0 10px 28px rgba(0,0,0,.35)'; const mic=_btn('micBtn',SVG_MIC,'Mic','#2563eb'); diff --git a/server/public/console.html b/server/public/dashboard.html similarity index 63% rename from server/public/console.html rename to server/public/dashboard.html index 54a4019..524c946 100644 --- a/server/public/console.html +++ b/server/public/dashboard.html @@ -3,7 +3,7 @@ -BizGaze Support — Staff Console +BizGaze Connect — Dashboard ' + - '

BizGaze Support — Session report

' + - '
Agent: ' + esc(agentSel) + ' · Period: ' + esc(period) + ' · Generated ' + new Date().toLocaleString() + '
' + - '' + - rows.map(r => '').join('') + + '

BizGaze Connect — Connection report

' + + '
' + esc(IS_ADMIN ? 'Agent: ' + agentSel : 'Agent: ' + agentSel) + ' · Period: ' + esc(period) + ' · Generated ' + new Date().toLocaleString() + '
' + + '
DateStart timeAgentTicketTime spent
' + [r.date, r.start, esc(r.agent), esc(r.ticket), r.spent].join('') + '
' + headCells.map(h => '').join('') + '' + + rows.map(r => '').join('') + '
' + esc(h) + '
' + [r.date, r.start].concat(IS_ADMIN ? [esc(r.agent)] : []).concat([esc(r.ticket), r.spent]).join('') + '
' + esc(repSummary.textContent) + '
'); w.document.close(); w.onload = () => { w.print(); }; @@ -393,9 +338,10 @@ function exportPdf() { function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } // ---------- Boot ---------- +// Login lives on /home — send logged-out visitors there. (async function () { try { const me = await api('/api/me', null, 'GET'); dashboard(me); } - catch { authView(); } + catch { location.href = '/home'; } })(); diff --git a/server/public/home-mockup.html b/server/public/home-mockup.html new file mode 100644 index 0000000..16207b5 --- /dev/null +++ b/server/public/home-mockup.html @@ -0,0 +1,277 @@ + + + + + +BizGaze Connect — Home + + + +
+
+ +
BizGaze Connect · Home
+
+
+
+ +
+ + + + +
+
+ + + +
+ +
+ +
+
+
+ +
+ COMING SOON +

Meetings are on the way

+

Soon you'll be able to host multi-party video meetings with your BizGaze team and customers — right here, no install needed. We're putting on the finishing touches.

+ +
In the meantime, use Share Screen or Connect Screen to start a session.
+
+
+ + +
+
+
+ +
+

Share your screen

+

Let a teammate or customer see your screen instantly. You'll get a 6-digit code to share — they enter it to connect. No download, works right in the browser.

+ Start sharing → +
Desktop browsers only — phones can't share their screen yet.
+
+
+ + +
+
+
+ +
+

Connect to a screen

+

Helping someone out? Enter the 6-digit code they give you to view their screen and provide live support — with two-way voice and chat built in.

+ Open connect page → +
The other person taps Allow before you can see anything.
+
+
+
+
+
+ + + + diff --git a/server/public/home.html b/server/public/home.html new file mode 100644 index 0000000..a6ef601 --- /dev/null +++ b/server/public/home.html @@ -0,0 +1,478 @@ + + + + + +BizGaze Connect + + + +
Loading…
+ +
+
+ +
BizGaze Connect
+
+
+
+ +
+ + + + + + + +
+ +
+ + +
+ + +
+ + +
+
+
+ +
+ COMING SOON +

Meetings are on the way

+

Soon you'll be able to host multi-party video meetings with your BizGaze team and customers — right here, no install needed.

+ +
In the meantime, use Share Screen or Connect Screen from the left.
+
+
+
+
+ +
+ +
+ + + + diff --git a/server/public/index.html b/server/public/index.html index 8634bd0..45c5976 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -17,14 +17,19 @@ .inner{max-width:780px;width:100%;text-align:center;} h1{color:var(--blue);font-size:1.8rem;margin:0 0 .4rem;} .sub{color:var(--muted);margin-bottom:2.2rem;} + .ssobtn{display:inline-flex;align-items:center;justify-content:center;gap:.6rem;background:var(--blue);color:#fff;text-decoration:none;font-weight:700;font-size:1.02rem;padding:.95rem 2rem;border-radius:12px;box-shadow:0 10px 26px rgba(31,59,115,.28);transition:transform .12s,box-shadow .12s,background .12s;} + .ssobtn:hover{transform:translateY(-2px);box-shadow:0 16px 34px rgba(31,59,115,.34);background:var(--blue-d);} + .ssobtn .bmark{width:26px;height:26px;border-radius:7px;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.9rem;} + .divider{display:flex;align-items:center;gap:1rem;color:var(--muted);font-size:.85rem;max-width:360px;margin:1.8rem auto;} + .divider::before,.divider::after{content:"";flex:1;height:1px;background:var(--line);} .choices{display:flex;gap:1.4rem;flex-wrap:wrap;justify-content:center;} - .choice{flex:1;min-width:260px;max-width:340px;background:#fff;border:1px solid var(--line);border-radius:18px;padding:2.2rem 1.6rem;text-decoration:none;color:var(--ink);box-shadow:0 10px 30px rgba(20,30,60,.06);transition:transform .12s,box-shadow .12s;} + .choice{flex:1;min-width:260px;max-width:360px;background:#fff;border:1px solid var(--line);border-radius:18px;padding:1.8rem 1.6rem;text-decoration:none;color:var(--ink);box-shadow:0 10px 30px rgba(20,30,60,.06);transition:transform .12s,box-shadow .12s,border-color .12s;display:flex;align-items:center;gap:1.1rem;text-align:left;} .choice:hover{transform:translateY(-3px);box-shadow:0 16px 38px rgba(20,30,60,.12);border-color:var(--brand);} - .icon{width:66px;height:66px;border-radius:18px;display:grid;place-items:center;margin:0 auto 1.1rem;} + .icon{width:56px;height:56px;flex:0 0 56px;border-radius:16px;display:grid;place-items:center;} .icon.share{background:#FFF7DA;} .icon.connect{background:var(--blue-soft);} - .icon svg{width:34px;height:34px;} - .choice h3{margin:.2rem 0 .4rem;color:var(--blue);font-size:1.15rem;} - .choice p{margin:0;color:var(--muted);font-size:.9rem;line-height:1.5;} + .icon svg{width:30px;height:30px;} + .choice h3{margin:0 0 .25rem;color:var(--blue);font-size:1.1rem;} + .choice p{margin:0;color:var(--muted);font-size:.88rem;line-height:1.45;} .foot{color:var(--muted);font-size:.82rem;margin-top:2.4rem;} footer{text-align:center;color:#9aa3b2;font-size:.8rem;padding:1rem;} .profile{position:relative} @@ -43,35 +48,38 @@
BizGaze Support
-
+
-

How can we help you today?

-
Secure remote support — no downloads, you stay in control.
-
+

Welcome to BizGaze Connect

+
Chat, meetings and secure remote support — for the BizGaze ecosystem.
+ + B Log in with BizGaze +
need support? no account required
+ -
🔒 Screen sharing only starts after the customer approves it, and can be stopped anytime.
+
🔒 Screen sharing only starts after you approve it, and can be stopped anytime.
© BizGaze · Remote Support
diff --git a/server/public/share.html b/server/public/share.html index 662e7bc..fc24557 100644 --- a/server/public/share.html +++ b/server/public/share.html @@ -32,6 +32,10 @@ .foot{color:var(--muted);font-size:.8rem;margin-top:1.4rem;} .indicator{position:fixed;top:0;left:0;right:0;background:#dc2626;color:#fff;text-align:center;padding:.5rem;font-size:.9rem;display:none;font-weight:600;z-index:9;} .indicator.show{display:block;} + /* Embedded inside the home shell: hide own chrome (the shell provides it). */ + html.embed .brandpanel{display:none!important;} + html.embed #homeLink{display:none!important;} + html.embed .panelside{flex:1;} @media(max-width:860px){ .stage{flex-direction:column;} .brandpanel{padding:2rem;min-height:auto;} .mark{width:60px;height:60px;border-radius:16px;font-size:1.8rem;margin-bottom:.7rem;} .wordmark{font-size:1.5rem;} .tagline{display:none;} } .profile{position:relative} .profile .pbtn{display:flex;align-items:center;gap:.4rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.45rem .85rem;font-weight:600;font-size:.88rem;cursor:pointer} @@ -44,6 +48,7 @@ +
● Your screen is being shared — close this tab anytime to stop
← Home
@@ -77,7 +82,10 @@ const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|M let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers&&IS_MOBILE)ICE=c;}).catch(()=>{});}catch(_){} async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;} function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));} -function profileHTML(name){return '
';} +// When embedded in the home shell, tell the parent when a session is live so the +// rail can show a "return here" indicator. +function bzcSession(active){try{if(window.parent&&window.parent!==window)window.parent.postMessage({type:'bzc-session',flow:'share',active:!!active},location.origin);}catch(_){}} +function profileHTML(name){return '
';} function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};} function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});} makeBrandClickable(); @@ -151,7 +159,7 @@ async function startStreaming(){ if(mic){ window.__mic=mic; try{mic.getAudioTracks().forEach(t=>localStream.addTrack(t));}catch(_){} } } await ensureIce(); - indicator.classList.add('show'); setStatus('You are now sharing your screen with your agent.','on'); + indicator.classList.add('show'); setStatus('You are now sharing your screen with your agent.','on'); bzcSession(true); { const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; } window.onbeforeunload=function(){ if((localStream||document.getElementById('sessionBar'))&&!sessionOver){ return 'Leaving or refreshing this page will end your screen sharing session.'; } }; pc=new RTCPeerConnection(ICE); @@ -198,7 +206,7 @@ function recNotice(on){ } else { clearInterval(recTimerInt); recTimerInt=null; if(n) n.remove(); } } function endShareSession(msgText){ - sessionOver=true; window.onbeforeunload=null; { const hl=document.getElementById('homeLink'); if(hl) hl.style.display=''; } try{recNotice(false);stopCustTranscription();}catch(_){} + sessionOver=true; window.onbeforeunload=null; bzcSession(false); { const hl=document.getElementById('homeLink'); if(hl) hl.style.display=''; } try{recNotice(false);stopCustTranscription();}catch(_){} removeSessionUI(); indicator.classList.remove('show'); if(window.__mic){try{window.__mic.getTracks().forEach(t=>t.stop());}catch(_){}window.__mic=null;} @@ -207,7 +215,7 @@ function endShareSession(msgText){ var card=document.querySelector('.panelside .card'); if(card){ card.innerHTML='

Session ended

'+esc(msgText||'The session has ended.')+'
'; } } -function teardown(){sessionOver=true;window.onbeforeunload=null;{const hl=document.getElementById('homeLink');if(hl)hl.style.display='';}try{recNotice(false);stopCustTranscription();}catch(_){}indicator.classList.remove('show');removeSessionUI();if(window.__mic){window.__mic.getTracks().forEach(t=>t.stop());window.__mic=null;}if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null;}if(pc){pc.close();pc=null;}consentBox.innerHTML='';setStatus('Session ended. Refresh this page to get a new code.');} +function teardown(){sessionOver=true;window.onbeforeunload=null;bzcSession(false);{const hl=document.getElementById('homeLink');if(hl)hl.style.display='';}try{recNotice(false);stopCustTranscription();}catch(_){}indicator.classList.remove('show');removeSessionUI();if(window.__mic){window.__mic.getTracks().forEach(t=>t.stop());window.__mic=null;}if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null;}if(pc){pc.close();pc=null;}consentBox.innerHTML='';setStatus('Session ended. Refresh this page to get a new code.');} let chatOpen=false; const SVG_MIC=''; diff --git a/server/repos.js b/server/repos.js new file mode 100644 index 0000000..1e09678 --- /dev/null +++ b/server/repos.js @@ -0,0 +1,103 @@ +// Data-access layer (Phase 1). +// All SQL lives here, never in route/signaling handlers. This decouples the rest of +// the app from SQLite so the store can later move to Postgres without touching callers. +// +// TENANT ABSTRACTION: a "tenant" currently maps 1:1 to a team (column `team_id`). +// Repo signatures take `tenantId` so that when the tenant is later elevated to a +// first-class Organization (Phase 3), callers and the API/auth built on top stay unchanged. +const db = require('./db'); +const A = require('./auth'); +const now = () => Date.now(); + +const teams = { + first: () => db.prepare('SELECT * FROM teams LIMIT 1').get(), + byId: (id) => db.prepare('SELECT * FROM teams WHERE id=?').get(id), + create: (name) => { + const id = A.id(); + db.prepare('INSERT INTO teams (id,name,created_at) VALUES (?,?,?)').run(id, name, now()); + return db.prepare('SELECT * FROM teams WHERE id=?').get(id); + }, +}; + +const users = { + anyExists: () => !!db.prepare('SELECT 1 FROM users LIMIT 1').get(), + byId: (id) => db.prepare('SELECT * FROM users WHERE id=?').get(id), + byEmail: (email) => db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email), + emailExists: (email) => !!db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email), + listByTenant: (tenantId) => + db.prepare('SELECT id,email,name,role,active,created_at FROM users WHERE team_id=?').all(tenantId), + inTenant: (id, tenantId) => + db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, tenantId), + create: ({ tenantId, email, hash, salt, role, name, mfaSecret }) => { + const id = A.id(); + db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at) + VALUES (?,?,?,?,?,?,?,?,0,?)`) + .run(id, tenantId, email, hash, salt, role, name || null, mfaSecret, now()); + return id; + }, + enableMfa: (id) => db.prepare('UPDATE users SET mfa_enabled=1 WHERE id=?').run(id), + setName: (id, name) => db.prepare('UPDATE users SET name=? WHERE id=?').run(name, id), + setPassword: (id, hash, salt) => db.prepare('UPDATE users SET pw_hash=?, pw_salt=? WHERE id=?').run(hash, salt, id), + setActive: (id, active) => db.prepare('UPDATE users SET active=? WHERE id=?').run(active ? 1 : 0, id), + remove: (id) => db.prepare('DELETE FROM users WHERE id=?').run(id), +}; + +const authSessions = { + byToken: (token) => db.prepare('SELECT * FROM sessions_auth WHERE token=?').get(token), + create: ({ token, userId, mfaPassed, ttl }) => + db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,?,?,?)') + .run(token, userId, mfaPassed ? 1 : 0, now(), now() + ttl), + markMfaPassed: (token) => db.prepare('UPDATE sessions_auth SET mfa_passed=1 WHERE token=?').run(token), + deleteByToken: (token) => db.prepare('DELETE FROM sessions_auth WHERE token=?').run(token), + deleteByUser: (userId) => db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(userId), +}; + +const machines = { + byEnrollToken: (t) => db.prepare('SELECT * FROM machines WHERE enroll_token=?').get(t), + inTenant: (id, tenantId) => db.prepare('SELECT * FROM machines WHERE id=? AND team_id=?').get(id, tenantId), + listByTenant: (tenantId) => + db.prepare('SELECT id,name,unattended,last_seen FROM machines WHERE team_id=?').all(tenantId), + create: ({ tenantId, name, enrollToken, unattended }) => { + const id = A.id(); + db.prepare('INSERT INTO machines (id,team_id,name,enroll_token,unattended,created_at) VALUES (?,?,?,?,?,?)') + .run(id, tenantId, name, enrollToken, unattended ? 1 : 0, now()); + return id; + }, + touch: (id) => db.prepare('UPDATE machines SET last_seen=? WHERE id=?').run(now(), id), +}; + +const audit = { + add: (e) => + 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: e.team_id, user_id: e.user_id || null, user_email: e.user_email || null, + machine_id: e.machine_id || null, machine_name: e.machine_name || null, + action: e.action, detail: e.detail || null, at: now(), + }), + listByTenant: (tenantId) => + db.prepare("SELECT * FROM audit_log WHERE team_id=? OR team_id='adhoc' ORDER BY at DESC LIMIT 200").all(tenantId), +}; + +const sessionsLog = { + byId: (id) => db.prepare('SELECT * FROM sessions_log WHERE id=?').get(id), + byIdInTenant: (id, tenantId) => db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(id, tenantId), + create: ({ id, tenantId, agentEmail, agentName, ticket }) => + db.prepare('INSERT INTO sessions_log (id,team_id,agent_email,agent_name,ticket,started_at) VALUES (?,?,?,?,?,?)') + .run(id, tenantId, agentEmail, agentName, ticket || null, now()), + end: (id) => db.prepare('UPDATE sessions_log SET ended_at=? WHERE id=? AND ended_at IS NULL').run(now(), id), + setRecording: (id, fname) => db.prepare('UPDATE sessions_log SET recording=? WHERE id=?').run(fname, id), + setTranscript: (id, fname) => db.prepare('UPDATE sessions_log SET transcript=? WHERE id=?').run(fname, id), + // Role-scoping is the caller's job: pass agentEmail to restrict to one agent (non-admins). + report: ({ tenantId, agentEmail, from, to }) => { + let sql = 'SELECT * FROM sessions_log WHERE team_id=?'; + const args = [tenantId]; + if (agentEmail) { sql += ' AND agent_email=?'; args.push(agentEmail); } + if (from) { sql += ' AND started_at>=?'; args.push(from); } + if (to) { sql += ' AND started_at<=?'; args.push(to); } + sql += ' ORDER BY started_at DESC LIMIT 500'; + return db.prepare(sql).all(...args); + }, +}; + +module.exports = { teams, users, authSessions, machines, audit, sessionsLog }; diff --git a/server/routes.js b/server/routes.js new file mode 100644 index 0000000..1d29922 --- /dev/null +++ b/server/routes.js @@ -0,0 +1,339 @@ +// 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). +function provisionFromBizgaze(email, bz) { + 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 role = bz.isAdmin ? 'admin' : 'technician'; + 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); + } + if (bz.name && bz.name !== existing.name) R.users.setName(existing.id, bz.name); + 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. +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 = (existing && A.verifyPassword(password, existing.pw_salt, existing.pw_hash)) ? existing : null; + if (!u) { + const bz = await BZ.validateLogin(email, password); + if (bz.ok) u = provisionFromBizgaze(email, bz); + else if (bz.error) return json(res, 503, { error: bz.error }); + } + if (!u) return json(res, 401, { error: 'invalid credentials' }); + + 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 managed TURN if +// configured via env (TURN_URLS comma-separated, TURN_USERNAME, TURN_CREDENTIAL). +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 = 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' }); + 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; diff --git a/server/server.js b/server/server.js index 789fe70..c5474a1 100644 --- a/server/server.js +++ b/server/server.js @@ -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}`); }); diff --git a/server/session.js b/server/session.js new file mode 100644 index 0000000..2a4879a --- /dev/null +++ b/server/session.js @@ -0,0 +1,38 @@ +// Session/auth helpers: resolve the current user from the cookie, write audit rows. +const R = require('./repos'); +const { parseCookies, now } = require('./lib'); + +function audit(entry) { + R.audit.add(entry); +} + +// Resolve the session token from a request, supporting every client transport: +// - `Authorization: Bearer ` → native desktop/mobile apps (HTTP + WS upgrade) +// - `sid` cookie → the web app (HTTP + same-origin WS) +// - `?access_token=`/`?token=` query → browser WS fallback when a cookie isn't usable +// All three resolve to the same opaque token in `sessions_auth`. +function tokenFromReq(req) { + const h = req.headers && (req.headers.authorization || req.headers.Authorization); + if (h && /^Bearer\s+/i.test(h)) return h.replace(/^Bearer\s+/i, '').trim(); + const cookieTok = parseCookies(req).sid; + if (cookieTok) return cookieTok; + try { + const qs = (req.url || '').split('?')[1]; + if (qs) { const t = new URLSearchParams(qs).get('access_token') || new URLSearchParams(qs).get('token'); if (t) return t; } + } catch (_) {} + return null; +} + +// Resolve the logged-in user from the request. Returns user row (with mfa state) or null. +function currentUser(req, { requireMfa = true } = {}) { + const tok = tokenFromReq(req); + if (!tok) return null; + const s = R.authSessions.byToken(tok); + if (!s || s.expires_at < now()) return null; + if (requireMfa && !s.mfa_passed) return null; + const u = R.users.byId(s.user_id); + if (!u || u.active === 0) return null; + return { ...u, _session: s }; +} + +module.exports = { audit, currentUser, tokenFromReq }; diff --git a/server/signaling.js b/server/signaling.js new file mode 100644 index 0000000..4f4bcb5 --- /dev/null +++ b/server/signaling.js @@ -0,0 +1,173 @@ +// 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 }; diff --git a/server/static.js b/server/static.js new file mode 100644 index 0000000..3f09342 --- /dev/null +++ b/server/static.js @@ -0,0 +1,72 @@ +// Static file serving + authenticated recording/transcript downloads. +// handleGet() is the fallback for any GET that didn't match an API route. +const fs = require('fs'); +const path = require('path'); +const R = require('./repos'); +const { json } = require('./lib'); +const { currentUser } = require('./session'); +const { PUBLIC_DIR, REC_DIR, TRANS_DIR } = require('./config'); + +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 === '/home') p = '/home.html'; + // Console was replaced by Dashboard; keep the old path working. + if (p === '/console' || p === '/dashboard') p = '/dashboard.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); + }); +} + +// GET fallback: authenticated transcript/recording downloads, else static files. +function handleGet(req, res) { + const pathOnly = req.url.split('?')[0]; + if (pathOnly.startsWith('/transcripts/')) { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const name = path.basename(decodeURIComponent(pathOnly)); + const sid = name.replace(/\.txt$/i, ''); + const row = R.sessionsLog.byIdInTenant(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 (pathOnly.startsWith('/recordings/')) { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const name = path.basename(decodeURIComponent(pathOnly)); + const sid = name.replace(/\.(webm|mp4)$/i, ''); + const row = R.sessionsLog.byIdInTenant(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' }); + const ext = path.extname(row.recording).toLowerCase() === '.mp4' ? 'mp4' : 'webm'; + const ctype = ext === 'mp4' ? 'video/mp4' : 'video/webm'; + return fs.stat(fp, (err, st) => { + if (err) return json(res, 404, { error: 'not found' }); + res.writeHead(200, { 'Content-Type': ctype, 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="session-' + sid + '.' + ext + '"', 'Cache-Control': 'no-store', 'Accept-Ranges': 'bytes' }); + const rs = fs.createReadStream(fp); + rs.on('error', () => { try { res.destroy(); } catch (e) {} }); + rs.pipe(res); + }); + } + return serveStatic(req, res); +} + +module.exports = { handleGet, serveStatic }; diff --git a/server/test/e2e.js b/server/test/e2e.js index 210dcd1..ad09013 100644 --- a/server/test/e2e.js +++ b/server/test/e2e.js @@ -1,15 +1,20 @@ // End-to-end test of the backend platform. -// Exercises the full flow: register -> enable MFA -> login (2 steps) -> -// enroll machine -> agent comes online -> technician requests session -> -// consent -> signaling relay -> audit trail. No browser/Electron needed: -// the "agent" and "viewer" are raw WebSocket clients. +// Exercises the full flow: register -> login -> enroll machine -> agent online -> +// technician requests session -> consent -> signaling relay -> audit trail. +// No browser/Electron needed: the "agent" and "viewer" are raw WebSocket clients. +// (Login currently marks the session MFA-passed directly, so there is no separate +// TOTP step in the product flow; the MFA endpoints still exist but aren't exercised here.) -process.env.DB_PATH = '/tmp/ra-e2e.db'; const fs = require('fs'); -for (const f of ['/tmp/ra-e2e.db', '/tmp/ra-e2e.db-wal', '/tmp/ra-e2e.db-shm']) { try { fs.unlinkSync(f); } catch {} } +const os = require('os'); +const path = require('path'); +const DB = path.join(os.tmpdir(), 'ra-e2e.db'); +process.env.DB_PATH = DB; +for (const f of [DB, DB + '-wal', DB + '-shm']) { try { fs.unlinkSync(f); } catch {} } const PORT = 8099; process.env.PORT = PORT; +process.env.HTTPS_PORT = 8444; // avoid clashing with a running dev server on 8443 const { server } = require('../server'); const A = require('../auth'); const WebSocket = require('ws'); @@ -59,38 +64,21 @@ function nextMsg(ws, type, timeout = 3000) { await wait(300); // let server bind console.log('E2E backend tests:'); - // 1. Register + // 1. Register (first user becomes admin) const email = 'tech@example.com'; const reg = await call('/api/register', { email, password: 'supersecret', teamName: 'Acme IT' }); - check('register returns mfa setup', reg.status === 200 && reg.data.mfaSetup && reg.data.mfaSetup.secret); - const secret = reg.data.mfaSetup.secret; + check('register succeeds', reg.status === 200 && reg.data.ok === true); - // 2. Login before MFA enabled — allowed, mfaRequired=false - let login = await call('/api/login', { email, password: 'supersecret' }); + // 2. Login -> session cookie (login marks the session MFA-passed) + const login = await call('/api/login', { email, password: 'supersecret' }); check('login sets session cookie', !!login.cookie); + const cookie = login.cookie; - // 3. Enable MFA with a valid TOTP - const enable = await call('/api/mfa/enable', { email, code: A.totp(secret) }); - check('mfa enable succeeds with valid code', enable.status === 200); - const badEnable = await call('/api/mfa/enable', { email, code: '000000' }); - check('mfa enable rejects bad code', badEnable.status === 401); - - // 4. Fresh login now requires MFA - login = await call('/api/login', { email, password: 'supersecret' }); - check('login now flags mfaRequired', login.data.mfaRequired === true); - let cookie = login.cookie; - - // 5. Protected route blocked until MFA passed - const meBlocked = await get('/api/me', cookie); - check('me blocked before mfa', meBlocked.status === 401); - - // 6. Pass MFA - const mfa = await call('/api/login/mfa', { code: A.totp(secret) }, cookie); - check('login mfa step succeeds', mfa.status === 200); + // 3. Protected route works right after login, role=admin const me = await get('/api/me', cookie); - check('me works after mfa, role=admin', me.status === 200 && me.data.role === 'admin'); + check('me works after login, role=admin', me.status === 200 && me.data.role === 'admin'); - // 7. Wrong password rejected + // 4. Wrong password rejected const badLogin = await call('/api/login', { email, password: 'wrong' }); check('wrong password rejected', badLogin.status === 401);