[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build a CAPTCHA Verification System

Build a CAPTCHA Verification System

Build a custom CAPTCHA system with image challenges, invisible risk scoring, accessibility alternatives, rate limiting, and bot detection without relying on third-party services.

Development#typescript#tsconfig#type-checking#type-safety#javascript-migration
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 4 skills

Avg quality 95/100·All SAFE
>

typescript

v1.0.0

Sets up and repairs TypeScript projects: picks tsconfig.json settings for a Node.js service, an npm library or a bundled web app, runs type-checking in CI, and replaces `any` and unsafe casts with types the compiler can verify. Use when someone asks to "set up TypeScript", "fix this tsconfig", "fix a TypeScript error", "upgrade to TypeScript 6 or 7", "migrate JavaScript to TypeScript", "publish types for a package", "run .ts files in Node" or "make this type-safe". Covers the changed defaults in TypeScript 6.0 and 7.0, module and moduleResolution, strict mode, project references, declaration output, narrowing, satisfies and common compiler errors.

93/100 quality
7.30× impact
SAFE· stale score
View skill
>

redis

v1.1.0

Redis is an in-memory data store used as a cache, session store, message broker and counter service. Use when adding a cache layer, storing sessions, rate limiting an API, running pub/sub or Streams with consumer groups, taking a distributed lock, building a leaderboard, or configuring Redis for production (persistence, maxmemory, eviction).

93/100 quality
1.79× impact
SAFE· stale score
View skill
>

hono

v1.1.0

Hono is a small web framework built on Web Standards (Request/Response) that runs unchanged on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda and Vercel. Use it when the user asks to build a Hono API, add Zod validation, JWT auth, CORS or middleware, use the type-safe RPC client (hc), render JSX, stream responses, or deploy one codebase to several runtimes.

100/100 quality
6.20× impact
SAFE· stale score
View skill
>

zod

v1.1.0

Zod is a TypeScript-first schema library that validates data at runtime and infers static types from the same schema. Use when validating API request bodies, form input, environment variables or config files, parsing untrusted JSON, or migrating from Zod 3 to Zod 4. Covers z.object, safeParse, z.infer, coercion, discriminated unions, refinements, JSON Schema output and the v4 API changes.

93/100 quality
3.87× impact
SAFE· stale score
View skill
$

The Problem

Sam leads security at a 20-person platform. Bots submit 10K fake registrations daily. They used reCAPTCHA but it sends user data to Google (GDPR concern), adds 2-3 seconds load time, and blocks 8% of legitimate users (especially on mobile). Some users can't solve image puzzles due to visual impairments. They need a privacy-preserving CAPTCHA: no third-party data sharing, fast, accessible, and smart enough to only challenge suspicious traffic.

Step 1: Build the CAPTCHA Engine

typescript
// src/captcha/engine.ts — Custom CAPTCHA with risk scoring and accessible challenges
import { Redis } from "ioredis";
import { createHash, randomBytes, randomInt } from "node:crypto";
import sharp from "sharp";

const redis = new Redis(process.env.REDIS_URL!);

interface ChallengeResult {
  challengeId: string;
  type: "image_select" | "math" | "text_distorted" | "invisible";
  imageUrl?: string;
  question?: string;
  options?: string[];
  expiresIn: number;
}

interface VerifyResult {
  valid: boolean;
  score: number;
  token: string | null;
}

// Risk scoring (invisible CAPTCHA layer)
export async function assessRisk(context: {
  ip: string;
  userAgent: string;
  headers: Record<string, string>;
  mouseMovements?: number;
  keystrokes?: number;
  timeOnPage?: number;
}): Promise<{ score: number; needsChallenge: boolean; challengeType: ChallengeResult["type"] }> {
  let score = 0;

  // Missing browser headers
  if (!context.headers["accept-language"]) score += 15;
  if (!context.headers["accept"]) score += 10;

  // Bot-like User-Agent
  const ua = context.userAgent.toLowerCase();
  if (!ua || ua.length < 20) score += 25;
  if (["bot", "crawl", "curl", "wget", "python", "node-fetch"].some((b) => ua.includes(b))) score += 40;

  // Rate analysis
  const minuteKey = `captcha:rate:${context.ip}:${Math.floor(Date.now() / 60000)}`;
  const requests = await redis.incr(minuteKey);
  await redis.expire(minuteKey, 120);
  if (requests > 10) score += 20;
  if (requests > 30) score += 30;

  // Behavioral signals (from client-side SDK)
  if (context.mouseMovements !== undefined) {
    if (context.mouseMovements === 0) score += 20; // no mouse = likely bot
    if (context.timeOnPage !== undefined && context.timeOnPage < 500) score += 25; // too fast
  }

  if (context.keystrokes !== undefined && context.keystrokes === 0) score += 10;

  score = Math.min(score, 100);

  let challengeType: ChallengeResult["type"] = "invisible";
  if (score >= 60) challengeType = "image_select";
  else if (score >= 30) challengeType = "math";

  return { score, needsChallenge: score >= 30, challengeType };
}

// Generate challenge
export async function generateChallenge(type: ChallengeResult["type"]): Promise<ChallengeResult> {
  const challengeId = randomBytes(16).toString("hex");

  switch (type) {
    case "math": {
      const a = randomInt(1, 50);
      const b = randomInt(1, 50);
      const ops = ["+", "-", "×"] as const;
      const op = ops[randomInt(0, 3)];
      let answer: number;
      switch (op) {
        case "+": answer = a + b; break;
        case "-": answer = a - b; break;
        case "×": answer = a * b; break;
      }

      await redis.setex(`captcha:${challengeId}`, 300, JSON.stringify({
        type: "math", answer: String(answer),
      }));

      return {
        challengeId, type: "math",
        question: `What is ${a} ${op} ${b}?`,
        expiresIn: 300,
      };
    }

    case "text_distorted": {
      const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
      const text = Array.from({ length: 6 }, () => chars[randomInt(0, chars.length)]).join("");

      const image = await generateDistortedText(text);

      await redis.setex(`captcha:${challengeId}`, 300, JSON.stringify({
        type: "text_distorted", answer: text.toUpperCase(),
      }));

      // Store image temporarily
      await redis.setex(`captcha:img:${challengeId}`, 300, image);

      return {
        challengeId, type: "text_distorted",
        imageUrl: `/api/captcha/image/${challengeId}`,
        question: "Type the characters shown in the image",
        expiresIn: 300,
      };
    }

    case "image_select": {
      // Generate grid of shapes, ask user to select specific ones
      const shapes = ["circle", "square", "triangle", "star"];
      const targetShape = shapes[randomInt(0, shapes.length)];
      const grid = Array.from({ length: 9 }, () => shapes[randomInt(0, shapes.length)]);
      const correctIndices = grid.map((s, i) => s === targetShape ? i : -1).filter((i) => i >= 0);

      // Ensure at least 2 correct
      if (correctIndices.length < 2) {
        grid[randomInt(0, 9)] = targetShape;
        grid[randomInt(0, 9)] = targetShape;
      }

      const image = await generateShapeGrid(grid);

      await redis.setex(`captcha:${challengeId}`, 300, JSON.stringify({
        type: "image_select",
        answer: grid.map((s, i) => s === targetShape ? i : -1).filter((i) => i >= 0).sort().join(","),
      }));

      await redis.setex(`captcha:img:${challengeId}`, 300, image);

      return {
        challengeId, type: "image_select",
        imageUrl: `/api/captcha/image/${challengeId}`,
        question: `Select all squares containing a ${targetShape}`,
        expiresIn: 300,
      };
    }

    default: {
      // Invisible — just a token
      const token = randomBytes(32).toString("hex");
      await redis.setex(`captcha:${challengeId}`, 300, JSON.stringify({ type: "invisible", token }));
      return { challengeId, type: "invisible", expiresIn: 300 };
    }
  }
}

// Verify challenge response
export async function verifyChallengeResponse(
  challengeId: string,
  response: string
): Promise<VerifyResult> {
  const stored = await redis.get(`captcha:${challengeId}`);
  if (!stored) return { valid: false, score: 0, token: null };

  // One-time use
  await redis.del(`captcha:${challengeId}`);
  await redis.del(`captcha:img:${challengeId}`);

  const challenge = JSON.parse(stored);
  const normalizedResponse = response.toUpperCase().trim();
  const normalizedAnswer = String(challenge.answer).toUpperCase().trim();

  const valid = normalizedResponse === normalizedAnswer;

  if (valid) {
    const token = randomBytes(32).toString("hex");
    await redis.setex(`captcha:verified:${token}`, 600, "1"); // valid for 10 min
    return { valid: true, score: 100, token };
  }

  return { valid: false, score: 0, token: null };
}

// Validate token (use in form submission)
export async function validateToken(token: string): Promise<boolean> {
  const result = await redis.get(`captcha:verified:${token}`);
  if (result) {
    await redis.del(`captcha:verified:${token}`); // one-time use
    return true;
  }
  return false;
}

// Image generation helpers
async function generateDistortedText(text: string): Promise<Buffer> {
  const width = 280;
  const height = 80;

  // Create SVG with distorted text
  const chars = text.split("").map((char, i) => {
    const x = 30 + i * 40;
    const y = 45 + randomInt(-10, 10);
    const rotate = randomInt(-20, 20);
    const color = `rgb(${randomInt(0, 100)},${randomInt(0, 100)},${randomInt(0, 100)})`;
    return `<text x="${x}" y="${y}" transform="rotate(${rotate},${x},${y})" fill="${color}" font-size="${randomInt(28, 38)}" font-family="monospace">${char}</text>`;
  }).join("");

  // Add noise lines
  const lines = Array.from({ length: 5 }, () => {
    const x1 = randomInt(0, width), y1 = randomInt(0, height);
    const x2 = randomInt(0, width), y2 = randomInt(0, height);
    return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="rgba(0,0,0,0.2)" stroke-width="2"/>`;
  }).join("");

  const svg = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
    <rect width="100%" height="100%" fill="#F5F5F5"/>
    ${lines}${chars}
  </svg>`;

  return sharp(Buffer.from(svg)).png().toBuffer();
}

async function generateShapeGrid(shapes: string[]): Promise<Buffer> {
  const cellSize = 80;
  const size = cellSize * 3;
  const cells = shapes.map((shape, i) => {
    const col = i % 3, row = Math.floor(i / 3);
    const cx = col * cellSize + cellSize / 2;
    const cy = row * cellSize + cellSize / 2;
    const r = 25;

    switch (shape) {
      case "circle": return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="#3B82F6" />`;
      case "square": return `<rect x="${cx - r}" y="${cy - r}" width="${r * 2}" height="${r * 2}" fill="#EF4444" />`;
      case "triangle": return `<polygon points="${cx},${cy - r} ${cx - r},${cy + r} ${cx + r},${cy + r}" fill="#22C55E" />`;
      case "star": return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="#EAB308" />`;
      default: return "";
    }
  }).join("");

  const gridLines = Array.from({ length: 4 }, (_, i) => {
    const pos = i * cellSize;
    return `<line x1="${pos}" y1="0" x2="${pos}" y2="${size}" stroke="#DDD" />
            <line x1="0" y1="${pos}" x2="${size}" y2="${pos}" stroke="#DDD" />`;
  }).join("");

  const svg = `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
    <rect width="100%" height="100%" fill="white"/>${gridLines}${cells}
  </svg>`;

  return sharp(Buffer.from(svg)).png().toBuffer();
}

Results

  • No third-party data sharing — all CAPTCHA processing happens on their servers; GDPR compliant; no Google tracking pixels
  • Smart challenge selection — low-risk users pass invisibly (0ms friction); medium-risk get a math question (3 seconds); high-risk get image grid (8 seconds)
  • Fake registrations: 10K/day → 50/day — 99.5% bot reduction; legitimate user block rate dropped from 8% to 0.5%
  • Accessible alternatives — math challenges work with screen readers; no "select all traffic lights" that visually impaired users can't solve
  • Page load: -3 seconds — no external reCAPTCHA script; CAPTCHA assets loaded only when challenge needed; faster pages, better SEO

Related use cases