Bypass de pruebas: solo se llaman números en RETELL_ALLOWED_NUMBERS

Guarda en iniciarLlamadaSaliente: si RETELL_ALLOWED_NUMBERS tiene valor (CSV de
E.164), solo se lanza la llamada a esos números; el resto se omite (devuelve
null, funnel en simulado). Vacío = se llama a todos. Protege tanto el form
(procesarLead) como el canal llamada (pedirLlamada). En prod = +34651194617.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-07 19:00:36 +02:00
parent ac04972100
commit db46dc9cf3
3 changed files with 18 additions and 0 deletions

View File

@@ -7,6 +7,9 @@ DATABASE_URL="postgresql://postgres:reformix@localhost:5432/reformix"
RETELL_API_KEY="" RETELL_API_KEY=""
RETELL_AGENT_ID="" RETELL_AGENT_ID=""
RETELL_FROM_NUMBER="" # número de origen en E.164, p. ej. +34910000000 RETELL_FROM_NUMBER="" # número de origen en E.164, p. ej. +34910000000
# Allowlist de pruebas: si tiene valor (CSV de E.164), SOLO se llama a esos números; el resto se
# omite (la llamada no se lanza). Vaciar para volver a llamar a todos. Ej: "+34651194617"
RETELL_ALLOWED_NUMBERS=""
# EP de ingesta del lead (/api/leads/:id/ingesta). Clave compartida que valida al llamante # EP de ingesta del lead (/api/leads/:id/ingesta). Clave compartida que valida al llamante
# externo (Authorization: Bearer ...). Sin ella, el EP responde 401. # externo (Authorization: Bearer ...). Sin ella, el EP responde 401.

View File

@@ -12,6 +12,8 @@ const schema = z.object({
RETELL_API_KEY: opcional, RETELL_API_KEY: opcional,
RETELL_AGENT_ID: opcional, RETELL_AGENT_ID: opcional,
RETELL_FROM_NUMBER: opcional, RETELL_FROM_NUMBER: opcional,
// Allowlist de pruebas: si tiene valor (CSV de números), SOLO se llaman esos; vacío = todos.
RETELL_ALLOWED_NUMBERS: opcional,
// EP de ingesta del lead: clave compartida que valida al llamante externo. // EP de ingesta del lead: clave compartida que valida al llamante externo.
FUNNEL_API_KEY: opcional, FUNNEL_API_KEY: opcional,
// SMTP para enviar el presupuesto y el enlace al formulario. // SMTP para enviar el presupuesto y el enlace al formulario.
@@ -32,6 +34,7 @@ export const env = schema.parse({
RETELL_API_KEY: process.env.RETELL_API_KEY, RETELL_API_KEY: process.env.RETELL_API_KEY,
RETELL_AGENT_ID: process.env.RETELL_AGENT_ID, RETELL_AGENT_ID: process.env.RETELL_AGENT_ID,
RETELL_FROM_NUMBER: process.env.RETELL_FROM_NUMBER, RETELL_FROM_NUMBER: process.env.RETELL_FROM_NUMBER,
RETELL_ALLOWED_NUMBERS: process.env.RETELL_ALLOWED_NUMBERS,
FUNNEL_API_KEY: process.env.FUNNEL_API_KEY, FUNNEL_API_KEY: process.env.FUNNEL_API_KEY,
SMTP_HOST: process.env.SMTP_HOST, SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT, SMTP_PORT: process.env.SMTP_PORT,

View File

@@ -50,6 +50,18 @@ export async function iniciarLlamadaSaliente(opts: {
const toNumber = normalizarTelefonoEs(opts.telefono); const toNumber = normalizarTelefonoEs(opts.telefono);
if (!toNumber) return null; if (!toNumber) return null;
// Bypass de seguridad (fase de pruebas): si RETELL_ALLOWED_NUMBERS tiene valor, SOLO se llama a
// esos números; cualquier otro se omite (devuelve null, el funnel sigue en simulado). Vaciar la
// variable para volver a llamar a todos.
const allow = (env.RETELL_ALLOWED_NUMBERS ?? '')
.split(',')
.map((s) => normalizarTelefonoEs(s.trim()))
.filter((n): n is string => Boolean(n));
if (allow.length > 0 && !allow.includes(toNumber)) {
console.warn(`Retell: ${toNumber} no está en RETELL_ALLOWED_NUMBERS; llamada omitida (bypass).`);
return null;
}
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
from_number: env.RETELL_FROM_NUMBER, from_number: env.RETELL_FROM_NUMBER,
to_number: toNumber, to_number: toNumber,