Correções de bugs, layout de cards e webhook test funcional

- Pipeline cancel/approve/reject corrigido com busca bidirecional
- Secrets injetados no executor via cleanEnv
- Versionamento automático ao atualizar agentes
- writeJsonAsync com log de erro
- Removido asyncHandler.js (código morto)
- Restaurado permissionMode padrão bypassPermissions
- Ícones dos cards alinhados à direita com wrapper
- Botão Editar convertido para ícone nos cards
- Webhook test agora dispara execução real do agente/pipeline
- Corrigido App.navigateTo no teste de webhook
This commit is contained in:
Frederico Castro
2026-02-26 23:28:50 -03:00
parent bbd2ec46dd
commit 9b66a415ff
17 changed files with 1147 additions and 259 deletions

View File

@@ -6,6 +6,8 @@ import { dirname, join } from 'path';
import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';
import rateLimit from 'express-rate-limit';
import helmet from 'helmet';
import compression from 'compression';
import apiRouter, { setWsBroadcast, setWsBroadcastTo, hookRouter } from './src/routes/api.js';
import * as manager from './src/agents/manager.js';
import { setGlobalBroadcast } from './src/agents/manager.js';
@@ -14,16 +16,17 @@ import { flushAllStores } from './src/store/db.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || '127.0.0.1';
const AUTH_TOKEN = process.env.AUTH_TOKEN || '';
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || 'http://localhost:3000';
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '';
function timingSafeCompare(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
const hashA = crypto.createHash('sha256').update(a).digest();
const hashB = crypto.createHash('sha256').update(b).digest();
return crypto.timingSafeEqual(hashA, hashB);
}
const apiLimiter = rateLimit({
@@ -34,6 +37,14 @@ const apiLimiter = rateLimit({
message: { error: 'Limite de requisições excedido. Tente novamente em breve.' },
});
const hookLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Limite de requisições de webhook excedido.' },
});
function verifyWebhookSignature(req, res, next) {
if (!WEBHOOK_SECRET) return next();
const sig = req.headers['x-hub-signature-256'];
@@ -77,24 +88,29 @@ app.get('/api/health', (req, res) => {
});
});
app.use(helmet({
contentSecurityPolicy: false,
}));
app.use(compression());
app.use('/api', apiLimiter);
if (AUTH_TOKEN) {
app.use('/api', (req, res, next) => {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : req.query.token;
if (!timingSafeCompare(token, AUTH_TOKEN)) {
return res.status(401).json({ error: 'Token de autenticação inválido' });
}
next();
});
}
app.use('/api', (req, res, next) => {
if (!AUTH_TOKEN) return next();
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : req.query.token;
if (!timingSafeCompare(token, AUTH_TOKEN)) {
return res.status(401).json({ error: 'Token de autenticação inválido' });
}
next();
});
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = buf; },
verify: (req, res, buf) => { req.rawBody = buf || Buffer.alloc(0); },
}));
app.use('/hook', verifyWebhookSignature, hookRouter);
app.use(express.static(join(__dirname, 'public')));
app.use('/hook', hookLimiter, verifyWebhookSignature, hookRouter);
app.use(express.static(join(__dirname, 'public'), { maxAge: '1h', etag: true }));
app.use('/api', apiRouter);
const connectedClients = new Map();
@@ -104,20 +120,30 @@ wss.on('connection', (ws, req) => {
if (AUTH_TOKEN) {
const token = new URL(req.url, 'http://localhost').searchParams.get('token');
if (token !== AUTH_TOKEN) {
if (!timingSafeCompare(token, AUTH_TOKEN)) {
ws.close(4001, 'Token inválido');
return;
}
}
ws.clientId = clientId;
ws.isAlive = true;
connectedClients.set(clientId, ws);
ws.on('pong', () => { ws.isAlive = true; });
ws.on('close', () => connectedClients.delete(clientId));
ws.on('error', () => connectedClients.delete(clientId));
ws.send(JSON.stringify({ type: 'connected', clientId }));
});
const wsHeartbeat = setInterval(() => {
wss.clients.forEach(ws => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
function broadcast(message) {
const payload = JSON.stringify(message);
for (const [, client] of connectedClients) {
@@ -145,6 +171,8 @@ function gracefulShutdown(signal) {
flushAllStores();
console.log('Dados persistidos.');
clearInterval(wsHeartbeat);
httpServer.close(() => {
console.log('Servidor HTTP encerrado.');
process.exit(0);
@@ -159,10 +187,18 @@ function gracefulShutdown(signal) {
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('uncaughtException', (err) => {
console.error('[FATAL] Exceção não capturada:', err.message);
console.error(err.stack);
});
process.on('unhandledRejection', (reason) => {
console.error('[WARN] Promise rejeitada não tratada:', reason);
});
manager.restoreSchedules();
httpServer.listen(PORT, () => {
console.log(`Painel administrativo disponível em http://localhost:${PORT}`);
httpServer.listen(PORT, HOST, () => {
console.log(`Painel administrativo disponível em http://${HOST}:${PORT}`);
console.log(`WebSocket server ativo na mesma porta.`);
if (AUTH_TOKEN) console.log('Autenticação por token ativada.');
});