
Después de endurecer ScamDetector contra inyecciones, bots y abuso, tenía todas las defensas haciendo su trabajo, pero no me enteraba de nada. Los logs en JSONL estaban ahí, la suite de tests pasaba en verde, el rate limiting persistía entre reinicios. Y yo, mientras tanto, abriendo el terminal cada dos días para hacer un tail del fichero por si había pasado algo interesante. Un sistema que solo te cuenta lo que está pasando cuando tú vas a preguntarle es un sistema que te obliga a desconfiar en tu tiempo libre, y eso no escala.
La solución obvia habría sido montar un dashboard. Grafana, un Prometheus pequeñito, alguna cosa con gráficas. Lo descarté por dos motivos. El primero es que un dashboard solo sirve si lo miras, y yo no lo iba a mirar. El segundo es que los eventos que me importan (un intento de inyección, una IP baneada, el backend de IA caído) son cosas que quiero saber cuando ocurren, no cuando tenga un rato. La respuesta natural era una notificación en el móvil.
Miré varias opciones. Un bot de Telegram es cómodo pero me dejaba expuesto a su API, a los cambios de política y a que el bot apareciera en las conversaciones. Pushover es excelente pero es un servicio de pago con tu móvil atado a una cuenta concreta. Los emails se pierden entre otros emails y no llevan prioridad. Slack me parecía desproporcionado para un proyecto personal.
ntfy encajaba con lo que yo quería. Es un servicio de notificaciones push que habla HTTP plano, tiene app nativa para iOS y Android, es open source y se puede autohospedar. Publicas en un topic con un POST de texto plano y la app suscrita al topic recibe el push al instante. No hay SDKs, no hay colas, no hay lógica de estado. Curl contra una URL.
Monté una instancia propia en mi VPS, protegida con auth-file y un usuario write-only por topic. La app del móvil apunta a mi dominio. No paso por ningún servicio de terceros ni dependo de que alguien siga existiendo dentro de tres años. Si ntfy desaparece mañana, el protocolo es tan simple que cualquier alternativa se adapta en una tarde.
Publicar en ntfy es tan básico que añadir una librería me parecía desperdicio. Escribí api/notify.js, un módulo de unas noventa líneas con dos funciones exportadas, cero dependencias y una idea central: que la app no dependa de ntfy para funcionar. Si la URL no está configurada, todas las llamadas son no-op silenciosas. Si ntfy está caído o no responde en cinco segundos, se registra el error en consola y la petición del usuario sigue su curso sin enterarse.
async function notify({ title, message, priority, tags } = {}) {
const url = process.env.NTFY_URL;
if (!url) return { ok: false, skipped: true };
const headers = { 'Content-Type': 'text/plain; charset=utf-8' };
if (process.env.NTFY_AUTH) headers.Authorization = process.env.NTFY_AUTH;
if (priority) headers.Priority = String(priority);
if (tags?.length) headers.Tags = tags.join(',');
let body = message || '';
if (title) {
if (/^[\x00-\x7F]*$/.test(title)) headers.Title = title;
else body = `${title}\n\n${body}`;
}
try {
const res = await fetch(url, {
method: 'POST', headers, body,
signal: AbortSignal.timeout(5000),
});
return { ok: res.ok, status: res.status };
} catch (err) {
console.error('[notify] ntfy request failed:', err?.message || err);
return { ok: false, error: err?.message };
}
}Hay tres detalles que aprendí por el camino. El primero es que las cabeceras HTTP no pueden llevar caracteres no-ASCII, así que si el título tiene una tilde o un emoji hay que moverlo al cuerpo. Descubrí esto cuando mis primeras notificaciones llegaban sin título porque fetch rechazaba silenciosamente el header. El segundo es que ntfy admite un Priority de 1 a 5, donde 1 es silencioso (sin vibración, sin sonido) y 5 salta el modo no-molestar. Reservo el 5 para bans e incidentes graves. El tercero es que los tags son emoji-shortcodes tipo :warning: que aparecen delante del título. Puro detalle visual, pero hace que la lista sea escaneable de un vistazo.
A eso le añadí un throttle en memoria por clave arbitraria. Un Map<string, timestamp> con eviction LRU cuando supera 10.000 entradas. Sirve para evitar que la misma IP me envíe cincuenta pushes en un minuto si alguien decide pelearse con los guardrails durante horas. Si el proceso reinicia el throttle se resetea, pero asumo ese coste: tras un reboot se permite un push más por clave, mínimo ruido.
Hay cuatro eventos que me interesa saber en el momento en que ocurren. Cada uno tiene su clave de throttle, su prioridad y su conjunto de tags, elegidos para que el teléfono pueda priorizar solo con ver la cabecera.
1. Intento de inyección de prompts. La detección ya existía en el código, pero antes se quedaba en el log. Ahora, cuando detectPromptInjection devuelve un score mayor o igual a 1, se emite un push con los patrones detectados (ignore_instructions, role_switch, force_json_output), el hash de la IP y el ID de la petición. El throttle es de diez minutos por IP, porque un bot insistente puede disparar veinte intentos en un rato y no me interesa que suenen los veinte. La prioridad sube a 4 si el score pasa de un umbral.
2. IP baneada durante veinticuatro horas. Es la consecuencia de acumular tres inyecciones con score alto dentro de la ventana de penalización. Cuando eso ocurre, el ban salta a la capa más dura del rate limiting (24 horas sin poder llamar a ningún endpoint). Este evento va con prioridad 5, tag rotating_light y sin throttle, porque cada ban es un incidente distinto y merece un push aunque caigan varios el mismo día.
3. Brute-force contra la bearer key. Cuando añadí autenticación por API key para agentes de IA y scripts, me preocupaba que alguien descubriera el endpoint y empezara a probar claves. El handler mantiene un contador por IP que suma cada fallo de verifyApiKey. Al quinto fallo dentro de diez minutos, push con tag key. El contador es in-memory y se resetea solo al pasar la ventana. No es una defensa, es un aviso, la defensa la hace la comparación con crypto.timingSafeEqual y el rate limit por IP.
4. Backend de IA caído o recuperado. Si OpenRouter empieza a devolver 5xx y acumulo tres seguidos, push con prioridad 5 diciendo qué backend cayó. Cuando vuelve el primer 2xx tras la caída, emito un push de recuperación. Los errores 4xx no cuentan porque normalmente son problemas míos (configuración, cuota, clave caducada). Es un circuit breaker light, sin la complejidad de abrir y cerrar estados, solo lo necesario para no despertar a nadie por un hipo de red puntual.
Los eventos en tiempo real se disparan dentro del flujo de una petición concreta. Pero hay información que solo emerge cuando miras el conjunto. Para eso añadí api/stats-monitor.js, un tercer módulo que arranca junto al servidor y programa tres tareas periódicas.
Digest diario a las 21:00. Una vez al día, el scheduler lee las últimas veinticuatro horas de los tres logs JSONL (analysis-log, urlscan-log, extract-urls-log), calcula un resumen y publica un push silencioso con prioridad 2. Lleva el total de análisis con delta porcentual respecto al día anterior, desglose por nivel de riesgo, top tres tipos de estafa, inyecciones detectadas con IPs únicas, reparto por método de autenticación, rate-limit hits y actividad de urlscan y OCR. Si no hubo absolutamente ninguna actividad el día no se publica, porque un push diario diciendo "cero análisis" sería tan silencioso como útil, es decir, nada.
La lógica está partida en dos. Por un lado, computeDigest, una función pura que recibe los arrays de entradas ya leídas y devuelve un objeto digest con los conteos y breakdowns. Por otro, formatDigestMessage, que toma ese objeto y produce el texto a publicar. Separarlas así me permite testear los cálculos con inputs controlados, sin leer disco ni llamar a notify, y iterar el formato sin tocar la lógica. El resultado son unas 35 líneas de tests cubriendo casos con cero actividad, con comparativa disponible o sin ella, con inyecciones y sin, con riesgo repartido y con todo cargando a un solo nivel.
Uso de disco de /app/data. Una vez por hora, fs.statfs sobre el volumen nombrado que tengo montado en Dokploy. Si el uso supera el 85%, push con prioridad 4 indicando cuántos megas quedan libres. Lleva un cooldown de 24 horas sobre la misma clave para no recibir 24 avisos al día del mismo problema. En un proyecto con logs que se purgan cada 7 días no debería llegar a pasar nunca, pero tener el aviso significa que si algún día falla la limpieza me entero antes de que se llene el disco.
Pico de tráfico. Cada cinco minutos, el monitor mira los análisis de la última hora y los compara con la media por hora de las 24 previas. Si la última hora es al menos tres veces la media y tiene al menos diez peticiones, push con prioridad 3. El mínimo absoluto es importante. Sin él, un día con poquísimo tráfico generaría falsos positivos en cuanto alguien hiciera cinco análisis seguidos. Con un cooldown de seis horas evito que una campaña sostenida me envíe avisos cada cinco minutos durante toda la tarde.
ScamDetector ya tenía la política de no usar ningún framework de tests, solo el runner nativo node:test, y eso encajaba bien con los monitores. Todas las funciones impuras están aisladas en las impuras y las puras se exportan por un module.exports._internal que el código de producción no toca. Desde los tests, importo el módulo y llamo directamente a computeDigest, computeSpike, diskUsageRatio o msUntilNext con inputs fabricados.
El test más útil que escribí fue para msUntilNext, que calcula cuántos milisegundos faltan hasta la próxima ocurrencia de una hora concreta del día. Es trivial si no cruzas medianoche. Se complica en el caso de "son las 21:05 y el digest es a las 21:00". Al principio mi implementación devolvía un número negativo y el setTimeout disparaba inmediatamente, provocando que el digest se enviara dos veces seguidas. Un test con now inyectado lo cazó a la primera.
Desde que puse en marcha ntfy dejé de abrir el terminal a ver los logs. Si algo pasa, me llega al móvil con el nivel de ruido adecuado. Si no pasa nada, el resumen diario a las 21:00 me confirma que el sistema sigue respondiendo y me ahorra la comprobación manual. Puedo ver de un vistazo si ayer hubo pico de inyecciones, si la latencia media sube, si alguien probó bearers falsos.
Lo interesante del experimento es que api/notify.js y api/stats-monitor.js no tienen nada específico de ScamDetector. Son dos ficheros pequeños que hablan con una URL. Ya los he copiado tal cual a otros dos proyectos del homelab con pequeños ajustes en las reglas de lo que cuenta como evento. Ntfy se está convirtiendo en mi capa estándar de observabilidad ligera, la que uso cuando un stack de Grafana-Loki-Prometheus sería usar un martillo para clavar un alfiler.
El código está en scamdetector.josemanuelortega.dev y el repositorio sigue siendo público. Si acabas montando algo parecido y encuentras una forma mejor de clasificar los eventos, cuéntamelo.

Cuatro cambios para que la privacidad de ScamDetector deje de ser un banner y esté en el código. Routing forzado a Google Vertex con Zero Data Retention, selector de envío con ofuscación server-side, política RGPD reescrita desde cero y fuentes autohospedadas para no filtrar la IP del usuario a Google.

Qué cambió en ScamDetector después de publicar. Tercer backend de IA, rate limiting persistente, logging con privacidad, pasos de acción por tipo de estafa, compartir resultados como imagen y PWA.

Cómo está construido ScamDetector por dentro, desde el proxy server-side que protege las claves de API hasta los tres backends de IA intercambiables y las medidas de seguridad.