feat(newwhats): backend do satélite — proxy REST + túnel WS com chave mestra

- proxy REST resolve a conta SEMPRE por x-nw-email (email do JWT verificado);
  remove o fallback "sem email" (a chave mestra sem email dá 400 no motor).
- túnel WS (/api/nw/v1/stream) injeta x-nw-email no handshake e valida o JWT
  por ?token=; server.js roteia o upgrade (/api/ws do app + /stream do plugin).
- testMotorKey usa email-sonda (404 = chave mestra válida).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-01 20:52:18 +02:00
parent 872acd800a
commit 6593993b3d
3 changed files with 159 additions and 36 deletions
+20 -12
View File
@@ -10,6 +10,22 @@ import { createRestProxy } from './proxy.js'
import { getConfig, isConfigured, loadConfigFromDb, saveConfigToDb } from './config.js'
export { syncKnowledge, buildKnowledgeText } from './sync-knowledge.js'
export { createWsTunnel } from './proxy.js'
// Valida uma chave contra o motor. Envia um email-sonda (a chave mestra exige
// x-nw-email). 200 = ok com dados; 404 = chave válida mas email-sonda inexistente
// (= chave OK); 401 = chave inválida.
async function testMotorKey(motorUrl, key) {
try {
const r = await fetch(`${motorUrl}/api/ext/v1/sessions`, {
headers: { 'x-nw-key': key, 'x-nw-email': 'probe@nw.local' },
});
if (r.ok || r.status === 404) return { ok: true, detail: 'Conexão OK' };
return { ok: false, detail: `Motor respondeu ${r.status}` };
} catch (e) {
return { ok: false, detail: `Motor inacessível: ${e.message}` };
}
}
export function registerNewwhats(app, pool, opts = {}) {
// passthrough middleware caso o guard não seja fornecido (dev)
@@ -69,12 +85,8 @@ export function registerNewwhats(app, pool, opts = {}) {
return res.status(400).json({ error: 'Token incompleto (faltam URL ou chave).' });
}
// Valida contra o motor ANTES de salvar.
let motorOk = false, detail = '';
try {
const r = await fetch(`${motorUrl}/api/ext/v1/sessions`, { headers: { 'x-nw-key': integrationKey } });
motorOk = r.ok; detail = r.ok ? 'Conexão OK' : `Motor respondeu ${r.status}`;
} catch (e) { detail = `Motor inacessível: ${e.message}`; }
// Valida a chave contra o motor ANTES de salvar.
const { ok: motorOk, detail } = await testMotorKey(motorUrl, integrationKey);
if (!motorOk) return res.status(400).json({ error: `Token não validou no motor: ${detail}` });
const cur = getConfig();
@@ -97,12 +109,8 @@ export function registerNewwhats(app, pool, opts = {}) {
app.get('/api/nw/status', superadminGuard, async (_req, res) => {
const c = getConfig();
if (!c.motorUrl || !c.integrationKey) return res.json({ configured: false, motorOk: false, detail: 'URL ou chave ausente' });
try {
const r = await fetch(`${c.motorUrl}/api/ext/v1/sessions`, { headers: { 'x-nw-key': c.integrationKey } });
res.json({ configured: true, motorOk: r.ok, status: r.status, detail: r.ok ? 'Conexão OK' : `Motor respondeu ${r.status}` });
} catch (e) {
res.json({ configured: true, motorOk: false, detail: `Motor inacessível: ${e.message}` });
}
const { ok, detail } = await testMotorKey(c.motorUrl, c.integrationKey);
res.json({ configured: true, motorOk: ok, detail });
});
// ── Operação ────────────────────────────────────────────────────────────────
+119 -21
View File
@@ -1,30 +1,128 @@
// Proxy REST: /api/nw/v1/* (frontend da clínica) → motor /api/ext/v1/*,
// injetando a integration_key como x-nw-key. Nunca expõe a chave ao browser.
import { getConfig } from './config.js'
// 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 emailFromReq(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);
return (p.email || '').toLowerCase() || null;
} catch { return null; }
}
export function createRestProxy() {
return async function restProxy(req, res) {
const cfg = getConfig()
const cfg = getConfig();
if (!cfg.motorUrl || !cfg.integrationKey) {
return res.status(503).json({ error: 'Integração NewWhats não configurada (NEWWHATS_URL / NEWWHATS_INTEGRATION_KEY).' })
return res.status(503).json({ error: 'Integração NewWhats não configurada.' });
}
// /api/nw/v1/inbox?x=1 → /api/ext/v1/inbox?x=1
const tail = req.originalUrl.replace(/^\/api\/nw\/v1/, '')
const url = `${cfg.motorUrl}/api/ext/v1${tail}`
const email = emailFromReq(req);
if (!email) return res.status(401).json({ error: 'Sessão inválida (faça login no scoreodonto).' });
const tail = req.originalUrl.replace(/^\/api\/nw\/v1/, '');
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,
} };
if (!['GET', 'HEAD'].includes(req.method) && req.body && Object.keys(req.body).length) {
opts.body = JSON.stringify(req.body);
}
try {
const opts = {
method: req.method,
headers: { 'Content-Type': 'application/json', 'x-nw-key': cfg.integrationKey },
}
if (!['GET', 'HEAD'].includes(req.method) && req.body && Object.keys(req.body).length) {
opts.body = JSON.stringify(req.body)
}
const r = await fetch(url, opts)
const text = await r.text()
res.status(r.status)
try { res.json(JSON.parse(text)) } catch { res.send(text) }
const r = await fetch(url, opts);
const 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}` })
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());
};
}
+20 -3
View File
@@ -11,7 +11,7 @@ import fs from 'fs';
import path from 'path';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { registerNewwhats } from './newwhats/index.js';
import { registerNewwhats, createWsTunnel } from './newwhats/index.js';
const { Pool } = pg;
const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026';
@@ -47,7 +47,7 @@ const pool = new Pool({
max: 10,
idleTimeoutMillis: 30000
});
pool.on('connect', () => console.log('[PG] Connected to PostgreSQL'));
// pool.on('connect', () => console.log('[PG] Connected to PostgreSQL'));
pool.on('error', (err) => console.error('[PG] Pool error:', err.message));
// Helper: transaction wrapper
@@ -9953,7 +9953,24 @@ const httpServer = app.listen(PORT, '0.0.0.0', async () => {
// ── WebSocket server (tempo real) em /api/ws ────────────────────────────────
// Auth via query string (?token=&clinicaId=), pois EventSource/WebSocket do
// navegador não enviam header Authorization. Valida JWT + vínculo com a clínica.
const wss = new WebSocketServer({ server: httpServer, path: '/api/ws' });
const wss = new WebSocketServer({ noServer: true });
// Dispatcher único de upgrade WS: roteia por path. O `ws` em modo {server,path}
// aborta com 400 qualquer upgrade de outro path, então centralizamos aqui para o
// /api/ws (app) coexistir com o túnel do NewWhats (/api/nw/v1/stream → motor).
const nwWsTunnel = createWsTunnel();
httpServer.on('upgrade', (req, socket, head) => {
let pathname;
try { pathname = new URL(req.url, 'http://localhost').pathname; }
catch { socket.destroy(); return; }
if (pathname === '/api/ws') {
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
} else if (pathname === '/api/nw/v1/stream') {
nwWsTunnel(req, socket, head);
} else {
socket.destroy();
}
});
wss.on('connection', async (ws, req) => {
try {
const u = new URL(req.url, 'http://localhost');