feat(evohub): registra webhook inbound no hub durante o link-existing#2636
Conversation
O fluxo link-existing criava a Instance mas não registrava webhook no hub (só o provision registrava, via single-shot do POST /channels). Resultado: canal vinculado enviava mensagens mas nunca recebia — nenhum evento inbound chegava em /webhook/evohub e não havia erro em lugar algum. - evohub.client.ts: novo ensureChannelWebhook(channelId, url) idempotente — (1) webhook já associado ao canal com a mesma URL: no-op; (2) webhook do usuário com a mesma URL: associa via POST /webhooks/:id/associate (se all_channels, nem associa); (3) cria via POST /webhooks single-shot com channels [channelId] e events [] (vazio = todos). Secret enviado quando EVOLUTION_HUB_WEBHOOK_SECRET setado (register-with-own-secret). Inclui HubWebhookInfo, listChannelWebhooks, listWebhooks e associateWebhook. - evohub.controlplane.router.ts: link-existing garante o webhook ANTES de criar a Instance — falha do hub responde 502 sem efeito colateral (retry seguro); SERVER_URL vazio mantém paridade com o provision (segue sem webhook). - .env.example: documenta o preenchimento das variáveis EvoHub (formato evh_pk_ da API key, SERVER_URL alcançável pelo hub, host local 8086, soft mode do webhook secret). Refs EVO-2098
There was a problem hiding this comment.
Sorry @pastoriniMatheus, you have reached your weekly rate limit of 500000 diff characters.
Please try again later or upgrade to continue using Sourcery
|
Validação end-to-end executada em ambiente local (hub via docker-compose + esta branch): link-existing cria a Instance (201), registra o webhook no hub com secret, não duplica no segundo canal (reuso via associate — 1 webhook, 2 associações), mensagem Meta simulada atravessa hub → Achado colateral: bug pré-existente de invalidação de cache no hub segura a entrega inbound por até 5min após a associação — registrado como EVO-2099 (fix no repo evolution-hub; não bloqueia esta PR). |
ensureChannelWebhook treated any webhook whose status was not exactly 'inactive' as good enough and no-oped. But the hub also auto-moves a webhook to status 'disabled' after repeated delivery failures, and its dispatcher delivers ONLY when status == 'active' (webhook_dispatcher.go:294). So a disabled webhook matched the URL, was accepted, and the channel stayed deaf — the exact "silently not receiving" symptom this feature exists to fix, in a common scenario (evolution-api was down long enough for the hub to disable the webhook; the operator re-links and gets 201 but still receives nothing). Now the guard keys on status === 'active' and reactivates a matching but non-active webhook via PUT /webhooks/:id/status (the hub's official path out of 'disabled'), for both the channel-associated and the user-level webhook. Also validates channel/webhook ids as UUID before interpolating them into the hub request path (assertHubId), closing the CodeQL js/request-forgery paths the new listChannelWebhooks method added and hardening getChannel/connectToMeta. Hub ids are UUIDs (uuid.Parse server-side), so this rejects nothing legitimate. Verified: tsc/eslint/build green, plus a stubbed-http harness driving ensureChannelWebhook through the disabled/active/missing/user-inactive/ all_channels branches and the id guard (10/10 assertions).
… webhook ensureChannelWebhook matched an existing hub webhook by URL alone and reused it as-is. The hub only signs the inbound delivery when the webhook has a secret stored (webhook_dispatcher.go:983) and its WebhookResponse exposes neither the secret nor a has_secret flag (webhook.go:116), so drift is undetectable from the client side. A deployment that ran EvoHub phase 1 in soft mode (empty secret — the documented default) has a hub webhook with NO secret. The moment the operator follows the guidance this branch adds to .env.example and sets EVOLUTION_HUB_WEBHOOK_SECRET, the next link-existing reuses that secret-less webhook, the hub delivers unsigned, verifyHmac answers 401 and the channel is deaf — the very symptom this feature exists to kill. Rotating the secret does the same. Worse: the webhook is shared across channels, so the repeated 401 makes the hub auto-disable it and every EvoHub channel stops receiving. Now every reuse path rewrites the configured secret (PUT /webhooks/:id/secret) BEFORE reactivating the webhook — reactivating first would just take another 401 and get auto-disabled again. Empty secret stays a no-op: the inbound accepts unsigned payloads in soft mode. Verified: tsc/eslint/build green, plus a stubbed-http harness driving the real client through reuse-with-secret, disabled-webhook ordering, soft mode, user-level webhook and the id guard (12/12 assertions). Refs EVO-2098
The UUID guard lived in assertHubId(), a separate method. It blocks path/URL injection at runtime just fine, but CodeQL's taint analysis does not carry a throwing guard across a function boundary, so js/request-forgery kept flagging every method that interpolates a hub id into the request path — the alerts the guard was added to close stayed open. Same check, now inline at each sink (HUB_ID.test), which is what both the scanner and the runtime accept. Behaviour is unchanged: hub ids are UUIDs (uuid.Parse server-side), so nothing legitimate is rejected. Refs EVO-2098
Repo convention is English code comments; the EvoHub comments introduced by this branch were in Portuguese. No behaviour change. Refs EVO-2098
Problema
No canal EvoHub há dois fluxos para plugar um canal do hub numa Instance (ambos disparados pelo manager via rotas
/evohub/*):POST /evohub/provision(canal novo) → registra o webhook inbound no hub via single-shot doPOST /api/v1/channels✅POST /evohub/link-existing(canal já existente no hub) → criava a Instance sem registrar webhook nenhum ❌Resultado: canal vinculado envia mensagens normalmente (data-plane
${HUB}/meta/*), mas nunca recebe — o hub não tem para onde entregar os eventos e nenhum erro aparece em lado algum. A Instance nasce "surda" silenciosamente.Implementação
evohub.client.ts— novoensureChannelWebhook(channelId, webhookUrl)idempotente, com resolução em 3 níveis para nunca duplicar webhooks no hub:inactive) → no-op (GET /api/v1/channels/:id/webhooks);POST /api/v1/webhooks/:id/associate(se forall_channels, nem associa — já cobre);POST /api/v1/webhookssingle-shot comchannels: [channelId]eevents: [](vazio = todos os eventos, contrato do hub emwebhook_service.go:98). Osecretvai junto quandoEVOLUTION_HUB_WEBHOOK_SECRETestá setado (recipe register-with-own-secret — o hub assinaX-Hub-Signature-256e overifyHmacdo inbound valida).Suporte: interface
HubWebhookInfo,listChannelWebhooks(),listWebhooks(),associateWebhook()e normalização de lista (webhooks | data | array nu).evohub.controlplane.router.ts—link-existinggarante o webhook ANTES de criar a Instance:Decisões:
SERVER_URLvazio → segue sem webhook: paridade com o provision (que também omitewebhook_urlnesse caso)..env.example— documentação de preenchimento das variáveis EvoHub (as variáveis já existiam; faltava orientação): aviso de que o inbound chega em{SERVER_URL}/webhook/evohube oSERVER_URLprecisa ser alcançável pelo hub (host.docker.internal:8080com hub em Docker), valores típicos deEVOLUTION_HUB_URL(SaaS × local:8086), formatoevh_pk_...da API key (obrigatória para/evohub/*) e semântica doWEBHOOK_SECRET(soft mode quando vazio).Validação
tsc --noEmit,eslintenpm run buildverdes (hooks de pre-commit/pre-push do repo)SERVER_URLalcançável): link → webhook criado no hub; segundo link → webhook reaproveitado via associate; mensagem inbound chega na Instance; API key inválida → 502 sem Instance criadaRefs EVO-2098