Skip to the content
Krizaka TypeScript
Krizaka TypeScript

@krizaka/rate-limit

Limits per action: fixed windows per address and subject, set by the admin, counted exactly under concurrency.

Install

bashTerminal
npm install @krizaka/rate-limit

Also 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 a perSubject limit and one is given; an unknown address is counted under unknown. The old call limit("sign-in", req, email, [30, 900], [10, 900]) is enforce("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 clientIp of @krizaka/auth; the package reads no header.
  • Limits are settings. rateLimitSettings (@krizaka/rate-limit/settings, needs @krizaka/settings and zod) declares limits.<action>.perIp, .perSubject and .windowSeconds as integers with the product's defaults; limitsFromSettings reads 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 is rate_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: false rethrows instead.
  • The extras are in the port: peek (read without counting), set (a lock-out) and reset.
  • 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.

NameKindWhat it is
ActionLimittypeWhat one action allows in one window.
BucketHittypeThe state of a counter's window after a hit: how many hits it holds, and when it ends.
CheckResulttypeA hit allowed, or refused with when to come back.
createLimiterfunctionThe limiter of a product: fixed windows per address and per subject for each action, the limits from a table or from settings.
dbLimitStorefunctionThe counters shared by every instance, in rate_limit.bucket (see rateLimitSchema; the schema may be renamed by the product).
DEFAULT_MAX_KEYSconstantThe memory bound of the in-process store, not a business parameter.
Limitertype
LimiterDepstype
LimitKeystypeThe keys one check counts.
LimitsSourcetypeThe 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).
LimitStoretypeWhere counters live: a fixed window per key, started by its first hit and lasting windowSeconds.
memoryLimitStorefunctionCounters kept in this process: fine for one instance, and for tests; a second instance needs dbLimitStore.
RATE_LIMIT_CODEconstantStable codes of this package's errors.
RateLimitCodetype
RateLimitedclassA refusal: HTTP 429, with the seconds to wait (the Retry-After header).
rateLimitSchemaconstantThe table of counters of « rate-limit », in its own Postgres schema (ADR-001 §3).
retryAfterSecondsfunctionHow long until a window ends, in whole seconds, at least one: the Retry-After of a refusal.

@krizaka/rate-limit/settings

NameKindWhat it is
ActionDefaultstypeWhat a product declares for an action: the defaults the admin can then change.
limitsFromSettingsfunctionThe limiter's limits function over the settings: it reads the keys of an action.
rateLimitSettingsfunctionThe registry fragment of the limits: limits.&lt;action>.perIp, limits.&lt;action>.perSubject (when the action has one) and limits.&lt;action>.windowSeconds, eac…

@krizaka/rate-limit/turnstile

NameKindWhat it is
createTurnstileVerifierfunctionA verifier with its replay memory: one per process, shared by the forms.
TURNSTILE_MAX_TOKEN_LENGTHconstant
TURNSTILE_SITEVERIFY_URLconstantServer-side check of a Cloudflare Turnstile token (siteverify).
TurnstileChecktype
TurnstileDepstype
TurnstileFailuretype
TurnstileFetchtype
TurnstileResponsetype
TurnstileResulttype

@krizaka/rate-limit/testkit

NameKindWhat it is
limitStoreContractfunctionWhat every LimitStore adapter must do: the same behaviour behind the same port.

Package

Version on npm0.2.1 (npm)
LicenceApache-2.0
Node.js>=22
Dependencies@krizaka/db workspace:^, @krizaka/settings workspace:^, @krizaka/time workspace:^
Peer dependenciesvitest >=4, zod >=3.23
Sourcerate-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]

0.2.0

Minor changes

  • @krizaka/rate-limit/turnstile: createTurnstileVerifier checks 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

0.1.1

Patch changes

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), memoryLimitStore and dbLimitStore (one atomic upsert in rate_limit.bucket, exact under concurrency), rateLimitSchema, RateLimited (429 with Retry-After), @krizaka/rate-limit/settings (the limits as limits.<action>.perIp|perSubject|windowSeconds settings) and limitStoreContract.

Patch changes

On this page