feat(pipeline): endpoint di ingresso lead, indipendente dalla sorgente

POST /api/webhooks/lead con header x-webhook-secret. Un endpoint solo per il
form del sito e per qualunque bridge: il contratto e' un POST, e chi lo manda
non cambia la route.

La normalizzazione dei campi sta in lead-intake.ts, non nella route, perche' e'
la parte che cambia quando si aggiunge una sorgente. Regge tre forme senza
doverle distinguere: payload piatto, `fields` annidati con {value} (Elementor
Pro), e urlencoded per i form che non mandano JSON. Riconosce i nomi italiani
(nome, telefono, azienda, messaggio), che e' come li chiama un form Elementor
scritto in italiano.

Chi compila due volte non diventa due lead. Il riconoscimento e' sull'email: il
secondo invio aggiorna last_contact_date e lascia un'attivita' con quello che
ha scritto, cosi' il messaggio non si perde ma la scheda resta una. Senza email
non si puo' dedurre nulla e si crea.

Due scelte di sicurezza, entrambe diverse dalle route /api/internal:

- Segreto assente in ambiente = 403, non "passa". Le internal possono
  permetterselo perche' sono raggiungibili solo da localhost; questa e' esposta
  a internet, e un deploy con la variabile dimenticata deve smettere di
  accettare lead, non accettarli da chiunque.
- Il rate limit viene PRIMA del confronto sul segreto, altrimenti tentare
  segreti a raffica costerebbe zero. Confronto a tempo costante con safeEqual,
  lo stesso del gate admin.

src/proxy.ts non intercetta /api/*, quindi da monte non arriva nessuna
protezione: sta tutto dentro la route.

Provato contro il DB di produzione via tunnel SSH, poi ripulito (2 lead e 2
attivita' prima, 2 e 2 dopo): senza segreto 403, segreto sbagliato 403, nome
mancante 422, payload piatto 201, ripetuto 200 "updated" senza duplicare,
forma Elementor 201 con nome/telefono/messaggio mappati, urlencoded 201, e con
starts_at valorizzato il lead nasce con la data della call e un'attivita'
"meeting".

Resta da confermare con un invio VERO da Elementor la forma esatta del suo
payload: qui e' gestita in modo difensivo, non verificata sul campo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 22:57:42 +02:00
parent d6d3be00f4
commit 19ed377214
3 changed files with 282 additions and 0 deletions
+7
View File
@@ -15,3 +15,10 @@ INTERNAL_SECRET=generate-with-openssl-rand-base64-32
# RESEND_FROM deve usare un dominio verificato su Resend
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
RESEND_FROM=Nome Mittente <no-reply@iamcavalli.net>
# Ingresso lead da fuori (form del sito, bridge Zapier/Make) su
# POST /api/webhooks/lead, header x-webhook-secret.
# A differenza di INTERNAL_SECRET questa route e' esposta a internet: se la
# variabile manca, la route risponde 403 invece di lasciar passare.
# Generate with: openssl rand -base64 32
LEAD_WEBHOOK_SECRET=generate-with-openssl-rand-base64-32