@krizaka/rate-limit 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,3 +33,7 @@ await limiter.forget("signIn", keys); // a successful sign-in clears its failure
33
33
  - **A store that fails lets the request through** (the limiter must not take the site down) and tells `onStoreError`; `failOpen: false` rethrows instead.
34
34
  - **Orochia's extras** are in the port: `peek` (read without counting), `set` (a lock-out) and `reset`.
35
35
  - `limitStoreContract` (from `@krizaka/rate-limit/testkit`) is the contract every store runs.
36
+
37
+ ## Turnstile (`@krizaka/rate-limit/turnstile`)
38
+
39
+ A Cloudflare Turnstile widget protects nothing until the server asks Cloudflare about the token it produced. `createTurnstileVerifier({ secret, hostnames, now })` returns `verify({ token, remoteIp, action })` → `{ ok: true }` or `{ ok: false, reason, errorCodes }`. It **fails closed**: no secret (`not-configured`), no or oversized token (`missing-token`), a refusal (`rejected`), a token solved on another hostname or for another form's `action`, a replayed token (single-use, remembered for Cloudflare's 300 s), a timeout or an unreachable service (`unavailable`) all refuse. The secret is `ABUSE_CAPTCHA_SECRET_KEY` with `ABUSE_CAPTCHA_PROVIDER=turnstile` (the provider is a value; never in the browser); the site key is the public `NEXT_PUBLIC_ABUSE_CAPTCHA_SITE_KEY`. Answer a refusal with a 4xx and send nothing; log the `reason` only (no address, no message). Rule `turnstile-server-side` of the org registry.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Server-side check of a Cloudflare Turnstile token (siteverify). The widget alone protects nothing: the token it hands the
3
+ * browser is only worth something once the server has asked Cloudflare about it. Fails closed — no secret, no token, a refusal, a
4
+ * wrong hostname or action, a replayed token, a timeout or an unreachable service all answer `ok: false` — so a form that calls
5
+ * it never sends a message on doubt. Nothing here knows a request, an environment variable or the wall clock.
6
+ */
7
+ declare const TURNSTILE_SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
8
+ declare const TURNSTILE_MAX_TOKEN_LENGTH = 2048;
9
+ type TurnstileFailure = "not-configured" | "missing-token" | "rejected" | "hostname" | "action" | "replayed" | "unavailable";
10
+ type TurnstileResult = {
11
+ ok: true;
12
+ } | {
13
+ ok: false;
14
+ reason: TurnstileFailure;
15
+ errorCodes: string[];
16
+ };
17
+ interface TurnstileResponse {
18
+ ok: boolean;
19
+ json(): Promise<unknown>;
20
+ }
21
+ type TurnstileFetch = (url: string, init: {
22
+ method: string;
23
+ body: string;
24
+ headers: Record<string, string>;
25
+ signal?: AbortSignal;
26
+ }) => Promise<TurnstileResponse>;
27
+ interface TurnstileDeps {
28
+ /** The widget's secret key (`ABUSE_TURNSTILE_SECRET`). Absent or blank: every check fails (`not-configured`). */
29
+ secret: string | undefined;
30
+ /** The hostnames a token may have been solved on (`www.krizaka.com`). Empty: the hostname is not checked. */
31
+ hostnames?: readonly string[];
32
+ /** The clock, injected. */
33
+ now: () => Date;
34
+ fetch?: TurnstileFetch;
35
+ timeoutMs?: number;
36
+ }
37
+ interface TurnstileCheck {
38
+ token: unknown;
39
+ /** The visitor's address; "unknown" and blank are not sent. */
40
+ remoteIp?: string;
41
+ /** The widget's `action`, when the form sets one: a token solved for another form is refused. */
42
+ action?: string;
43
+ }
44
+ /** A verifier with its replay memory: one per process, shared by the forms. */
45
+ declare function createTurnstileVerifier(deps: TurnstileDeps): (check: TurnstileCheck) => Promise<TurnstileResult>;
46
+
47
+ export { TURNSTILE_MAX_TOKEN_LENGTH, TURNSTILE_SITEVERIFY_URL, type TurnstileCheck, type TurnstileDeps, type TurnstileFailure, type TurnstileFetch, type TurnstileResponse, type TurnstileResult, createTurnstileVerifier };
@@ -0,0 +1,50 @@
1
+ // src/turnstile.ts
2
+ var TURNSTILE_SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
3
+ var TURNSTILE_MAX_TOKEN_LENGTH = 2048;
4
+ var DEFAULT_TIMEOUT_MS = 8e3;
5
+ var REPLAY_WINDOW_MS = 3e5;
6
+ var MAX_REMEMBERED_TOKENS = 5e3;
7
+ var fail = (reason, errorCodes = []) => ({ ok: false, reason, errorCodes });
8
+ function createTurnstileVerifier(deps) {
9
+ const seen = /* @__PURE__ */ new Map();
10
+ const doFetch = deps.fetch ?? globalThis.fetch;
11
+ const remember = (token, nowMs) => {
12
+ for (const [t, at] of seen) if (nowMs - at > REPLAY_WINDOW_MS) seen.delete(t);
13
+ if (seen.has(token)) return false;
14
+ if (seen.size >= MAX_REMEMBERED_TOKENS) seen.delete(seen.keys().next().value);
15
+ seen.set(token, nowMs);
16
+ return true;
17
+ };
18
+ return async ({ token, remoteIp, action }) => {
19
+ const secret = deps.secret?.trim();
20
+ if (!secret) return fail("not-configured");
21
+ if (typeof token !== "string" || !token.trim() || token.length > TURNSTILE_MAX_TOKEN_LENGTH) return fail("missing-token");
22
+ if (!remember(token, deps.now().valueOf())) return fail("replayed");
23
+ const body = new URLSearchParams({ secret, response: token });
24
+ if (remoteIp && remoteIp !== "unknown") body.set("remoteip", remoteIp);
25
+ let answer;
26
+ try {
27
+ const res = await doFetch(TURNSTILE_SITEVERIFY_URL, {
28
+ method: "POST",
29
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
30
+ body: body.toString(),
31
+ signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
32
+ });
33
+ if (!res.ok) return fail("unavailable");
34
+ answer = await res.json();
35
+ } catch {
36
+ return fail("unavailable");
37
+ }
38
+ const codes = Array.isArray(answer["error-codes"]) ? answer["error-codes"] : [];
39
+ if (answer.success !== true) return fail("rejected", codes);
40
+ if (deps.hostnames?.length && !deps.hostnames.includes(answer.hostname ?? "")) return fail("hostname", codes);
41
+ if (action !== void 0 && answer.action !== action) return fail("action", codes);
42
+ return { ok: true };
43
+ };
44
+ }
45
+ export {
46
+ TURNSTILE_MAX_TOKEN_LENGTH,
47
+ TURNSTILE_SITEVERIFY_URL,
48
+ createTurnstileVerifier
49
+ };
50
+ //# sourceMappingURL=turnstile.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/turnstile.ts"],"sourcesContent":["/**\n * Server-side check of a Cloudflare Turnstile token (siteverify). The widget alone protects nothing: the token it hands the\n * browser is only worth something once the server has asked Cloudflare about it. Fails closed — no secret, no token, a refusal, a\n * wrong hostname or action, a replayed token, a timeout or an unreachable service all answer `ok: false` — so a form that calls\n * it never sends a message on doubt. Nothing here knows a request, an environment variable or the wall clock.\n */\nexport const TURNSTILE_SITEVERIFY_URL = \"https://challenges.cloudflare.com/turnstile/v0/siteverify\";\n\n// @fixed: a Turnstile token is a few hundred characters; 2048 is Cloudflare's own documented maximum\nexport const TURNSTILE_MAX_TOKEN_LENGTH = 2048;\n// @fixed: a form submit must not hang on the verification service\nconst DEFAULT_TIMEOUT_MS = 8000;\n// @fixed: a token expires after 300 s at Cloudflare; remembering it for as long is enough to refuse a replay here\nconst REPLAY_WINDOW_MS = 300_000;\n// @fixed: bounds the replay memory of one process\nconst MAX_REMEMBERED_TOKENS = 5000;\n\nexport type TurnstileFailure = \"not-configured\" | \"missing-token\" | \"rejected\" | \"hostname\" | \"action\" | \"replayed\" | \"unavailable\";\nexport type TurnstileResult = { ok: true } | { ok: false; reason: TurnstileFailure; errorCodes: string[] };\n\nexport interface TurnstileResponse {\n ok: boolean;\n json(): Promise<unknown>;\n}\nexport type TurnstileFetch = (\n url: string,\n init: { method: string; body: string; headers: Record<string, string>; signal?: AbortSignal },\n) => Promise<TurnstileResponse>;\n\nexport interface TurnstileDeps {\n /** The widget's secret key (`ABUSE_TURNSTILE_SECRET`). Absent or blank: every check fails (`not-configured`). */\n secret: string | undefined;\n /** The hostnames a token may have been solved on (`www.krizaka.com`). Empty: the hostname is not checked. */\n hostnames?: readonly string[];\n /** The clock, injected. */\n now: () => Date;\n fetch?: TurnstileFetch;\n timeoutMs?: number;\n}\n\nexport interface TurnstileCheck {\n token: unknown;\n /** The visitor's address; \"unknown\" and blank are not sent. */\n remoteIp?: string;\n /** The widget's `action`, when the form sets one: a token solved for another form is refused. */\n action?: string;\n}\n\nconst fail = (reason: TurnstileFailure, errorCodes: string[] = []): TurnstileResult => ({ ok: false, reason, errorCodes });\n\n/** A verifier with its replay memory: one per process, shared by the forms. */\nexport function createTurnstileVerifier(deps: TurnstileDeps): (check: TurnstileCheck) => Promise<TurnstileResult> {\n const seen = new Map<string, number>();\n const doFetch = deps.fetch ?? (globalThis.fetch as unknown as TurnstileFetch);\n\n const remember = (token: string, nowMs: number): boolean => {\n for (const [t, at] of seen) if (nowMs - at > REPLAY_WINDOW_MS) seen.delete(t);\n if (seen.has(token)) return false;\n if (seen.size >= MAX_REMEMBERED_TOKENS) seen.delete(seen.keys().next().value as string);\n seen.set(token, nowMs);\n return true;\n };\n\n return async ({ token, remoteIp, action }) => {\n const secret = deps.secret?.trim();\n if (!secret) return fail(\"not-configured\");\n if (typeof token !== \"string\" || !token.trim() || token.length > TURNSTILE_MAX_TOKEN_LENGTH) return fail(\"missing-token\");\n if (!remember(token, deps.now().valueOf())) return fail(\"replayed\");\n\n const body = new URLSearchParams({ secret, response: token });\n if (remoteIp && remoteIp !== \"unknown\") body.set(\"remoteip\", remoteIp);\n let answer: { success?: boolean; hostname?: string; action?: string; \"error-codes\"?: string[] };\n try {\n const res = await doFetch(TURNSTILE_SITEVERIFY_URL, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: body.toString(),\n signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS),\n });\n if (!res.ok) return fail(\"unavailable\");\n answer = (await res.json()) as typeof answer;\n } catch {\n return fail(\"unavailable\");\n }\n const codes = Array.isArray(answer[\"error-codes\"]) ? answer[\"error-codes\"] : [];\n if (answer.success !== true) return fail(\"rejected\", codes);\n if (deps.hostnames?.length && !deps.hostnames.includes(answer.hostname ?? \"\")) return fail(\"hostname\", codes);\n if (action !== undefined && answer.action !== action) return fail(\"action\", codes);\n return { ok: true };\n };\n}\n"],"mappings":";AAMO,IAAM,2BAA2B;AAGjC,IAAM,6BAA6B;AAE1C,IAAM,qBAAqB;AAE3B,IAAM,mBAAmB;AAEzB,IAAM,wBAAwB;AAiC9B,IAAM,OAAO,CAAC,QAA0B,aAAuB,CAAC,OAAwB,EAAE,IAAI,OAAO,QAAQ,WAAW;AAGjH,SAAS,wBAAwB,MAA0E;AAChH,QAAM,OAAO,oBAAI,IAAoB;AACrC,QAAM,UAAU,KAAK,SAAU,WAAW;AAE1C,QAAM,WAAW,CAAC,OAAe,UAA2B;AAC1D,eAAW,CAAC,GAAG,EAAE,KAAK,KAAM,KAAI,QAAQ,KAAK,iBAAkB,MAAK,OAAO,CAAC;AAC5E,QAAI,KAAK,IAAI,KAAK,EAAG,QAAO;AAC5B,QAAI,KAAK,QAAQ,sBAAuB,MAAK,OAAO,KAAK,KAAK,EAAE,KAAK,EAAE,KAAe;AACtF,SAAK,IAAI,OAAO,KAAK;AACrB,WAAO;AAAA,EACT;AAEA,SAAO,OAAO,EAAE,OAAO,UAAU,OAAO,MAAM;AAC5C,UAAM,SAAS,KAAK,QAAQ,KAAK;AACjC,QAAI,CAAC,OAAQ,QAAO,KAAK,gBAAgB;AACzC,QAAI,OAAO,UAAU,YAAY,CAAC,MAAM,KAAK,KAAK,MAAM,SAAS,2BAA4B,QAAO,KAAK,eAAe;AACxH,QAAI,CAAC,SAAS,OAAO,KAAK,IAAI,EAAE,QAAQ,CAAC,EAAG,QAAO,KAAK,UAAU;AAElE,UAAM,OAAO,IAAI,gBAAgB,EAAE,QAAQ,UAAU,MAAM,CAAC;AAC5D,QAAI,YAAY,aAAa,UAAW,MAAK,IAAI,YAAY,QAAQ;AACrE,QAAI;AACJ,QAAI;AACF,YAAM,MAAM,MAAM,QAAQ,0BAA0B;AAAA,QAClD,QAAQ;AAAA,QACR,SAAS,EAAE,gBAAgB,oCAAoC;AAAA,QAC/D,MAAM,KAAK,SAAS;AAAA,QACpB,QAAQ,YAAY,QAAQ,KAAK,aAAa,kBAAkB;AAAA,MAClE,CAAC;AACD,UAAI,CAAC,IAAI,GAAI,QAAO,KAAK,aAAa;AACtC,eAAU,MAAM,IAAI,KAAK;AAAA,IAC3B,QAAQ;AACN,aAAO,KAAK,aAAa;AAAA,IAC3B;AACA,UAAM,QAAQ,MAAM,QAAQ,OAAO,aAAa,CAAC,IAAI,OAAO,aAAa,IAAI,CAAC;AAC9E,QAAI,OAAO,YAAY,KAAM,QAAO,KAAK,YAAY,KAAK;AAC1D,QAAI,KAAK,WAAW,UAAU,CAAC,KAAK,UAAU,SAAS,OAAO,YAAY,EAAE,EAAG,QAAO,KAAK,YAAY,KAAK;AAC5G,QAAI,WAAW,UAAa,OAAO,WAAW,OAAQ,QAAO,KAAK,UAAU,KAAK;AACjF,WAAO,EAAE,IAAI,KAAK;AAAA,EACpB;AACF;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krizaka/rate-limit",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Limits per action for every Krizaka product: fixed windows per address and per subject, the limits read from the admin's settings, a memory store and a PostgreSQL store whose atomic upsert counts exactly under concurrency, a stable 429 with Retry-After. No database client, no wall clock, no request object.",
5
5
  "keywords": [
6
6
  "krizaka",
@@ -30,6 +30,10 @@
30
30
  "types": "./dist/settings.d.ts",
31
31
  "import": "./dist/settings.js"
32
32
  },
33
+ "./turnstile": {
34
+ "types": "./dist/turnstile.d.ts",
35
+ "import": "./dist/turnstile.js"
36
+ },
33
37
  "./testkit": {
34
38
  "types": "./dist/testkit.d.ts",
35
39
  "import": "./dist/testkit.js"
@@ -54,12 +58,12 @@
54
58
  "typescript": "^5.9.3",
55
59
  "vitest": "^4.1.11",
56
60
  "zod": "^4.6.7",
57
- "@krizaka/config": "^0.14.1"
61
+ "@krizaka/config": "^0.15.0"
58
62
  },
59
63
  "dependencies": {
60
- "@krizaka/db": "^0.3.0",
61
- "@krizaka/settings": "^0.5.1",
62
- "@krizaka/time": "^0.1.0"
64
+ "@krizaka/db": "^0.3.1",
65
+ "@krizaka/time": "^0.1.0",
66
+ "@krizaka/settings": "^0.5.1"
63
67
  },
64
68
  "peerDependencies": {
65
69
  "vitest": ">=4",