@krizaka/rate-limit
Des limites par action : des fenêtres fixes par adresse et par sujet, réglées par l’admin, comptées exactement en concurrence.
Installer
npm install @krizaka/rate-limitAussi publié en sous-chemins : @krizaka/rate-limit/settings, @krizaka/rate-limit/turnstile, @krizaka/rate-limit/testkit.
Le README ci-dessous est celui du paquet sur npm, en anglais.
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
Les 17 noms que le paquet exporte, lus dans ses types.
| Nom | Nature | Ce que c’est |
|---|---|---|
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 | fonction | The limiter of a product: fixed windows per address and per subject for each action, the limits from a table or from settings. |
dbLimitStore | fonction | The counters shared by every instance, in rate_limit.bucket (see rateLimitSchema; the schema may be renamed by the product). |
DEFAULT_MAX_KEYS | constante | 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 | fonction | Counters kept in this process: fine for one instance, and for tests; a second instance needs dbLimitStore. |
RATE_LIMIT_CODE | constante | Stable codes of this package's errors. |
RateLimitCode | type | |
RateLimited | classe | A refusal: HTTP 429, with the seconds to wait (the Retry-After header). |
rateLimitSchema | constante | The table of counters of « rate-limit », in its own Postgres schema (ADR-001 §3). |
retryAfterSeconds | fonction | How long until a window ends, in whole seconds, at least one: the Retry-After of a refusal. |
@krizaka/rate-limit/settings
| Nom | Nature | Ce que c’est |
|---|---|---|
ActionDefaults | type | What a product declares for an action: the defaults the admin can then change. |
limitsFromSettings | fonction | The limiter's limits function over the settings: it reads the keys of an action. |
rateLimitSettings | fonction | 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
| Nom | Nature | Ce que c’est |
|---|---|---|
createTurnstileVerifier | fonction | A verifier with its replay memory: one per process, shared by the forms. |
TURNSTILE_MAX_TOKEN_LENGTH | constante | |
TURNSTILE_SITEVERIFY_URL | constante | 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
| Nom | Nature | Ce que c’est |
|---|---|---|
limitStoreContract | fonction | What every LimitStore adapter must do: the same behaviour behind the same port. |
Paquet
| Version sur npm | 0.2.1 (npm) |
| Licence | Apache-2.0 |
| Node.js | >=22 |
| Dépendances | @krizaka/db workspace:^, @krizaka/settings workspace:^, @krizaka/time workspace:^ |
| Dépendances à fournir | vitest >=4, zod >=3.23 |
| Code source | rate-limit |
Journal des versions
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 : des consentements par finalité écrits une fois, l’export et l’effacement assemblés depuis chaque module, une conservation réglée par l’admin.
@krizaka/email-verification
La preuve d’une adresse courriel pour les formulaires publics : un code à 6 chiffres valable 15 minutes, à usage unique, cinq essais, gardé sous forme de hachage salé ; le message n’est libéré qu’après le code.