Esta guía es para Fastify 5, y para NestJS 12 tal como lo crea nest new (un proyecto de módulos ES sobre Express), en Node 22 o posterior, con el archivo del cliente que usa la guía de Express: korzen.js, un solo archivo sin dependencias, que pregunta a /v1/decide, se rinde al llegar a tu timeout y deja pasar al visitante ante cualquier error.
El tag
Pon el tag en el <head> del layout que renderiza tus páginas de registro, inicio de sesión y checkout:
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
En la página, antes de enviar el formulario, llama a await window.korzen?.ready(): informa lo que el visitante hizo desde el último informe del tag y se resuelve en pocos segundos pase lo que pase. El tag
La llamada
Descarga el cliente en tu proyecto y léelo antes de hacer commit:
curl -o src/korzen.js https://korzen.dev/bot-protection/sdk/korzen.js curl -o src/korzen.d.ts https://korzen.dev/bot-protection/sdk/korzen.d.ts # types, for TypeScript
El fetch de Node cierra una conexión tras 4 segundos inactiva; un Agent de undici con un keepAliveTimeout más largo, pasado como dispatcher, la mantiene abierta (consulta Express para saber por qué undici 7).
Fastify
Un hook preHandler en la ruta que crea la cuenta. Guarda la respuesta en request.korzen, o null cuando no la hay. fromNodeRequest(request.raw) lee la petición propia de Node, así que la llamada lleva el orden en que llegaron los headers:
// app.js import Fastify from "fastify"; import formbody from "@fastify/formbody"; import { Agent } from "undici"; // optional (npm install undici@7): keeps the connection open between calls import { createClient, fromNodeRequest } from "./src/korzen.js"; const korzen = createClient({ apiKey: process.env.KORZEN_API_KEY, url: "https://korzen.dev", timeoutMs: 200, dispatcher: new Agent({ keepAliveTimeout: 300_000 }) }); const app = Fastify({ trustProxy: "loopback,uniquelocal" }); // your proxy on a private network: see "The visitor's IP" app.decorateRequest("korzen", null); await app.register(formbody); // Asks before the handler, and keeps the answer on request.korzen (null when there is none). async function guard(request, reply) { request.korzen = await korzen.decide(fromNodeRequest(request.raw, { ip: request.ip })); if (request.korzen?.action === "block") return reply.code(400).send("We could not create your account."); // "challenge": your CAPTCHA, or an email or phone check, before going on } app.post("/signup", { preHandler: guard }, async (request, reply) => { const user = await createAccount(request.body); // your own code await saveRequestID(user.id, request.korzen?.request_id); // see "Keep the request_id" return reply.redirect("/welcome", 303); }); await app.listen({ port: 3000 });
NestJS
NestJS corre sobre Express, así que protect, el middleware de Express del cliente, protege la ruta en main.ts. Un proyecto nuevo solo compila TypeScript en dist/: añade korzen.js a los assets de nest-cli.json, para que el build lo copie junto a main.js:
{
"sourceRoot": "src",
"compilerOptions": { "assets": ["korzen.js"] }
}
// src/main.ts import { NestFactory } from "@nestjs/core"; import type { NestExpressApplication } from "@nestjs/platform-express"; import { Agent } from "undici"; // optional (npm install undici@7): keeps the connection open between calls import { AppModule } from "./app.module.js"; import { createClient, protect } from "./korzen.js"; const korzen = createClient({ apiKey: process.env.KORZEN_API_KEY!, url: "https://korzen.dev", timeoutMs: 200, dispatcher: new Agent({ keepAliveTimeout: 300_000 }) }); const app = await NestFactory.create<NestExpressApplication>(AppModule); app.set("trust proxy", 1); // one proxy of yours in front: see "The visitor's IP" // Asks before every route under /signup; the answer is on req.korzen, or null when there is none. app.use("/signup", protect({ client: korzen, ip: (_get, req) => req.ip, // with "trust proxy" set onBlock: (_req, res) => res.status(400).send("We could not create your account."), // onChallenge: (req, res, next, d) => your CAPTCHA, or an email or phone check, then next() })); await app.listen(process.env.PORT ?? 3000);
Después, en el controller, la respuesta está en la petición:
// src/signup.controller.ts import { Body, Controller, Get, Post, Redirect, Req } from "@nestjs/common"; import type { Request } from "express"; import type { Decision } from "./korzen.js"; @Controller("signup") export class SignupController { @Get() // the form's page: a challenge meets the person here form() { return renderSignupForm(); // your own code } @Post() @Redirect("/welcome", 303) async create(@Req() req: Request & { korzen?: Decision | null }, @Body() body: Record<string, string>) { const user = await createAccount(body); // your own code await saveRequestID(user.id, req.korzen?.request_id); // see "Keep the request_id" } }
Sin onChallenge, protect no deja pasar ningún desafío: una carga de página (GET) va a nuestra página de desafío y vuelve, y el envío de un formulario se rechaza con 403. Proteger /signup protege también la página del formulario, para que la persona encuentre el desafío allí y su pase deje pasar el envío.
La IP del visitante
Fastify lee X-Forwarded-For solo de los proxies que nombra trustProxy, por dirección o por red: "loopback,uniquelocal" confía en un proxy en esta máquina o en una red privada (10.x, 172.16–31.x, 192.168.x, fc00::/7), y request.ip es entonces la entrada que añadió ese proxy. Fastify 5 no acepta un número de saltos: un número no confía en nada. true confía en todas las entradas, y la primera la escribe el visitante. En NestJS sobre Express, trust proxy es el número de proxies que hay delante, como en la guía de Express. Detrás de Cloudflare, lee CF-Connecting-IP (ipFrom.cloudflare), si tu servidor solo recibe tráfico de Cloudflare.
Actúa según la action
Lee solo action. Con block, responde con el mismo error que la acción muestra cuando falla por cualquier otro motivo, para que un bot no aprenda nada. Con challenge, usa la verificación que ya tienes (un CAPTCHA, un código por email o teléfono); si no tienes ninguna, sigue adelante. La cuenta empieza en monitor mode, donde action es siempre allow: nada cambia para los visitantes hasta que actives el block mode.
Guarda el request_id
Cada respuesta trae un request_id. Guárdalo con lo que la acción creó, en una columna de la cuenta o del pedido, o en una línea de log con su id. Sirve para identificar esa decisión cuando nos preguntes por ella y para averiguar después qué respuesta dejó entrar una cuenta falsa. Cuando la cuenta resulte buena o mala, avísanos: await korzen.feedback([{ request_id, outcome: "chargeback" }]) (consulta feedback).
Pruébalo en tu máquina
Arranca tu servidor con node --env-file=.env app.js (en NestJS, npm run start:dev) y ábrelo en localhost. Las páginas en localhost siempre aceptan el tag y nunca aparecen como dominio. En local la IP es 127.0.0.1 o ::1, y es normal.
- Define
KORZEN_API_KEYen.envy abre la página de registro. En las herramientas de desarrollo del navegador, la cookie_kzaparece tras unos segundos. - Regístrate. La decisión aparece en la página Decisiones del panel, con el path que enviaste.
- Comprueba el fail open: pon una clave incorrecta y regístrate de nuevo. La llamada responde
401, y el registro pasa.
Errores comunes
trustProxy: 1en Fastify 5. No confía en ningún proxy, así querequest.ipes la dirección de tu load balancer para todos los visitantes.trustProxy: true, otrust proxyatrueen NestJS. Confía en todos los saltos, y la IP pasa a ser lo que el visitante escribió enX-Forwarded-For.- En NestJS,
korzen.jsfuera de los assets denest-cli.json: el build no tienedist/korzen.jsy el servidor no arranca. - Llamar a
/v1/decidedesde el navegador. La API key quedaría expuesta a cada visitante; la llamada se hace desde tu servidor. - No poner timeout, o dejar que un error detenga el registro. Cualquier respuesta distinta de
200, o la falta de respuesta a tiempo, significa: deja pasar al visitante.