{"_id":"@beekamai/pow-captcha","name":"@beekamai/pow-captcha","dist-tags":{"latest":"0.1.0"},"versions":{"0.1.0":{"name":"@beekamai/pow-captcha","version":"0.1.0","description":"Stateless HMAC proof-of-work captcha for Bun and Node — no images, no third party, no cookies. Ships a framework-agnostic browser solver.","type":"module","main":"./dist/index.cjs","module":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./client":{"types":"./dist/client.d.ts","import":"./dist/client.js","require":"./dist/client.cjs"}},"sideEffects":false,"keywords":["captcha","proof-of-work","pow","altcha","bot-protection","hmac","bun","typescript"],"license":"MIT","author":{"name":"beekamai"},"repository":{"type":"git","url":"git+https://github.com/beekamai/pow-captcha.git"},"homepage":"https://github.com/beekamai/pow-captcha#readme","bugs":{"url":"https://github.com/beekamai/pow-captcha/issues"},"scripts":{"dev":"bun run examples/serve.ts","test":"bun test","typecheck":"tsc --noEmit","build":"tsup","prepublishOnly":"bun run build"},"devDependencies":{"@types/bun":"^1.1.0","tsup":"^8.0.0","typescript":"^5.6.0"},"engines":{"node":">=18","bun":">=1.1.0"},"_id":"@beekamai/pow-captcha@0.1.0","gitHead":"2772e169f4f5fed761ce9bbbf63b312d90068652","_nodeVersion":"22.15.1","_npmVersion":"10.9.2","dist":{"integrity":"sha512-zpFVkvTex/5kid+ILGhBru0mfAoTbPA3ftg/5YNi3A2Mgm2EmeiGpn3H0dmHo7/PjfL3IrSjnTR4EmhLaa82vw==","shasum":"4ce06c0e96ca6884aa59a717da6f803d934dbc94","tarball":"https://registry.npmjs.org/@beekamai/pow-captcha/-/pow-captcha-0.1.0.tgz","fileCount":15,"unpackedSize":102629,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQDKlxpvA1r4Kq4eZyWaJiL+JE8WgY2Cp9rmxO8aG1/4AAIgak6P56zyLbzpG515sQAGxES9av0JS89KangMJQvRuEU="}]},"_npmUser":{"name":"beekamai","email":"jaroslavobida@gmail.com"},"directories":{},"maintainers":[{"name":"beekamai","email":"jaroslavobida@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/pow-captcha_0.1.0_1788364606038_0.3997441963475201"},"_hasShrinkwrap":false}},"time":{"created":"2026-09-02T15:56:45.639Z","0.1.0":"2026-09-02T15:56:46.176Z","modified":"2026-09-02T15:56:46.554Z"},"maintainers":[{"name":"beekamai","email":"jaroslavobida@gmail.com"}],"description":"Stateless HMAC proof-of-work captcha for Bun and Node — no images, no third party, no cookies. Ships a framework-agnostic browser solver.","homepage":"https://github.com/beekamai/pow-captcha#readme","keywords":["captcha","proof-of-work","pow","altcha","bot-protection","hmac","bun","typescript"],"repository":{"type":"git","url":"git+https://github.com/beekamai/pow-captcha.git"},"author":{"name":"beekamai"},"bugs":{"url":"https://github.com/beekamai/pow-captcha/issues"},"license":"MIT","readme":"# @beekamai/pow-captcha\r\n\r\n![TypeScript](https://img.shields.io/badge/TypeScript-5-3178c6?logo=typescript&logoColor=white)\r\n![Bun](https://img.shields.io/badge/Bun-%E2%89%A51.1-000000?logo=bun&logoColor=white)\r\n![Dependencies](https://img.shields.io/badge/runtime%20deps-0-3da639)\r\n![License](https://img.shields.io/badge/license-MIT-3da639)\r\n\r\n🇬🇧 [English](#english) · 🇷🇺 [Русский](#русский)\r\n\r\n---\r\n\r\n## English\r\n\r\nA **stateless proof-of-work captcha**: no images, no third-party service, no cookies, no tracking.\r\nThe server hands out a salt and the SHA-256 hash of a hidden number; the browser brute-forces\r\nnumbers until one hashes to that value and sends the answer back. Verification is a single HMAC\r\ncheck — the server keeps no table of issued challenges.\r\n\r\nThe challenge format follows [Altcha](https://altcha.org), so the wire shape will look familiar; the\r\nimplementation, the pass tokens and the replay store are this library's own.\r\n\r\n### Features\r\n\r\n- 🧊 **Stateless verification** — the HMAC signature proves the challenge is ours, unmodified and not expired. Nothing to store between issue and verify.\r\n- 🔁 **Replay protection** — every accepted signature is burned until it expires, so one solved challenge buys exactly one action.\r\n- 🎟️ **Single-use passes** — a solved captcha mints a short-lived token scoped to one action (`sign-in`, `device-code`, …), and it is spent on first use.\r\n- 🔌 **Pluggable store** — in-memory by default; hand it Redis/SQL/KV when more than one process verifies.\r\n- 🧩 **No singletons** — `createCaptcha()` returns an instance; tests and multi-tenant setups hold several at once.\r\n- 🪶 **Zero runtime dependencies** — `node:crypto` on the server, WebCrypto in the browser.\r\n- 🖥️ **Framework-agnostic solver** — `pow-captcha/client` exports one function; wrap it in React, Vue, Svelte or plain DOM yourself.\r\n\r\n### Why proof of work\r\n\r\nAn image captcha costs a human real seconds and a solver farm fractions of a cent — the economics\r\nrun the wrong way. Proof of work inverts them: a human pays nothing but a fraction of a second of\r\nbackground CPU and **zero clicks**, while a script minting thousands of sign-ins pays CPU on every\r\nsingle one. No images to squint at, no data leaving your origin, no vendor to trust, nothing to\r\nconsent to under GDPR.\r\n\r\n### Threat model\r\n\r\n**It raises the unit cost of automated requests. That is the whole claim.**\r\n\r\nWhat it does:\r\n\r\n- makes bulk automation expensive — 50k hashes per attempt is nothing once, painful a million times;\r\n- stops replay — a solved challenge cannot be resubmitted, so one CPU spend cannot fan out;\r\n- keeps forgery off the table — challenge, difficulty and expiry are all inside the HMAC.\r\n\r\nWhat it does **not** do:\r\n\r\n- ❌ **Stop a determined attacker.** Someone willing to burn CPU (or rent GPUs) still gets through, just slower and pricier.\r\n- ❌ **Distinguish human from machine.** A real browser driven by Playwright solves it exactly like a person's browser does. This is a cost gate, not a Turing test.\r\n- ❌ **Replace rate limiting.** Pair it with per-IP/per-account limits and lockouts; PoW makes each attempt costly, rate limits cap how many attempts exist.\r\n- ❌ **Defend the endpoint behind it.** A pass says \"someone paid CPU for this action\", nothing about authorization. Keep your auth checks.\r\n- ❌ **Survive a leaked secret.** Anything that can compute the HMAC issues valid challenges and passes. Treat it like a signing key: out of VCS, rotatable.\r\n- ❌ **Protect non-browser clients.** Native apps and CLIs must implement the same search, or be gated some other way.\r\n\r\nOne honest caveat: difficulty is a tax on the *slowest legitimate device*, not on the attacker's\r\nfastest one. Pick numbers a five-year-old phone can pay.\r\n\r\n### Quick start\r\n\r\n```bash\r\nbun add @beekamai/pow-captcha      # or: npm i @beekamai/pow-captcha\r\n```\r\n\r\nWorks on **Bun** and **Node 18+** (ESM + CJS builds with type declarations).\r\n\r\n```ts\r\nimport { createCaptcha, DIFFICULTY } from \"@beekamai/pow-captcha\";\r\n\r\nexport const captcha = createCaptcha({\r\n  secret: process.env.CAPTCHA_SECRET!,   // HMAC key — keep it out of VCS\r\n  challengeTtlMs: 5 * 60_000,            // default\r\n  passTtlMs: 10 * 60_000,                // default\r\n});\r\n```\r\n\r\n**Generic fetch handler** (Bun.serve, Deno, Workers, Next.js route handlers — same shape):\r\n\r\n```ts\r\nasync function handler(req: Request): Promise<Response> {\r\n  const { pathname } = new URL(req.url);\r\n  const json = (b: unknown, status = 200) =>\r\n    new Response(JSON.stringify(b), { status, headers: { \"content-type\": \"application/json\" } });\r\n\r\n  if (pathname === \"/captcha/challenge\") return json(captcha.createChallenge(DIFFICULTY.base));\r\n\r\n  if (pathname === \"/captcha/verify\") {\r\n    const { scope, solution } = await req.json();\r\n    const verdict = captcha.verifySolution(solution);\r\n    if (!verdict.ok) return json({ error: verdict.reason }, 400);\r\n    return json({ pass: captcha.issuePass(scope) });\r\n  }\r\n\r\n  if (pathname === \"/sign-in\") {\r\n    const { pass, ...credentials } = await req.json();\r\n    if (!captcha.consumePass(pass, \"sign-in\")) return json({ error: \"captcha_required\" }, 403);\r\n    return json(await signIn(credentials));\r\n  }\r\n\r\n  return json({ error: \"not_found\" }, 404);\r\n}\r\n```\r\n\r\n**Elysia**:\r\n\r\n```ts\r\nimport { Elysia, t } from \"elysia\";\r\nimport { createCaptcha, DIFFICULTY } from \"@beekamai/pow-captcha\";\r\n\r\nconst captcha = createCaptcha({ secret: process.env.CAPTCHA_SECRET! });\r\n\r\nexport const captchaRoutes = new Elysia({ prefix: \"/captcha\" })\r\n  .get(\"/challenge\", () => captcha.createChallenge(DIFFICULTY.base))\r\n  .post(\"/verify\", ({ body, status }) => {\r\n    const verdict = captcha.verifySolution(body.solution);\r\n    if (!verdict.ok) return status(400, { error: verdict.reason });\r\n    return { pass: captcha.issuePass(body.scope) };\r\n  }, {\r\n    body: t.Object({\r\n      scope: t.String({ maxLength: 64 }),\r\n      solution: t.Object({\r\n        salt: t.String({ maxLength: 64 }),\r\n        challenge: t.String({ maxLength: 64 }),\r\n        maxnumber: t.Integer({ minimum: 1 }),\r\n        number: t.Integer({ minimum: 0 }),\r\n        expiresAt: t.Number(),\r\n        signature: t.String({ maxLength: 64 }),\r\n      }),\r\n    }),\r\n  });\r\n\r\n/* Guard the action itself: the pass is spent here, once. */\r\nexport const signIn = new Elysia()\r\n  .post(\"/sign-in\", ({ body, status }) =>\r\n    captcha.consumePass(body.captchaPass, \"sign-in\") ? doSignIn(body) : status(403, { error: \"captcha_required\" }),\r\n  );\r\n```\r\n\r\nRun the whole loop locally — server, solver, pass, replay attempt:\r\n\r\n```bash\r\nbun run examples/serve.ts\r\n```\r\n\r\n### Client usage\r\n\r\n`pow-captcha/client` has no framework and no dependencies. It runs the search in a Web Worker so the\r\npage keeps painting, and falls back to chunked main-thread hashing where workers are unavailable.\r\n\r\n```ts\r\nimport { solveChallenge, SolveError } from \"@beekamai/pow-captcha/client\";\r\n\r\nasync function getPass(scope: string, signal?: AbortSignal): Promise<string> {\r\n  const challenge = await (await fetch(\"/captcha/challenge\", { cache: \"no-store\" })).json();\r\n\r\n  const number = await solveChallenge(challenge, {\r\n    signal,\r\n    onProgress: (p) => setProgress(p),   // 0..1\r\n  });\r\n\r\n  const res = await fetch(\"/captcha/verify\", {\r\n    method: \"POST\",\r\n    headers: { \"content-type\": \"application/json\" },\r\n    body: JSON.stringify({ scope, solution: { ...challenge, number } }),\r\n  });\r\n  const { pass, error } = await res.json();\r\n  if (!pass) throw new Error(error ?? \"verify failed\");\r\n  return pass;\r\n}\r\n```\r\n\r\nSolve it while the user is still typing, then send the pass along with the form — that way the wait\r\nis invisible. Errors are `SolveError` with a `reason`:\r\n\r\n| `reason` | Meaning |\r\n|---|---|\r\n| `invalid_challenge` | Not a usable challenge object (rejected before hashing). |\r\n| `not_found` | No answer in `0..maxnumber` — the challenge was tampered with or is not yours. |\r\n| `aborted` | The signal fired. `error.name` is `\"AbortError\"`. |\r\n| `worker_failed` | The worker itself died; retry with `useWorker: false`. |\r\n\r\n### Difficulty\r\n\r\nDifficulty is the search upper bound `maxnumber`; the browser averages `maxnumber / 2` hashes.\r\n\r\n| Preset | `maxnumber` | Typical cost | When |\r\n|---|---|---|---|\r\n| `DIFFICULTY.base` | 50 000 | fractions of a second | normal traffic |\r\n| `DIFFICULTY.elevated` | 200 000 | ~1 second | suspicious source, repeated failures |\r\n| `DIFFICULTY.attack` | 1 000 000 | a few seconds on a weak phone | active abuse; the practical ceiling |\r\n\r\nAnything above a million turns a slow phone into a broken login page. Escalate per request from your\r\nown signals (IP reputation, failure streak, account age) — the level is signed into the challenge,\r\nso a client cannot lower it.\r\n\r\n### API\r\n\r\n| Server (`pow-captcha`) | |\r\n|---|---|\r\n| `createCaptcha(options)` | Build an instance. `{ secret, challengeTtlMs?, passTtlMs?, now?, store? }`. |\r\n| `.createChallenge(maxnumber)` | `CaptchaChallenge` — send it to the browser as-is. |\r\n| `.verifySolution(solution)` | `{ ok: true }` or `{ ok: false, reason }` — `malformed` \\| `bad_signature` \\| `expired` \\| `replayed` \\| `wrong_number`. Burns the signature on success. |\r\n| `.issuePass(scope)` | Single-use token for one action. |\r\n| `.consumePass(token, scope)` | `true` at most once per token. |\r\n| `createMemoryStore(sweepIntervalMs?)` | Default `ReplayStore`. |\r\n| `DIFFICULTY` | `{ base, elevated, attack }`. |\r\n\r\n| Client (`pow-captcha/client`) | |\r\n|---|---|\r\n| `solveChallenge(challenge, options?)` | `Promise<number>`. Options: `onProgress`, `signal`, `useWorker`, `progressInterval`. |\r\n| `SolveError` | `Error` with `reason: SolveFailure`. |\r\n\r\n**Custom replay store.** Anything with this shape works; keys are namespaced (`c:` challenges,\r\n`p:` passes) so one store serves both:\r\n\r\n```ts\r\nimport type { ReplayStore } from \"@beekamai/pow-captcha\";\r\n\r\nconst redisStore: ReplayStore = {\r\n  has: (key) => cache.has(key),                       // must be synchronous\r\n  set: (key, expiresAt) => cache.set(key, expiresAt), // expire at expiresAt\r\n  sweep: () => {},                                    // Redis expires on its own\r\n};\r\n```\r\n\r\nThe interface is **synchronous** — verification stays a plain function call. For a network-backed\r\nstore, front it with a local cache that a background task keeps warm, or run replay checks per\r\nprocess and accept that a solution can be spent once per node.\r\n\r\n### Deployment notes\r\n\r\n- **One secret per environment**, from the environment, never in VCS. Rotating it invalidates every\r\n  outstanding challenge and pass — harmless, they are minutes-lived.\r\n- **Share the store across processes.** With the in-memory default and N workers behind a load\r\n  balancer, a solution can be replayed up to N times.\r\n- **Scope passes narrowly.** `\"sign-in\"` and `\"password-reset\"` are different scopes; a pass for one\r\n  never opens the other.\r\n- **The pass is a bearer token.** Send it over HTTPS and keep the TTL short.\r\n\r\n<details>\r\n<summary>Project layout</summary>\r\n\r\n```\r\nsrc/\r\n  index.ts      public entry point (server)\r\n  captcha.ts    createCaptcha: challenges, verification, passes\r\n  store.ts      ReplayStore interface + in-memory implementation\r\n  client.ts     browser solver (pow-captcha/client): worker + inline fallback\r\ntests/\r\n  captcha.test.ts   challenges, replay, passes, TTLs, stores\r\n  client.test.ts    solver, progress, abort, failure reasons\r\nexamples/\r\n  serve.ts      end-to-end demo: fetch handler + solver + pass replay\r\n```\r\n</details>\r\n\r\n### Origin\r\n\r\nThe challenge format follows **Altcha** so tooling and mental models carry over; the\r\nimplementation, the scoped single-use passes and the pluggable replay store are this library's own.\r\n\r\n### License\r\n\r\nMIT.\r\n\r\n---\r\n\r\n## Русский\r\n\r\n**Proof-of-work капча без состояния**: без картинок, без стороннего сервиса, без кук и трекинга.\r\nСервер выдаёт соль и SHA-256-хэш скрытого числа; браузер перебирает числа, пока не найдёт то,\r\nкоторое даёт этот хэш, и присылает ответ. Проверка — одна HMAC-сверка, таблицы выданных челленджей\r\nсервер не держит.\r\n\r\nФормат челленджа повторяет [Altcha](https://altcha.org), так что «по проводу» всё выглядит привычно;\r\nреализация, пропуска и стор от повторов — собственные.\r\n\r\n### Возможности\r\n\r\n- 🧊 **Проверка без состояния** — HMAC-подпись доказывает, что челлендж наш, не изменён и не просрочен. Между выдачей и проверкой хранить нечего.\r\n- 🔁 **Защита от повторной сдачи** — принятая подпись сгорает до истечения срока: одно решение = ровно одно действие.\r\n- 🎟️ **Одноразовые пропуска** — решённая капча выдаёт короткоживущий токен под одно действие (`sign-in`, `device-code`, …), который тратится при первом предъявлении.\r\n- 🔌 **Сменный стор** — по умолчанию в памяти; подставьте Redis/SQL/KV, когда проверяет больше одного процесса.\r\n- 🧩 **Никаких синглтонов** — `createCaptcha()` возвращает инстанс; тестам и мульти-тенанту можно держать несколько сразу.\r\n- 🪶 **Ноль рантайм-зависимостей** — `node:crypto` на сервере, WebCrypto в браузере.\r\n- 🖥️ **Солвер без фреймворка** — `pow-captcha/client` экспортирует одну функцию; обёртка на React, Vue, Svelte или голом DOM — ваша.\r\n\r\n### Зачем proof of work\r\n\r\nКапча-картинка стоит человеку реальных секунд, а ферме решателей — доли цента: экономика работает не\r\nв ту сторону. PoW её переворачивает — человек платит долей секунды фонового CPU и **нулём кликов**, а\r\nскрипт, штампующий тысячи входов, платит процессорным временем за каждый. Ничего не надо\r\nразглядывать, данные не уходят с вашего домена, доверять вендору не нужно, согласие по GDPR брать не\r\nза что.\r\n\r\n### Модель угроз\r\n\r\n**Она поднимает стоимость одного автоматического запроса. Это всё, что она обещает.**\r\n\r\nЧто делает:\r\n\r\n- делает массовую автоматизацию дорогой — 50k хэшей за попытку это ничто один раз и больно миллион раз;\r\n- убивает повтор — решение нельзя сдать дважды, одна оплата CPU не размножается;\r\n- закрывает подделку — челлендж, сложность и срок жизни лежат внутри HMAC.\r\n\r\nЧего **не** делает:\r\n\r\n- ❌ **Не останавливает мотивированного атакующего.** Кто готов жечь CPU (или арендовать GPU) — пройдёт, просто медленнее и дороже.\r\n- ❌ **Не отличает человека от машины.** Настоящий браузер под Playwright решает её ровно так же, как браузер живого человека. Это ворота по стоимости, а не тест Тьюринга.\r\n- ❌ **Не заменяет rate limiting.** Ставьте рядом лимиты по IP/аккаунту и локауты: PoW делает попытку дорогой, лимиты ограничивают их число.\r\n- ❌ **Не защищает эндпоинт за собой.** Пропуск говорит «за это действие кто-то заплатил CPU» и ничего — про права. Проверки авторизации остаются.\r\n- ❌ **Не переживает утечку секрета.** Кто умеет считать HMAC — выпускает валидные челленджи и пропуска. Относиться как к ключу подписи: вне VCS, с ротацией.\r\n- ❌ **Не защищает не-браузерных клиентов.** Нативным приложениям и CLI нужно реализовать тот же перебор — или закрывать их иначе.\r\n\r\nЧестная оговорка: сложность — это налог на **самое медленное легитимное устройство**, а не на самое\r\nбыстрое у атакующего. Берите числа, которые потянет пятилетний телефон.\r\n\r\n### Быстрый старт\r\n\r\n```bash\r\nbun add @beekamai/pow-captcha      # либо: npm i @beekamai/pow-captcha\r\n```\r\n\r\nРаботает на **Bun** и **Node 18+** (ESM + CJS сборка с декларациями типов).\r\n\r\n```ts\r\nimport { createCaptcha, DIFFICULTY } from \"@beekamai/pow-captcha\";\r\n\r\nexport const captcha = createCaptcha({\r\n  secret: process.env.CAPTCHA_SECRET!,   // ключ HMAC — держать вне VCS\r\n  challengeTtlMs: 5 * 60_000,            // по умолчанию\r\n  passTtlMs: 10 * 60_000,                // по умолчанию\r\n});\r\n```\r\n\r\n**Обычный fetch-хендлер** (Bun.serve, Deno, Workers, route handlers Next.js — форма та же):\r\n\r\n```ts\r\nasync function handler(req: Request): Promise<Response> {\r\n  const { pathname } = new URL(req.url);\r\n  const json = (b: unknown, status = 200) =>\r\n    new Response(JSON.stringify(b), { status, headers: { \"content-type\": \"application/json\" } });\r\n\r\n  if (pathname === \"/captcha/challenge\") return json(captcha.createChallenge(DIFFICULTY.base));\r\n\r\n  if (pathname === \"/captcha/verify\") {\r\n    const { scope, solution } = await req.json();\r\n    const verdict = captcha.verifySolution(solution);\r\n    if (!verdict.ok) return json({ error: verdict.reason }, 400);\r\n    return json({ pass: captcha.issuePass(scope) });\r\n  }\r\n\r\n  if (pathname === \"/sign-in\") {\r\n    const { pass, ...credentials } = await req.json();\r\n    if (!captcha.consumePass(pass, \"sign-in\")) return json({ error: \"captcha_required\" }, 403);\r\n    return json(await signIn(credentials));\r\n  }\r\n\r\n  return json({ error: \"not_found\" }, 404);\r\n}\r\n```\r\n\r\n**Elysia**:\r\n\r\n```ts\r\nimport { Elysia, t } from \"elysia\";\r\nimport { createCaptcha, DIFFICULTY } from \"@beekamai/pow-captcha\";\r\n\r\nconst captcha = createCaptcha({ secret: process.env.CAPTCHA_SECRET! });\r\n\r\nexport const captchaRoutes = new Elysia({ prefix: \"/captcha\" })\r\n  .get(\"/challenge\", () => captcha.createChallenge(DIFFICULTY.base))\r\n  .post(\"/verify\", ({ body, status }) => {\r\n    const verdict = captcha.verifySolution(body.solution);\r\n    if (!verdict.ok) return status(400, { error: verdict.reason });\r\n    return { pass: captcha.issuePass(body.scope) };\r\n  }, {\r\n    body: t.Object({\r\n      scope: t.String({ maxLength: 64 }),\r\n      solution: t.Object({\r\n        salt: t.String({ maxLength: 64 }),\r\n        challenge: t.String({ maxLength: 64 }),\r\n        maxnumber: t.Integer({ minimum: 1 }),\r\n        number: t.Integer({ minimum: 0 }),\r\n        expiresAt: t.Number(),\r\n        signature: t.String({ maxLength: 64 }),\r\n      }),\r\n    }),\r\n  });\r\n\r\n/* Само действие: пропуск тратится здесь, один раз. */\r\nexport const signIn = new Elysia()\r\n  .post(\"/sign-in\", ({ body, status }) =>\r\n    captcha.consumePass(body.captchaPass, \"sign-in\") ? doSignIn(body) : status(403, { error: \"captcha_required\" }),\r\n  );\r\n```\r\n\r\nПрогнать весь цикл локально — сервер, солвер, пропуск, попытка повтора:\r\n\r\n```bash\r\nbun run examples/serve.ts\r\nbun test          # тесты\r\nbun run typecheck # tsc --noEmit\r\n```\r\n\r\n### Клиент\r\n\r\nУ `pow-captcha/client` нет ни фреймворка, ни зависимостей. Перебор идёт в Web Worker, чтобы страница\r\nне замирала; там, где воркеры недоступны, считает в основном потоке порциями.\r\n\r\n```ts\r\nimport { solveChallenge, SolveError } from \"@beekamai/pow-captcha/client\";\r\n\r\nasync function getPass(scope: string, signal?: AbortSignal): Promise<string> {\r\n  const challenge = await (await fetch(\"/captcha/challenge\", { cache: \"no-store\" })).json();\r\n\r\n  const number = await solveChallenge(challenge, {\r\n    signal,\r\n    onProgress: (p) => setProgress(p),   // 0..1\r\n  });\r\n\r\n  const res = await fetch(\"/captcha/verify\", {\r\n    method: \"POST\",\r\n    headers: { \"content-type\": \"application/json\" },\r\n    body: JSON.stringify({ scope, solution: { ...challenge, number } }),\r\n  });\r\n  const { pass, error } = await res.json();\r\n  if (!pass) throw new Error(error ?? \"verify failed\");\r\n  return pass;\r\n}\r\n```\r\n\r\nРешайте, пока пользователь ещё заполняет форму, и отправляйте пропуск вместе с ней — тогда ожидания\r\nне видно вообще. Ошибки — `SolveError` с полем `reason`:\r\n\r\n| `reason` | Что значит |\r\n|---|---|\r\n| `invalid_challenge` | Это не пригодный челлендж (отбрасывается до хэширования). |\r\n| `not_found` | В диапазоне `0..maxnumber` ответа нет — челлендж подменён или не ваш. |\r\n| `aborted` | Сработал signal. `error.name` — `\"AbortError\"`. |\r\n| `worker_failed` | Умер сам воркер; повторить с `useWorker: false`. |\r\n\r\n### Сложность\r\n\r\nСложность — это верхняя граница перебора `maxnumber`; браузер в среднем считает `maxnumber / 2` хэшей.\r\n\r\n| Пресет | `maxnumber` | Обычная цена | Когда |\r\n|---|---|---|---|\r\n| `DIFFICULTY.base` | 50 000 | доли секунды | обычный трафик |\r\n| `DIFFICULTY.elevated` | 200 000 | ~секунда | подозрительный источник, серия неудач |\r\n| `DIFFICULTY.attack` | 1 000 000 | несколько секунд на слабом телефоне | активная атака; практический потолок |\r\n\r\nВыше миллиона — это сломанная страница входа на медленном телефоне. Повышать уровень по своим\r\nсигналам (репутация IP, серия отказов, возраст аккаунта): уровень входит в подпись, занизить его\r\nклиент не может.\r\n\r\n### API\r\n\r\n| Сервер (`pow-captcha`) | |\r\n|---|---|\r\n| `createCaptcha(options)` | Создать инстанс. `{ secret, challengeTtlMs?, passTtlMs?, now?, store? }`. |\r\n| `.createChallenge(maxnumber)` | `CaptchaChallenge` — отдать браузеру как есть. |\r\n| `.verifySolution(solution)` | `{ ok: true }` либо `{ ok: false, reason }` — `malformed` \\| `bad_signature` \\| `expired` \\| `replayed` \\| `wrong_number`. При успехе сжигает подпись. |\r\n| `.issuePass(scope)` | Одноразовый токен под одно действие. |\r\n| `.consumePass(token, scope)` | `true` максимум один раз на токен. |\r\n| `createMemoryStore(sweepIntervalMs?)` | Стор по умолчанию. |\r\n| `DIFFICULTY` | `{ base, elevated, attack }`. |\r\n\r\n| Клиент (`pow-captcha/client`) | |\r\n|---|---|\r\n| `solveChallenge(challenge, options?)` | `Promise<number>`. Опции: `onProgress`, `signal`, `useWorker`, `progressInterval`. |\r\n| `SolveError` | `Error` с полем `reason: SolveFailure`. |\r\n\r\n**Свой стор.** Подойдёт что угодно такой формы; ключи разведены по префиксам (`c:` челленджи,\r\n`p:` пропуска), поэтому одного стора хватает на оба:\r\n\r\n```ts\r\nimport type { ReplayStore } from \"@beekamai/pow-captcha\";\r\n\r\nconst redisStore: ReplayStore = {\r\n  has: (key) => cache.has(key),                       // должно быть синхронно\r\n  set: (key, expiresAt) => cache.set(key, expiresAt), // истекает в expiresAt\r\n  sweep: () => {},                                    // Redis чистит сам\r\n};\r\n```\r\n\r\nИнтерфейс **синхронный** — проверка остаётся обычным вызовом функции. Для сетевого хранилища ставьте\r\nперед ним локальный кэш, который прогревает фоновая задача, — либо считайте повторы попроцессно и\r\nпринимайте, что решение можно потратить один раз на ноду.\r\n\r\n### Эксплуатация\r\n\r\n- **Свой секрет на каждое окружение**, из переменных, никогда не в VCS. Ротация обнуляет все живые\r\n  челленджи и пропуска — не страшно, они живут минуты.\r\n- **Общий стор на все процессы.** С дефолтным стором в памяти и N воркерами за балансировщиком\r\n  решение можно сдать до N раз.\r\n- **Узкие скоупы.** `\"sign-in\"` и `\"password-reset\"` — разные скоупы; пропуск от одного не открывает\r\n  другое.\r\n- **Пропуск — bearer-токен.** Только по HTTPS и с коротким TTL.\r\n\r\n<details>\r\n<summary>Структура проекта</summary>\r\n\r\n```\r\nsrc/\r\n  index.ts      публичная точка входа (сервер)\r\n  captcha.ts    createCaptcha: челленджи, проверка, пропуска\r\n  store.ts      интерфейс ReplayStore + реализация в памяти\r\n  client.ts     браузерный солвер (pow-captcha/client): воркер + инлайн-фолбэк\r\ntests/\r\n  captcha.test.ts   челленджи, повторы, пропуска, TTL, сторы\r\n  client.test.ts    солвер, прогресс, отмена, причины отказа\r\nexamples/\r\n  serve.ts      сквозной демо-прогон: fetch-хендлер + солвер + повтор пропуска\r\n```\r\n</details>\r\n\r\n### Происхождение\r\n\r\nФормат челленджа повторяет **Altcha**, чтобы инструменты и привычки переносились; реализация,\r\nодноразовые пропуски со scope и подключаемое хранилище повторов — свои.\r\n\r\n### Лицензия\r\n\r\nMIT.\r\n","readmeFilename":"README.md","_rev":"1-868e6de884a99272f9d2eaca2be81062"}