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:
+119
-21
@@ -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());
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user