npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mafesoftware/limite-intentos

v0.1.1

Published

Freno a la fuerza bruta en el ingreso (login/attempt throttling), persistido en Postgres por cuenta y por IP — nunca en memoria (las instancias de Vercel no comparten memoria). claveCuenta/claveIp en el núcleo; en /drizzle, tablaIntentos + registrarIntent

Readme

@mafesoftware/limite-intentos

Freno a la fuerza bruta en el ingreso (login/attempt throttling), persistido siempre en Postgres — nunca en memoria: en Vercel cada request puede caer en otra instancia (y las funciones se reciclan), así que un contador en RAM se reinicia solo y no frena nada.

Se cuenta por cuenta (claveCuenta) y por IP (claveIp) — las dos hacen falta:

  • Por cuenta frena el ataque clásico: mil contraseñas contra un mail.
  • Por IP frena el spraying: una contraseña ("club2026") contra mil mails distintos, que no mueve ningún contador por cuenta.

Núcleo puro: sin variables de entorno, sin framework, sin base de datos. Lo específico de Drizzle (la tabla y las tres operaciones que la usan) vive en el subpath /drizzle (drizzle-orm como peerDependency opcional, >=0.45 <0.46).

bun add @mafesoftware/limite-intentos

API

Núcleo (@mafesoftware/limite-intentos)

claveCuenta(email: string): string

La clave (PK de la tabla) de una CUENTA: baja a minúsculas y recorta espacios antes de prefijar con "cuenta:", para que variar mayúsculas o espacios no sirva para evadir el freno. Tira ErrorLimiteIntentos si email está vacío.

import { claveCuenta } from "@mafesoftware/limite-intentos";

claveCuenta("  [email protected] "); // "cuenta:[email protected]"

claveIp(ip: string): string

La clave de una IP: recorta espacios y prefija con "ip:" — NO baja a minúsculas (una IPv4/IPv6 no gana nada normalizando el caso). Queda en su propio espacio de nombres, separado de claveCuenta: el mismo texto nunca choca entre las dos. Tira ErrorLimiteIntentos si ip está vacía.

import { claveIp } from "@mafesoftware/limite-intentos";

claveIp("203.0.113.7"); // "ip:203.0.113.7"

ErrorLimiteIntentos

El único error que tira este paquete, siempre por un error de programación (una clave vacía, o una opción numérica que no es un entero finito positivo) — nunca por datos que mandó quien intenta iniciar sesión. codigo: "opciones_invalidas".

import { ErrorLimiteIntentos, claveCuenta } from "@mafesoftware/limite-intentos";

try {
  claveCuenta("");
} catch (error) {
  if (error instanceof ErrorLimiteIntentos) error.codigo; // "opciones_invalidas"
}

ErrorLimiteIntentos también se re-exporta desde el subpath /drizzle, para quien ya importa de ahí registrarIntento/consultarIntento y no quiere un segundo import del núcleo solo para el instanceof:

import { ErrorLimiteIntentos, registrarIntento } from "@mafesoftware/limite-intentos/drizzle";
import { claveCuenta } from "@mafesoftware/limite-intentos";

try {
  await registrarIntento(db, limiteIntentos, {
    clave: claveCuenta(email),
    maximo: 0, // inválido: dispara ErrorLimiteIntentos antes de tocar la base
    ventanaMs: 15 * 60_000,
    bloqueoMs: 15 * 60_000,
  });
} catch (error) {
  if (error instanceof ErrorLimiteIntentos) error.codigo; // "opciones_invalidas"
}

Drizzle (@mafesoftware/limite-intentos/drizzle)

tablaIntentos(opciones?: { nombre?; columnasExtra? }): TablaIntentos

La tabla del freno: clave (text, PRIMARY KEY — claveCuenta(email) o claveIp(ip)), contador (integer, default 0), ventana_desde (timestamptz, default now()), bloqueado_hasta (timestamptz, nullable) y actualizado_en (timestamptz, default now()).

Sin columna de tenant: ninguna función de este paquete recibe ni escribe un tenantId — todas operan solo por clave. Si tu app necesita filtrar o reportar por tenant, agregala vos como una columna más en columnasExtra (este paquete no la va a tocar).

import { tablaIntentos } from "@mafesoftware/limite-intentos/drizzle";

// Con los defaults: tabla "limite_intentos".
export const limiteIntentos = tablaIntentos();

// Con otro nombre de tabla:
export const limiteIntentosTienda = tablaIntentos({ nombre: "limite_intentos_tienda" });

registrarIntento(db, tabla, { clave, maximo, ventanaMs, bloqueoMs, ahora? }): Promise<{ permitido; restantes; desbloqueaEn }>

Registra UN intento fallido para clave y devuelve si, después de este registro, se puede seguir intentando. No exige transacción: es una única sentencia SQL atómica (INSERT ... ON CONFLICT (clave) DO UPDATE), así que se puede llamar con db directo o con una tx.

Ventana fija con auto-reinicio: si ventana_desde es anterior a ahora - ventanaMs, arranca una ventana nueva (contador = 1, ventana_desde = ahora); si la ventana sigue vigente, incrementa contador. En los dos casos, si el contador resultante llega a maximo, fija bloqueado_hasta = ahora + bloqueoMs — y si sigue llegando fuerza bruta dentro del bloqueo (y de la ventana), cada intento nuevo extiende bloqueado_hasta, en vez de vencer a mitad de un ataque sostenido.

El bloqueo no se limpia solo: bloqueado_hasta se queda con la fecha fijada aunque ya haya pasado — lo que cambia es que una lectura posterior (registrarIntento o consultarIntento) la compara contra un ahora más nuevo y ya no la encuentra "en el futuro". Sin cron de "desbloqueo".

Atómico bajo concurrencia, con un único INSERT ... ON CONFLICT DO UPDATE: dos llamadas concurrentes para la MISMA clave se serializan una detrás de la otra en el lock de esa fila — ninguna pisa el incremento de la otra (probado con 20 llamadas verdaderamente concurrentes contra Postgres real, cada una con su propia conexión de un pool de 20+, en tests/drizzle/postgres.test.ts).

Valida antes de tocar la base: clave no puede estar vacía; maximo/ventanaMs/bloqueoMs tienen que ser enteros finitos >= 1 — todo tira ErrorLimiteIntentos("opciones_invalidas").

import { registrarIntento } from "@mafesoftware/limite-intentos/drizzle";
import { claveCuenta } from "@mafesoftware/limite-intentos";

const { permitido, restantes, desbloqueaEn } = await registrarIntento(db, limiteIntentos, {
  clave: claveCuenta(email),
  maximo: 10,
  ventanaMs: 15 * 60_000,
  bloqueoMs: 15 * 60_000,
});
if (!permitido) {
  // "Demasiados intentos, probá de nuevo después de desbloqueaEn"
}

consultarIntento(db, tabla, { clave, ahora? }): Promise<{ contador; ventanaDesde; bloqueado; desbloqueaEn }>

Lee el estado de clave SIN escribir nada — no cuenta como un intento. Pensada para chequear ANTES de intentar autenticar (evita gastar el costo de comparar contraseña con bcrypt/argon2 en una cuenta ya bloqueada). No recibe maximo: bloqueado/desbloqueaEn salen únicamente de comparar bloqueado_hasta contra ahora. Si la clave nunca registró ningún intento, devuelve { contador: 0, ventanaDesde: null, bloqueado: false, desbloqueaEn: null }.

import { consultarIntento } from "@mafesoftware/limite-intentos/drizzle";
import { claveCuenta } from "@mafesoftware/limite-intentos";

const { bloqueado, desbloqueaEn } = await consultarIntento(db, limiteIntentos, { clave: claveCuenta(email) });
if (bloqueado) {
  // "Demasiados intentos, probá de nuevo después de desbloqueaEn" — sin llegar a comparar la contraseña.
}

limpiarIntentos(db, tabla, clave): Promise<void>

Borra la fila de clave — se llama cuando el intento SALIÓ BIEN (login correcto), para que la cuenta/IP arranque limpia la próxima vez que alguien se equivoque. No limpia la IP automáticamente al limpiar la cuenta (una IP puede tener muchas cuentas detrás): quien llama decide si también quiere limpiar claveIp(ip).

import { limpiarIntentos } from "@mafesoftware/limite-intentos/drizzle";
import { claveCuenta } from "@mafesoftware/limite-intentos";

// Login correcto: limpia el contador de la cuenta (no el de la IP).
await limpiarIntentos(db, limiteIntentos, claveCuenta(email));

Flujo completo

import { tablaIntentos, registrarIntento, consultarIntento, limpiarIntentos } from "@mafesoftware/limite-intentos/drizzle";
import { claveCuenta, claveIp } from "@mafesoftware/limite-intentos";

export const limiteIntentos = tablaIntentos();

async function ingresar(email: string, contrasena: string, ip: string) {
  const cuenta = claveCuenta(email);
  const ipClave = claveIp(ip);

  // 1) Chequear ANTES de comparar contraseña (evita el costo de bcrypt en una cuenta ya bloqueada).
  const previo = await consultarIntento(db, limiteIntentos, { clave: cuenta });
  if (previo.bloqueado) throw new Error("demasiados intentos");

  const credencialesOk = await verificarContrasena(email, contrasena);
  if (!credencialesOk) {
    // 2) Falló: registrar por cuenta Y por IP.
    const [porCuenta] = await Promise.all([
      registrarIntento(db, limiteIntentos, { clave: cuenta, maximo: 10, ventanaMs: 15 * 60_000, bloqueoMs: 15 * 60_000 }),
      registrarIntento(db, limiteIntentos, { clave: ipClave, maximo: 30, ventanaMs: 15 * 60_000, bloqueoMs: 15 * 60_000 }),
    ]);
    throw new Error(porCuenta.permitido ? "credenciales inválidas" : "demasiados intentos");
  }

  // 3) Login correcto: limpiar el contador de la cuenta (la IP se deja como está).
  await limpiarIntentos(db, limiteIntentos, cuenta);
}

Postgres para los tests de /drizzle

tests/drizzle/postgres.test.ts prueba las garantías de concurrencia contra Postgres real — no hay mock que valga para el bloqueo de fila que las sostiene: 10 fallos que bloquean con el reloj inyectado, el bloqueo que se extiende si sigue llegando fuerza bruta, el desbloqueo (por lectura, sin escritura) una vez que pasa bloqueoMs, el reinicio de ventana sin llegar al máximo, cuenta e IP independientes, 20 llamadas VERDADERAMENTE concurrentes sobre la misma clave (contador final exactamente 20, probado con un pool de 20+ conexiones y también con pg_sleep sosteniendo el lock de fila para confirmar contención real), limpiarIntentos y las validaciones. El DDL que ejecuta no está escrito a mano: sale del MISMO esquema de Drizzle que arma tablaIntentos (tests/drizzle/esquema.ts), generado con drizzle-kit/api — igual que sql/ejemplo.sql.

Levantalo con docker compose up -d db_test desde la raíz del monorepo antes de correr bun run test — si no está arriba, ese archivo FALLA con un mensaje claro (no se saltea en silencio). La conexión la arma poolDePrueba() (tests/lib/postgres-de-prueba.ts en la raíz). tests/drizzle/config.test.ts (la verificación estructural con getTableConfig, sin tocar la base) corre siempre, con o sin Docker. bun run test:sin-db excluye solo postgres.test.ts.

El aislamiento entre corridas es por NOMBRE DE TABLA en public (la firma pública de tablaIntentos no tiene un parámetro de schema de Postgres), igual que packages/numeradores.