// Proxy REST: /api/nw/v1/* (frontend) → motor /api/ext/v1/*. // // Segurança: valida o JWT do scoreodonto (só usuário logado acessa). // Resolução de conta: SEMPRE via x-nw-email (chave mestra resolve o tenant pelo // email). Todo usuário da clínica tem conta própria no motor — se o motor não // conhecer o email, é erro (404 "conta não encontrada"), não há fallback. // O email vem SEMPRE do token verificado — nunca do cliente. import net from 'node:net'; import tls from 'node:tls'; import jwt from 'jsonwebtoken'; import { getConfig } from './config.js'; function authFromReq(req) { try { const tk = (req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim(); if (!tk) return null; const p = jwt.verify(tk, process.env.JWT_SECRET); const email = (p.email || '').toLowerCase() || null; if (!email) return null; return { email, userId: p.userId || null }; } catch { return null; } } // Resolve o dono do workspace (clínica OU sala). Retorna o userId dono ou null. async function resolveOwnerId(pool, clinicaId) { if (!pool || !clinicaId) return null; try { const { rows: cl } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [clinicaId]); if (cl.length) { if (cl[0].owner_id) return cl[0].owner_id; // Clínicas antigas sem owner_id: dono = vínculo donoclinica/donoconsultorio. const { rows: v } = await pool.query( "SELECT usuario_id FROM vinculos WHERE clinica_id = $1 AND role IN ('donoclinica','donoconsultorio') ORDER BY id LIMIT 1", [clinicaId]); return v[0]?.usuario_id || null; } const { rows: sl } = await pool.query('SELECT owner_usuario_id FROM salas WHERE id = $1', [clinicaId]); return sl[0]?.owner_usuario_id || null; } catch { return null; } } // Autoriza (ou nega 403) uma ação sensível de sessão de WhatsApp. // Mapeamento das ações do motor: // POST /sessions → scan INICIAL (criar) → só o dono // GET /sessions/:id/qr → re-scan (parear QR) → dono OU can_rescan // POST /sessions/:id/connect → re-scan (reconectar) → dono OU can_rescan // DELETE /sessions/:id → excluir → dono OU can_delete // Retorna { ok: true } ou { ok: false, status, error }. async function guardSessionAction(pool, req, tail, userId) { const method = req.method.toUpperCase(); const m = tail.match(/^\/sessions(?:\/([^/?]+)(\/qr|\/connect)?)?/); if (!m) return { ok: true }; // não é uma rota de sessão const instanceId = m[1] || null; const sub = m[2] || null; const isCreate = method === 'POST' && !instanceId; // POST /sessions const isRescan = (method === 'GET' && sub === '/qr') || (method === 'POST' && sub === '/connect'); // parear/reconectar const isDelete = method === 'DELETE' && instanceId && !sub; // DELETE /sessions/:id if (!isCreate && !isRescan && !isDelete) return { ok: true }; // GET lista / etc → livre const clinicaId = req.headers['x-nw-clinica'] ? String(req.headers['x-nw-clinica']) : null; if (!clinicaId) return { ok: false, status: 403, error: 'Workspace não identificado para esta ação.' }; const ownerId = await resolveOwnerId(pool, clinicaId); if (ownerId && ownerId === userId) return { ok: true }; // dono: poder total // Scan inicial é exclusivo do dono — não delegável. if (isCreate) return { ok: false, status: 403, error: 'Apenas o dono pode conectar um novo WhatsApp.' }; // Re-scan / exclusão: exigem delegação por sessão. if (!instanceId) return { ok: false, status: 403, error: 'Sessão não identificada.' }; let authz = null; try { const { rows } = await pool.query( 'SELECT can_rescan, can_delete FROM wa_session_authz WHERE clinica_id = $1 AND instance_id = $2 AND usuario_id = $3', [clinicaId, instanceId, userId]); authz = rows[0] || null; } catch { /* nega por padrão */ } if (isRescan && authz?.can_rescan) return { ok: true }; if (isDelete && authz?.can_delete) return { ok: true }; return { ok: false, status: 403, error: isDelete ? 'Somente o dono pode excluir esta sessão. Peça autorização de exclusão.' : 'Somente o dono pode reconectar esta sessão. Peça autorização de re-scan.', }; } // Auto-provisiona (idempotente) a conta do email no motor, via chave mestra. // Usado como self-heal quando o motor responde "Conta não encontrada". export async function provisionNewwhatsAccount(pool, cfg, email) { if (!cfg.motorUrl || !cfg.integrationKey || !email) return false; let name = email; try { if (pool) { const { rows } = await pool.query('SELECT nome FROM usuarios WHERE lower(email) = $1', [email.toLowerCase()]); if (rows[0]?.nome) name = rows[0].nome; } } catch { /* usa email como nome */ } try { const r = await fetch(`${cfg.motorUrl}/api/ext/v1/satellites/accounts`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-nw-key': cfg.integrationKey }, body: JSON.stringify({ email, name }), }); return r.ok; } catch { return false; } } // Monta a assinatura do operador para prefixar mensagens: primeiro nome; se houver // outro membro do workspace com o MESMO primeiro nome, desambigua com "Nome I." (inicial // do primeiro sobrenome). Retorna null se não houver nome. async function operatorSignature(pool, userId, clinicaId) { if (!pool || !userId) return null; try { const { rows } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [userId]); const full = String(rows[0]?.nome || '').trim(); if (!full) return null; const parts = full.split(/\s+/); const first = parts[0]; let ambiguous = false; if (clinicaId) { const { rows: m } = await pool.query( `SELECT u.nome FROM vinculos v JOIN usuarios u ON u.id = v.usuario_id WHERE v.clinica_id = $1 AND u.id <> $2`, [clinicaId, userId]); ambiguous = m.some(x => String(x.nome || '').trim().split(/\s+/)[0].toLowerCase() === first.toLowerCase()); } if (ambiguous && parts.length > 1 && parts[1][0]) { return `${first} ${parts[1][0].toUpperCase()}.`; } return first; } catch { return null; } } // Gate de acesso a ÁREAS (Inbox / Secretária). Dono passa sempre; os demais só com // delegação (can_inbox / can_secretaria) do dono em alguma sessão do workspace. async function guardAreaAccess(pool, tail, userId, clinicaId) { const isSecretaria = /^\/(secretaria|sec)(\/|$|\?)/.test(tail); const isInbox = /^\/(inbox|conversations)(\/|$|\?)/.test(tail); if (!isSecretaria && !isInbox) return { ok: true }; if (!pool || !clinicaId) return { ok: true }; // sem workspace resolvido, não barra aqui const ownerId = await resolveOwnerId(pool, clinicaId); if (ownerId && ownerId === userId) return { ok: true }; const col = isSecretaria ? 'can_secretaria' : 'can_inbox'; try { const { rows } = await pool.query( `SELECT 1 FROM wa_session_authz WHERE clinica_id = $1 AND usuario_id = $2 AND ${col} = true LIMIT 1`, [clinicaId, userId]); if (rows.length) return { ok: true }; } catch { /* nega por padrão */ } return { ok: false, status: 403, error: isSecretaria ? 'Sem acesso à Secretária. Peça autorização ao dono.' : 'Sem acesso ao Inbox. Peça autorização ao dono.' }; } // Gate de ações DESTRUTIVAS no inbox (apagar conversa / apagar mensagem). // Só o dono ou quem tiver can_delete_msg. (Complementa o gate de acesso ao inbox.) async function guardInboxDestructive(pool, method, tail, userId, clinicaId) { const isDeleteChat = method === 'DELETE' && /^\/inbox\/[^/]+\/?(\?|$)/.test(tail); const isDeleteMsg = method === 'DELETE' && /^\/inbox\/[^/]+\/messages\/[^/]+/.test(tail); if (!isDeleteChat && !isDeleteMsg) return { ok: true }; if (!pool || !clinicaId) return { ok: true }; const ownerId = await resolveOwnerId(pool, clinicaId); if (ownerId && ownerId === userId) return { ok: true }; try { const { rows } = await pool.query( 'SELECT 1 FROM wa_session_authz WHERE clinica_id = $1 AND usuario_id = $2 AND can_delete_msg = true LIMIT 1', [clinicaId, userId]); if (rows.length) return { ok: true }; } catch { /* nega por padrão */ } return { ok: false, status: 403, error: 'Sem permissão para apagar conversas/mensagens. Peça autorização ao dono.' }; } export function createRestProxy(pool) { return async function restProxy(req, res) { const cfg = getConfig(); if (!cfg.motorUrl || !cfg.integrationKey) { return res.status(503).json({ error: 'Integração NewWhats não configurada.' }); } const auth = authFromReq(req); if (!auth) return res.status(401).json({ error: 'Sessão inválida (faça login no scoreodonto).' }); const { email, userId } = auth; const tail = req.originalUrl.replace(/^\/api\/nw\/v1/, ''); const clinicaId = req.headers['x-nw-clinica'] ? String(req.headers['x-nw-clinica']) : null; // Enforcement de ownership/permissões antes de repassar ao motor. const guard = await guardSessionAction(pool, req, tail, userId); if (!guard.ok) return res.status(guard.status).json({ error: guard.error }); // Gate de acesso às áreas Inbox / Secretária. const area = await guardAreaAccess(pool, tail, userId, clinicaId); if (!area.ok) return res.status(area.status).json({ error: area.error }); // Gate de ações destrutivas do inbox (apagar conversa/mensagem). const destructive = await guardInboxDestructive(pool, req.method, tail, userId, clinicaId); if (!destructive.ok) return res.status(destructive.status).json({ error: destructive.error }); // Assinatura do operador: prefixa o texto enviado com "*Nome*\n" (identifica // quem respondeu numa caixa compartilhada). Só no envio de texto por humano. if (req.method === 'POST' && /^\/inbox\/[^/]+\/send$/.test(tail) && req.body && typeof req.body.text === 'string' && req.body.text.trim()) { const sig = await operatorSignature(pool, userId, clinicaId); if (sig) req.body.text = `*${sig}*\n${req.body.text}`; } const url = `${cfg.motorUrl}/api/ext/v1${tail}`; const opts = { method: req.method, headers: { 'Content-Type': 'application/json', 'x-nw-key': cfg.integrationKey, 'x-nw-email': email, // clínica do workspace ativo (modelo de canal) — repassa ao motor se veio. ...(req.headers['x-nw-clinica'] ? { 'x-nw-clinica': String(req.headers['x-nw-clinica']) } : {}), } }; if (!['GET', 'HEAD'].includes(req.method) && req.body && Object.keys(req.body).length) { opts.body = JSON.stringify(req.body); } try { let r = await fetch(url, opts); let text = await r.text(); // Self-heal: a conta ainda não foi espelhada no motor. Provisiona (idempotente) // e refaz o request UMA vez — assim "quando a pessoa for mexer, tudo funciona". if (r.status === 404 && /conta n[ãa]o encontrada/i.test(text) && email) { const ok = await provisionNewwhatsAccount(pool, cfg, email); if (ok) { r = await fetch(url, opts); text = await r.text(); } } res.status(r.status); try { res.json(JSON.parse(text)); } catch { res.send(text); } } catch (e) { res.status(502).json({ error: `Motor indisponível: ${e.message}` }); } }; } // ── Túnel WebSocket: /api/nw/v1/stream (browser) → motor /api/ext/v1/stream ── // // O inbox em tempo real do motor (QR, status da sessão, mensagens novas) roda // sobre WebSocket. Aqui abrimos um túnel TCP bruto socket↔socket e injetamos a // x-nw-key no handshake com o motor — a chave nunca chega ao browser. // // Auth do usuário: o browser NÃO envia header Authorization no upgrade WS, então // o JWT do scoreodonto vem por ?token= (mesmo esquema do /api/ws do app). Só // usuário logado abre o túnel; o token fica local — não é repassado ao motor. // // Motor: autentica o /api/ext/v1/stream pela x-nw-key + x-nw-email (a chave é a // mestra; o tenant é resolvido pelo email do usuário) e exige o path EXATO, sem // query — por isso reescrevemos um request-line limpo. O email vem do JWT. export function createWsTunnel() { return function wsTunnel(req, clientSocket, head) { const fail = (statusLine) => { try { clientSocket.write(`HTTP/1.1 ${statusLine}\r\n\r\n`); } catch { /* socket já morto */ } clientSocket.destroy(); }; // Auth: JWT do scoreodonto no query string (?token=). let token; try { token = new URL(req.url, 'http://localhost').searchParams.get('token'); } catch { return fail('400 Bad Request'); } let email; try { const p = jwt.verify(token, process.env.JWT_SECRET); email = (p.email || '').toLowerCase(); } catch { return fail('401 Unauthorized'); } if (!email) return fail('401 Unauthorized'); const cfg = getConfig(); if (!cfg.motorUrl || !cfg.integrationKey) return fail('503 Service Unavailable'); let target; try { target = new URL(cfg.motorUrl); } catch { return fail('502 Bad Gateway'); } const isHttps = target.protocol === 'https:'; const host = target.hostname; const port = Number(target.port) || (isHttps ? 443 : 80); const onConnect = () => { // Reescreve o handshake: path EXATO + Host do motor + x-nw-key. // Preserva os headers do WS (sec-websocket-*, upgrade, connection). const headers = Object.entries(req.headers) .filter(([k]) => !['host', 'x-nw-key', 'x-nw-email'].includes(k.toLowerCase())) .map(([k, v]) => `${k}: ${v}`) .join('\r\n'); proxySocket.write( `${req.method} /api/ext/v1/stream HTTP/1.1\r\n` + `Host: ${host}\r\n` + `x-nw-key: ${cfg.integrationKey}\r\n` + `x-nw-email: ${email}\r\n` + `${headers}\r\n\r\n` ); if (head && head.length) proxySocket.write(head); proxySocket.pipe(clientSocket); clientSocket.pipe(proxySocket); }; const proxySocket = isHttps ? tls.connect({ host, port, servername: host }, onConnect) : net.connect(port, host, onConnect); proxySocket.on('error', (err) => { console.error('[newwhats] túnel WS erro:', err.message); clientSocket.destroy(); }); clientSocket.on('error', () => proxySocket.destroy()); clientSocket.on('close', () => proxySocket.destroy()); proxySocket.on('close', () => clientSocket.destroy()); }; }