@krizaka/rate-limit
Limits per action: fixed windows per address and subject, set by the admin, counted exactly under concurrency.
Install
npm install @krizaka/rate-limitAlso published as subpath exports: @krizaka/rate-limit/settings, @krizaka/rate-limit/turnstile, @krizaka/rate-limit/testkit.
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 shared by every instance. It replaces the rate-limit.ts that Yomizaka and Akinaza carried byte for byte, and the PostgreSQL variants some products carried.
import { emitMigration } from "@krizaka/db";
import { createLimiter, dbLimitStore, rateLimitSchema, RateLimited } from "@krizaka/rate-limit";
import { limitsFromSettings, rateLimitSettings } from "@krizaka/rate-limit/settings";
import { clientIp } from "@krizaka/auth";
emitMigration(rateLimitSchema, 1, { tables: {} }); // commit the SQL once, in your migrations: table rate_limit.bucket
// the registry fragment: limits.signIn.perIp, limits.signIn.perSubject, limits.signIn.windowSeconds… the admin changes them without a redeploy
export const limitSettings = rateLimitSettings({ signIn: { perIp: 30, perSubject: 10, windowSeconds: 900 }, me: { perIp: 600, windowSeconds: 3600 } });
const limiter = createLimiter({
store: dbLimitStore(query), // or memoryLimitStore() in one process and in tests
clock: { now: () => new Date() },
limits: limitsFromSettings((key) => settings.get(key)), // or a plain table: { signIn: { perIp: 30, perSubject: 10, windowSeconds: 900 } }
onStoreError: (error, action) => log.error({ error, action }),
});
// in a route
await limiter.enforce("signIn", { ip: clientIp(req.headers, rules), subject: email }); // throws RateLimited: status 429, headers { "Retry-After" }
const verdict = await limiter.check("signIn", keys); // { ok: true } | { ok: false, retryAfterSeconds, scope: "ip" | "subject" }
await limiter.forget("signIn", keys); // a successful sign-in clears its failures- The semantics of the copies, so their tests pass unchanged: a window starts at its first hit and lasts
windowSeconds; a refused hit is counted too; the address is counted first and a refusal stops there; the subject is compared in lower case and counted only when the action has aperSubjectlimit and one is given; an unknown address is counted underunknown. The old calllimit("sign-in", req, email, [30, 900], [10, 900])isenforce("signIn", { ip, subject: email })with{ perIp: 30, perSubject: 10, windowSeconds: 900 }in the settings. - The limiter takes keys, never a request. The client's address is extracted by
clientIpof@krizaka/auth; the package reads no header. - Limits are settings.
rateLimitSettings(@krizaka/rate-limit/settings, needs@krizaka/settingsandzod) declareslimits.<action>.perIp,.perSubjectand.windowSecondsas integers with the product's defaults;limitsFromSettingsreads them back at each check. An action id is letters and digits (signIn), because it sits in a settings key. - Stores:
memoryLimitStore({ maxKeys })keeps the copies' bound (10 000 windows; ended ones dropped first, then one shared overflow window, so a flood of keys limits itself and never clears a blocked caller's count).dbLimitStore(query, { schema })counts in one atomic statement (INSERT … ON CONFLICT DO UPDATE … RETURNING), so 100 parallel checks count exactly and every instance sees the same counters; times come from the injected clock, not the database's. The table israte_limit.bucket (key, count, window_start, reset_at), one row per key (a deviation from(key, window_start)as primary key: with the first hit starting the window, two concurrent first hits would otherwise open two rows).purge(now)deletes the ended windows: call it from the product's job. - A store that fails lets the request through (the limiter must not take the site down) and tells
onStoreError;failOpen: falserethrows instead. - The extras are in the port:
peek(read without counting),set(a lock-out) andreset. limitStoreContract(from@krizaka/rate-limit/testkit) is the contract every store runs.
Turnstile (@krizaka/rate-limit/turnstile)
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.
Exports
The 17 names the package exports, read from its types.
| Name | Kind | What it is |
|---|---|---|
ActionLimit | type | What one action allows in one window. |
BucketHit | type | The state of a counter's window after a hit: how many hits it holds, and when it ends. |
CheckResult | type | A hit allowed, or refused with when to come back. |
createLimiter | function | The limiter of a product: fixed windows per address and per subject for each action, the limits from a table or from settings. |
dbLimitStore | function | The counters shared by every instance, in rate_limit.bucket (see rateLimitSchema; the schema may be renamed by the product). |
DEFAULT_MAX_KEYS | constant | The memory bound of the in-process store, not a business parameter. |
Limiter | type | |
LimiterDeps | type | |
LimitKeys | type | The keys one check counts. |
LimitsSource | type | The limits, by action: a table, or a function that answers for an action (so the product can read them from its settings at each check). |
LimitStore | type | Where counters live: a fixed window per key, started by its first hit and lasting windowSeconds. |
memoryLimitStore | function | Counters kept in this process: fine for one instance, and for tests; a second instance needs dbLimitStore. |
RATE_LIMIT_CODE | constant | Stable codes of this package's errors. |
RateLimitCode | type | |
RateLimited | class | A refusal: HTTP 429, with the seconds to wait (the Retry-After header). |
rateLimitSchema | constant | The table of counters of « rate-limit », in its own Postgres schema (ADR-001 §3). |
retryAfterSeconds | function | How long until a window ends, in whole seconds, at least one: the Retry-After of a refusal. |
@krizaka/rate-limit/settings
| Name | Kind | What it is |
|---|---|---|
ActionDefaults | type | What a product declares for an action: the defaults the admin can then change. |
limitsFromSettings | function | The limiter's limits function over the settings: it reads the keys of an action. |
rateLimitSettings | function | The registry fragment of the limits: limits.<action>.perIp, limits.<action>.perSubject (when the action has one) and limits.<action>.windowSeconds, eac… |
@krizaka/rate-limit/turnstile
| Name | Kind | What it is |
|---|---|---|
createTurnstileVerifier | function | A verifier with its replay memory: one per process, shared by the forms. |
TURNSTILE_MAX_TOKEN_LENGTH | constant | |
TURNSTILE_SITEVERIFY_URL | constant | Server-side check of a Cloudflare Turnstile token (siteverify). |
TurnstileCheck | type | |
TurnstileDeps | type | |
TurnstileFailure | type | |
TurnstileFetch | type | |
TurnstileResponse | type | |
TurnstileResult | type |
@krizaka/rate-limit/testkit
| Name | Kind | What it is |
|---|---|---|
limitStoreContract | function | What every LimitStore adapter must do: the same behaviour behind the same port. |
Package
| Version on npm | 0.2.1 (npm) |
| Licence | Apache-2.0 |
| Node.js | >=22 |
| Dependencies | @krizaka/db workspace:^, @krizaka/settings workspace:^, @krizaka/time workspace:^ |
| Peer dependencies | vitest >=4, zod >=3.23 |
| Source | rate-limit |
Changelog
0.2.1
Patch changes
- The READMEs, comments and published declarations no longer name the products that left the organisation (krizaka/.github#413, rule
no-departed-published). The sources were cleaned in #262 with an empty changeset, so the published tarballs still carried the old text; this release replaces them. No behaviour change. - Updated dependencies [2a7898c]
- Updated dependencies [2272b57]
- @krizaka/[email protected]
- @krizaka/[email protected]
0.2.0
Minor changes
@krizaka/rate-limit/turnstile:createTurnstileVerifierchecks a Cloudflare Turnstile token server-side (siteverify) and fails closed: no secret, no token, a refusal, a wrong hostname or action, a replayed token, a timeout or an unreachable service all refuse. Single-use, injected clock and fetch (krizaka-com#464).
0.1.2
Patch changes
- Updated dependencies [0d19b64]
- @krizaka/[email protected]
- @krizaka/[email protected]
0.1.1
Patch changes
- Updated dependencies [8210a9a]
- @krizaka/[email protected]
0.1.0
Minor changes
- New package (#54):
createLimiter(fixed windows per address and per subject for each action, the semantics of the copies Yomizaka, Akinaza and Kachizaka carried),memoryLimitStoreanddbLimitStore(one atomic upsert inrate_limit.bucket, exact under concurrency),rateLimitSchema,RateLimited(429 withRetry-After),@krizaka/rate-limit/settings(the limits aslimits.<action>.perIp|perSubject|windowSecondssettings) andlimitStoreContract.
Patch changes
- Updated dependencies [da6d980]
- @krizaka/[email protected]
@krizaka/privacy
Loi 25: consents by purpose written once, export and erasure assembled from every module, retention set by the admin.
@krizaka/email-verification
Proof of an e-mail address for public forms: a 6-digit code valid 15 minutes, single use, five tries, kept as a salted hash; the message is released only after the code.