@fonderie/rate-limit 4.0.2 → 4.0.4
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/brain/signatures.md +1 -1
- package/dist/index.cjs +7 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/brain/signatures.md
CHANGED
|
@@ -42,7 +42,7 @@ interface IRateLimitOptions {
|
|
|
42
42
|
|
|
43
43
|
type KeyFn = (ctx: IFonderieContext) => string | null;
|
|
44
44
|
|
|
45
|
-
new MemoryStore(): MemoryStore
|
|
45
|
+
new MemoryStore(options?: { now?: () => number; } | undefined): MemoryStore
|
|
46
46
|
.consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult>
|
|
47
47
|
.size: number
|
|
48
48
|
|
package/dist/index.cjs
CHANGED
|
@@ -133,11 +133,17 @@ function fullRefillMs(rule) {
|
|
|
133
133
|
var MemoryStore = class _MemoryStore {
|
|
134
134
|
buckets = /* @__PURE__ */ new Map();
|
|
135
135
|
ops = 0;
|
|
136
|
+
now;
|
|
136
137
|
// Sweep lazily every N operations rather than on a timer, so the store
|
|
137
138
|
// holds no open handle that keeps short-lived processes (tests, CLIs) alive.
|
|
138
139
|
static SWEEP_EVERY = 1024;
|
|
140
|
+
// `now` is injectable so the time-based sweep is deterministically testable
|
|
141
|
+
// (drive a fake clock instead of racing the wall clock); defaults to Date.now.
|
|
142
|
+
constructor(options) {
|
|
143
|
+
this.now = options?.now ?? (() => Date.now());
|
|
144
|
+
}
|
|
139
145
|
async consume(key, rule) {
|
|
140
|
-
const now =
|
|
146
|
+
const now = this.now();
|
|
141
147
|
const { next, result } = consumeFromBucket(this.buckets.get(key) ?? null, rule, now);
|
|
142
148
|
this.buckets.set(key, next);
|
|
143
149
|
if (++this.ops % _MemoryStore.SWEEP_EVERY === 0) this.sweep(rule, now);
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/middleware.ts","../src/bucket.ts","../src/stores/memory.ts","../src/stores/store-adapter.ts","../src/stores/redis.ts"],"sourcesContent":["// ── Public API ───────────────────────────────────────────────────\nexport type {\n\tIRateLimitRule,\n\tIConsumeResult,\n\tIRateLimitStore,\n\tIRedisEvalClient,\n} from './types';\n\nexport { rateLimit, byIp, byBodyField } from './middleware';\nexport type { IRateLimitOptions, KeyFn } from './middleware';\n\nexport { MemoryStore } from './stores/memory';\nexport { StoreAdapterStore } from './stores/store-adapter';\nexport { RedisStore } from './stores/redis';\n\n// Pure bucket math — exported for tests and custom stores.\nexport { consumeFromBucket, fullRefillMs } from './bucket';\nexport type { IBucketState } from './bucket';\n","import { createHash } from 'node:crypto';\n\nimport type { IFonderieContext, Middleware } from '@fonderie/core';\nimport { HTTP, setApiResponse } from '@fonderie/core';\n\nimport type { IRateLimitRule, IRateLimitStore } from './types';\n\n// Key extractors. A limiter guards a scarce thing — name it in the key so\n// two limiters on the same route can't collide.\n//\n// Every key is hashed to a fixed-width digest before it reaches a store:\n// - bounds key size (an attacker can't blow up storage with 10KB \"emails\")\n// - keeps user identifiers (emails, IPs) OUT of the rate-limit table as\n// plaintext — no PII to leak or to forget under a deletion request\n// The `scope` prefix stays readable so operators can eyeball which limiter a\n// key belongs to; only the identifying tail is digested.\n\nexport type KeyFn = (ctx: IFonderieContext) => string | null;\n\nfunction hashed(scope: string, ...parts: string[]): string {\n\tconst h = createHash('sha256').update(parts.join('\\0')).digest('base64url');\n\treturn `${scope}:${h}`;\n}\n\n// Client IP, as resolved by the adapter into ctx.meta['clientIp'] (see\n// resolveClientIp in @fonderie/core/middlewares — trust-proxy aware). Returns\n// null when unavailable, which skips this limiter rather than collapsing every\n// request onto one shared key.\n//\n// IPv6 is keyed on the /64 prefix, not the full address: a single residential\n// IPv6 allocation is a /64 (2^64 addresses), so per-exact-address limiting is\n// trivially bypassed. IPv4 keys on the full address.\nexport function byIp(scope: string): KeyFn {\n\treturn (ctx) => {\n\t\tconst ip = ctx.meta['clientIp'];\n\t\tif (typeof ip !== 'string' || ip.length === 0) return null;\n\t\treturn hashed(`${scope}:ip`, ipv6Prefix(ip));\n\t};\n}\n\n// Collapse an IPv6 address to its /64 network prefix; pass IPv4 through.\nfunction ipv6Prefix(ip: string): string {\n\tif (!ip.includes(':')) return ip; // IPv4\n\t// Expand omitted groups enough to take the first four (the /64 network).\n\tconst [head] = ip.split('%'); // strip zone id\n\tconst groups = head!.split('::');\n\tlet left = groups[0] ? groups[0].split(':') : [];\n\tlet right = groups[1] ? groups[1].split(':') : [];\n\tif (groups.length === 2) {\n\t\tconst fill = 8 - left.length - right.length;\n\t\tleft = [...left, ...Array(Math.max(0, fill)).fill('0'), ...right];\n\t}\n\treturn left.slice(0, 4).join(':') + '::/64';\n}\n\n// A field of the request body — e.g. the login email — normalized so\n// \"Jane@x.com\" and \"jane@x.com \" share a bucket, then hashed.\n//\n// SECURITY: place this limiter AFTER validate() in the route chain so the\n// field is a bounded, well-typed string before it becomes a key. On an\n// unvalidated body a caller could submit huge or non-string values; the\n// length guard below is a backstop, not the primary control.\nexport function byBodyField(scope: string, field: string): KeyFn {\n\treturn (ctx) => {\n\t\tconst body = ctx.meta['body'] as Record<string, unknown> | undefined;\n\t\tconst v = body?.[field];\n\t\tif (typeof v !== 'string' || v.length === 0) return null;\n\t\t// Backstop cap: an oversized value can't reach the hash unbounded.\n\t\tconst normalized = v.slice(0, 320).trim().toLowerCase();\n\t\tif (normalized.length === 0) return null;\n\t\treturn hashed(`${scope}:${field}`, normalized);\n\t};\n}\n\nexport interface IRateLimitOptions {\n\tstore: IRateLimitStore;\n\trule: IRateLimitRule;\n\tkey: KeyFn;\n\t// Fail-open (default) keeps auth available when the store is down —\n\t// an outage shouldn't lock every user out. Flip to fail-closed for\n\t// endpoints where an unthrottled request is worse than a rejected one.\n\t// This is a deliberate availability-over-strictness default; see the\n\t// package README § Fail-open.\n\tfailClosed?: boolean;\n}\n\n// One or more limits guarding a route; ALL must allow. Emits the IETF\n// draft-ietf-httpapi-ratelimit-headers fields on the 429 (RateLimit-Limit /\n// -Remaining / -Reset in seconds, plus Retry-After).\nexport function rateLimit(...limits: IRateLimitOptions[]): Middleware {\n\treturn async (ctx, next) => {\n\t\tfor (const limit of limits) {\n\t\t\tconst key = limit.key(ctx);\n\t\t\tif (key === null) continue;\n\n\t\t\tlet result: Awaited<ReturnType<IRateLimitStore['consume']>>;\n\t\t\ttry {\n\t\t\t\tresult = await limit.store.consume(key, limit.rule);\n\t\t\t} catch {\n\t\t\t\tif (limit.failClosed) {\n\t\t\t\t\treturn setApiResponse(\n\t\t\t\t\t\tHTTP.TOO_MANY_REQUESTS,\n\t\t\t\t\t\t'RATE_LIMITED',\n\t\t\t\t\t\t'Rate limiter unavailable. Please try again later.',\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tcontinue; // fail-open\n\t\t\t}\n\n\t\t\tif (!result.allowed) {\n\t\t\t\tconst resetSec = Math.ceil(result.retryAfterMs / 1000);\n\t\t\t\tconst res = setApiResponse(\n\t\t\t\t\tHTTP.TOO_MANY_REQUESTS,\n\t\t\t\t\t'RATE_LIMITED',\n\t\t\t\t\t'Too many requests. Please try again later.',\n\t\t\t\t\t{ retryAfter: resetSec },\n\t\t\t\t);\n\t\t\t\tres.headers.set('RateLimit-Limit', String(limit.rule.capacity));\n\t\t\t\tres.headers.set('RateLimit-Remaining', '0');\n\t\t\t\tres.headers.set('RateLimit-Reset', String(resetSec));\n\t\t\t\tres.headers.set('Retry-After', String(resetSec));\n\t\t\t\treturn res;\n\t\t\t}\n\t\t}\n\t\treturn next();\n\t};\n}\n","import type { IConsumeResult, IRateLimitRule } from './types';\n\n// Pure token-bucket math, shared by every store: given the persisted state\n// (tokens, lastRefillMs) and the current time, refill then try to consume.\n// Stores are responsible only for applying this atomically.\n\nexport interface IBucketState {\n\ttokens: number;\n\tlastRefillMs: number;\n}\n\nexport function consumeFromBucket(\n\tstate: IBucketState | null,\n\trule: IRateLimitRule,\n\tnowMs: number,\n): { next: IBucketState; result: IConsumeResult } {\n\tconst cost = rule.cost ?? 1;\n\tconst prevTokens = state ? state.tokens : rule.capacity;\n\tconst prevRefill = state ? state.lastRefillMs : nowMs;\n\n\tconst elapsedSec = Math.max(0, nowMs - prevRefill) / 1000;\n\tconst refilled = Math.min(rule.capacity, prevTokens + elapsedSec * rule.refillPerSec);\n\n\tif (refilled >= cost) {\n\t\tconst tokens = refilled - cost;\n\t\treturn {\n\t\t\tnext: { tokens, lastRefillMs: nowMs },\n\t\t\tresult: { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 },\n\t\t};\n\t}\n\n\tconst deficit = cost - refilled;\n\tconst retryAfterMs = Math.ceil((deficit / rule.refillPerSec) * 1000);\n\treturn {\n\t\tnext: { tokens: refilled, lastRefillMs: nowMs },\n\t\tresult: { allowed: false, remaining: 0, retryAfterMs },\n\t};\n}\n\n// How long until a full (idle) bucket forgets a key entirely — used by\n// stores for expiry so old keys don't accumulate forever.\nexport function fullRefillMs(rule: IRateLimitRule): number {\n\treturn Math.ceil((rule.capacity / rule.refillPerSec) * 1000);\n}\n","import { consumeFromBucket, fullRefillMs, type IBucketState } from '../bucket';\nimport type { IConsumeResult, IRateLimitRule, IRateLimitStore } from '../types';\n\n// Single-instance store. Atomic by virtue of the single-threaded event loop —\n// consume() does no awaiting between read and write. Correct for one process;\n// use StoreAdapterStore or RedisStore when running multiple instances.\n\nexport class MemoryStore implements IRateLimitStore {\n\tprivate buckets = new Map<string, IBucketState>();\n\tprivate ops = 0;\n\n\t// Sweep lazily every N operations rather than on a timer, so the store\n\t// holds no open handle that keeps short-lived processes (tests, CLIs) alive.\n\tprivate static SWEEP_EVERY = 1024;\n\n\tasync consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult> {\n\t\tconst now = Date.now();\n\t\tconst { next, result } = consumeFromBucket(this.buckets.get(key) ?? null, rule, now);\n\t\tthis.buckets.set(key, next);\n\n\t\tif (++this.ops % MemoryStore.SWEEP_EVERY === 0) this.sweep(rule, now);\n\t\treturn result;\n\t}\n\n\tprivate sweep(rule: IRateLimitRule, nowMs: number): void {\n\t\tconst idleMs = fullRefillMs(rule);\n\t\tfor (const [key, state] of this.buckets) {\n\t\t\t// A bucket idle long enough to be full again is indistinguishable\n\t\t\t// from an absent one — drop it.\n\t\t\tif (nowMs - state.lastRefillMs > idleMs) this.buckets.delete(key);\n\t\t}\n\t}\n\n\t// Test/ops introspection.\n\tget size(): number {\n\t\treturn this.buckets.size;\n\t}\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IConsumeResult, IRateLimitRule, IRateLimitStore } from '../types';\n\n// Distributed store over the IStoreAdapter (PostgreSQL) every Fonderie module\n// already receives. Refill-then-consume happens in ONE upsert — the\n// ON CONFLICT UPDATE recomputes the bucket from the stored row inside the\n// row lock the statement takes, so N app instances hammering the same key\n// can never both win the last token. No transaction, no read-modify-write.\n//\n// TIME COMES FROM THE DATABASE, not the app. `clock_timestamp()` is evaluated\n// once in the VALUES clause and reused via EXCLUDED.last_refill_ms in the\n// UPDATE — so every app instance measures elapsed time against ONE\n// authoritative clock. This removes app-server clock skew from the refill\n// math entirely (making \"distributed-correct\" literally true, not\n// \"true assuming NTP\"). clock_timestamp() — not now()/transaction_timestamp()\n// — because we want real wall-clock at execution, and it MUST be captured\n// once: a second call would return a slightly later value and desync the two\n// places `now` is used.\n//\n// RETURNING only sees the post-update row, which cannot distinguish\n// \"allowed, bucket now low\" from \"denied, bucket unchanged\" — so the\n// allow/deny verdict is computed INSIDE the statement and persisted to the\n// `granted` column, then read back.\n//\n// Params: $1 key, $2 capacity, $3 cost, $4 refill_per_sec.\n// `refilled` = min(capacity, old.tokens + elapsed_sec * refill_per_sec),\n// where elapsed uses EXCLUDED.last_refill_ms (this call's DB `now`).\nconst REFILLED = `LEAST($2::double precision,\n\tfonderie_rate_limits.tokens\n\t+ GREATEST(0, EXCLUDED.last_refill_ms - fonderie_rate_limits.last_refill_ms) / 1000.0\n\t * $4::double precision)`;\n\nconst CONSUME_SQL = `\nINSERT INTO fonderie_rate_limits (key, tokens, last_refill_ms, granted)\nVALUES (\n\t$1,\n\tGREATEST(0, $2::double precision - $3::double precision),\n\t(EXTRACT(EPOCH FROM clock_timestamp()) * 1000.0),\n\t$2::double precision >= $3::double precision\n)\nON CONFLICT (key) DO UPDATE SET\n\ttokens = CASE\n\t\tWHEN ${REFILLED} >= $3::double precision THEN ${REFILLED} - $3::double precision\n\t\tELSE ${REFILLED}\n\tEND,\n\tgranted = ${REFILLED} >= $3::double precision,\n\tlast_refill_ms = EXCLUDED.last_refill_ms\nRETURNING tokens, granted\n`;\n\n// Idle rows (past a full refill) are dead weight; prune them using DB time too.\nconst CLEAN_SQL = `\nDELETE FROM fonderie_rate_limits\nWHERE last_refill_ms < (EXTRACT(EPOCH FROM clock_timestamp()) * 1000.0) - $1\n`;\n\nexport class StoreAdapterStore implements IRateLimitStore {\n\tprivate ops = 0;\n\tprivate static CLEAN_EVERY = 512;\n\n\tconstructor(private store: IStoreAdapter) {}\n\n\tasync consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult> {\n\t\tconst cost = rule.cost ?? 1;\n\n\t\tconst rows = await this.store.query<{ tokens: number | string; granted: boolean }>(\n\t\t\tCONSUME_SQL,\n\t\t\t[key, rule.capacity, cost, rule.refillPerSec],\n\t\t);\n\t\tconst row = rows[0];\n\t\tif (!row) throw new Error('[rate-limit] consume returned no row');\n\n\t\tconst tokens = Number(row.tokens);\n\n\t\tif (++this.ops % StoreAdapterStore.CLEAN_EVERY === 0) {\n\t\t\tconst idleMs = Math.ceil((rule.capacity / rule.refillPerSec) * 1000);\n\t\t\tthis.store.query(CLEAN_SQL, [idleMs]).catch(() => {});\n\t\t}\n\n\t\tif (row.granted) {\n\t\t\treturn { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 };\n\t\t}\n\t\treturn {\n\t\t\tallowed: false,\n\t\t\tremaining: 0,\n\t\t\tretryAfterMs: Math.ceil(((cost - tokens) / rule.refillPerSec) * 1000),\n\t\t};\n\t}\n}\n","import type { IConsumeResult, IRateLimitRule, IRateLimitStore, IRedisEvalClient } from '../types';\n\n// High-throughput distributed store. Accepts any client exposing eval()\n// (ioredis and node-redis both do) — this package depends on no Redis\n// library. Refill-then-consume runs as one Lua script: Redis executes\n// scripts atomically, so cross-instance races are impossible by\n// construction. PEXPIRE gives free key expiry at full-refill time.\n//\n// TIME COMES FROM REDIS, not the app. `redis.call('TIME')` returns\n// [seconds, microseconds] from the Redis server clock, so every app instance\n// measures elapsed time against ONE authoritative clock — app-server skew\n// can't affect the refill math. (Effects-replication, default since Redis 5,\n// permits a non-deterministic read before writes; we target Redis 7.)\nconst CONSUME_LUA = `\nlocal key = KEYS[1]\nlocal capacity = tonumber(ARGV[1])\nlocal cost = tonumber(ARGV[2])\nlocal refill_per_sec = tonumber(ARGV[3])\nlocal ttl_ms = tonumber(ARGV[4])\n\nlocal t = redis.call('TIME')\nlocal now_ms = (tonumber(t[1]) * 1000) + (tonumber(t[2]) / 1000)\n\nlocal state = redis.call('HMGET', key, 'tokens', 'last_refill_ms')\nlocal tokens = tonumber(state[1])\nlocal last_refill = tonumber(state[2])\n\nif tokens == nil then\n tokens = capacity\n last_refill = now_ms\nend\n\nlocal elapsed_sec = math.max(0, now_ms - last_refill) / 1000\nlocal refilled = math.min(capacity, tokens + elapsed_sec * refill_per_sec)\n\nlocal allowed = 0\nlocal new_tokens = refilled\nif refilled >= cost then\n allowed = 1\n new_tokens = refilled - cost\nend\n\nredis.call('HSET', key, 'tokens', new_tokens, 'last_refill_ms', now_ms)\nredis.call('PEXPIRE', key, ttl_ms)\n\nreturn { allowed, tostring(new_tokens) }\n`;\n\nexport class RedisStore implements IRateLimitStore {\n\tconstructor(\n\t\tprivate client: IRedisEvalClient,\n\t\tprivate keyPrefix = 'fonderie:rl:',\n\t) {}\n\n\tasync consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult> {\n\t\tconst cost = rule.cost ?? 1;\n\t\tconst ttlMs = Math.ceil((rule.capacity / rule.refillPerSec) * 1000);\n\n\t\tconst raw = (await this.client.eval(\n\t\t\tCONSUME_LUA,\n\t\t\t1,\n\t\t\tthis.keyPrefix + key,\n\t\t\trule.capacity,\n\t\t\tcost,\n\t\t\trule.refillPerSec,\n\t\t\tttlMs,\n\t\t)) as [number, string];\n\n\t\tconst allowed = raw[0] === 1;\n\t\tconst tokens = Number(raw[1]);\n\n\t\tif (allowed) {\n\t\t\treturn { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 };\n\t\t}\n\t\treturn {\n\t\t\tallowed: false,\n\t\t\tremaining: 0,\n\t\t\tretryAfterMs: Math.ceil(((cost - tokens) / rule.refillPerSec) * 1000),\n\t\t};\n\t}\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/middleware.ts","../src/bucket.ts","../src/stores/memory.ts","../src/stores/store-adapter.ts","../src/stores/redis.ts"],"sourcesContent":["// ── Public API ───────────────────────────────────────────────────\nexport type {\n\tIRateLimitRule,\n\tIConsumeResult,\n\tIRateLimitStore,\n\tIRedisEvalClient,\n} from './types';\n\nexport { rateLimit, byIp, byBodyField } from './middleware';\nexport type { IRateLimitOptions, KeyFn } from './middleware';\n\nexport { MemoryStore } from './stores/memory';\nexport { StoreAdapterStore } from './stores/store-adapter';\nexport { RedisStore } from './stores/redis';\n\n// Pure bucket math — exported for tests and custom stores.\nexport { consumeFromBucket, fullRefillMs } from './bucket';\nexport type { IBucketState } from './bucket';\n","import { createHash } from 'node:crypto';\n\nimport type { IFonderieContext, Middleware } from '@fonderie/core';\nimport { HTTP, setApiResponse } from '@fonderie/core';\n\nimport type { IRateLimitRule, IRateLimitStore } from './types';\n\n// Key extractors. A limiter guards a scarce thing — name it in the key so\n// two limiters on the same route can't collide.\n//\n// Every key is hashed to a fixed-width digest before it reaches a store:\n// - bounds key size (an attacker can't blow up storage with 10KB \"emails\")\n// - keeps user identifiers (emails, IPs) OUT of the rate-limit table as\n// plaintext — no PII to leak or to forget under a deletion request\n// The `scope` prefix stays readable so operators can eyeball which limiter a\n// key belongs to; only the identifying tail is digested.\n\nexport type KeyFn = (ctx: IFonderieContext) => string | null;\n\nfunction hashed(scope: string, ...parts: string[]): string {\n\tconst h = createHash('sha256').update(parts.join('\\0')).digest('base64url');\n\treturn `${scope}:${h}`;\n}\n\n// Client IP, as resolved by the adapter into ctx.meta['clientIp'] (see\n// resolveClientIp in @fonderie/core/middlewares — trust-proxy aware). Returns\n// null when unavailable, which skips this limiter rather than collapsing every\n// request onto one shared key.\n//\n// IPv6 is keyed on the /64 prefix, not the full address: a single residential\n// IPv6 allocation is a /64 (2^64 addresses), so per-exact-address limiting is\n// trivially bypassed. IPv4 keys on the full address.\nexport function byIp(scope: string): KeyFn {\n\treturn (ctx) => {\n\t\tconst ip = ctx.meta['clientIp'];\n\t\tif (typeof ip !== 'string' || ip.length === 0) return null;\n\t\treturn hashed(`${scope}:ip`, ipv6Prefix(ip));\n\t};\n}\n\n// Collapse an IPv6 address to its /64 network prefix; pass IPv4 through.\nfunction ipv6Prefix(ip: string): string {\n\tif (!ip.includes(':')) return ip; // IPv4\n\t// Expand omitted groups enough to take the first four (the /64 network).\n\tconst [head] = ip.split('%'); // strip zone id\n\tconst groups = head!.split('::');\n\tlet left = groups[0] ? groups[0].split(':') : [];\n\tlet right = groups[1] ? groups[1].split(':') : [];\n\tif (groups.length === 2) {\n\t\tconst fill = 8 - left.length - right.length;\n\t\tleft = [...left, ...Array(Math.max(0, fill)).fill('0'), ...right];\n\t}\n\treturn left.slice(0, 4).join(':') + '::/64';\n}\n\n// A field of the request body — e.g. the login email — normalized so\n// \"Jane@x.com\" and \"jane@x.com \" share a bucket, then hashed.\n//\n// SECURITY: place this limiter AFTER validate() in the route chain so the\n// field is a bounded, well-typed string before it becomes a key. On an\n// unvalidated body a caller could submit huge or non-string values; the\n// length guard below is a backstop, not the primary control.\nexport function byBodyField(scope: string, field: string): KeyFn {\n\treturn (ctx) => {\n\t\tconst body = ctx.meta['body'] as Record<string, unknown> | undefined;\n\t\tconst v = body?.[field];\n\t\tif (typeof v !== 'string' || v.length === 0) return null;\n\t\t// Backstop cap: an oversized value can't reach the hash unbounded.\n\t\tconst normalized = v.slice(0, 320).trim().toLowerCase();\n\t\tif (normalized.length === 0) return null;\n\t\treturn hashed(`${scope}:${field}`, normalized);\n\t};\n}\n\nexport interface IRateLimitOptions {\n\tstore: IRateLimitStore;\n\trule: IRateLimitRule;\n\tkey: KeyFn;\n\t// Fail-open (default) keeps auth available when the store is down —\n\t// an outage shouldn't lock every user out. Flip to fail-closed for\n\t// endpoints where an unthrottled request is worse than a rejected one.\n\t// This is a deliberate availability-over-strictness default; see the\n\t// package README § Fail-open.\n\tfailClosed?: boolean;\n}\n\n// One or more limits guarding a route; ALL must allow. Emits the IETF\n// draft-ietf-httpapi-ratelimit-headers fields on the 429 (RateLimit-Limit /\n// -Remaining / -Reset in seconds, plus Retry-After).\nexport function rateLimit(...limits: IRateLimitOptions[]): Middleware {\n\treturn async (ctx, next) => {\n\t\tfor (const limit of limits) {\n\t\t\tconst key = limit.key(ctx);\n\t\t\tif (key === null) continue;\n\n\t\t\tlet result: Awaited<ReturnType<IRateLimitStore['consume']>>;\n\t\t\ttry {\n\t\t\t\tresult = await limit.store.consume(key, limit.rule);\n\t\t\t} catch {\n\t\t\t\tif (limit.failClosed) {\n\t\t\t\t\treturn setApiResponse(\n\t\t\t\t\t\tHTTP.TOO_MANY_REQUESTS,\n\t\t\t\t\t\t'RATE_LIMITED',\n\t\t\t\t\t\t'Rate limiter unavailable. Please try again later.',\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tcontinue; // fail-open\n\t\t\t}\n\n\t\t\tif (!result.allowed) {\n\t\t\t\tconst resetSec = Math.ceil(result.retryAfterMs / 1000);\n\t\t\t\tconst res = setApiResponse(\n\t\t\t\t\tHTTP.TOO_MANY_REQUESTS,\n\t\t\t\t\t'RATE_LIMITED',\n\t\t\t\t\t'Too many requests. Please try again later.',\n\t\t\t\t\t{ retryAfter: resetSec },\n\t\t\t\t);\n\t\t\t\tres.headers.set('RateLimit-Limit', String(limit.rule.capacity));\n\t\t\t\tres.headers.set('RateLimit-Remaining', '0');\n\t\t\t\tres.headers.set('RateLimit-Reset', String(resetSec));\n\t\t\t\tres.headers.set('Retry-After', String(resetSec));\n\t\t\t\treturn res;\n\t\t\t}\n\t\t}\n\t\treturn next();\n\t};\n}\n","import type { IConsumeResult, IRateLimitRule } from './types';\n\n// Pure token-bucket math, shared by every store: given the persisted state\n// (tokens, lastRefillMs) and the current time, refill then try to consume.\n// Stores are responsible only for applying this atomically.\n\nexport interface IBucketState {\n\ttokens: number;\n\tlastRefillMs: number;\n}\n\nexport function consumeFromBucket(\n\tstate: IBucketState | null,\n\trule: IRateLimitRule,\n\tnowMs: number,\n): { next: IBucketState; result: IConsumeResult } {\n\tconst cost = rule.cost ?? 1;\n\tconst prevTokens = state ? state.tokens : rule.capacity;\n\tconst prevRefill = state ? state.lastRefillMs : nowMs;\n\n\tconst elapsedSec = Math.max(0, nowMs - prevRefill) / 1000;\n\tconst refilled = Math.min(rule.capacity, prevTokens + elapsedSec * rule.refillPerSec);\n\n\tif (refilled >= cost) {\n\t\tconst tokens = refilled - cost;\n\t\treturn {\n\t\t\tnext: { tokens, lastRefillMs: nowMs },\n\t\t\tresult: { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 },\n\t\t};\n\t}\n\n\tconst deficit = cost - refilled;\n\tconst retryAfterMs = Math.ceil((deficit / rule.refillPerSec) * 1000);\n\treturn {\n\t\tnext: { tokens: refilled, lastRefillMs: nowMs },\n\t\tresult: { allowed: false, remaining: 0, retryAfterMs },\n\t};\n}\n\n// How long until a full (idle) bucket forgets a key entirely — used by\n// stores for expiry so old keys don't accumulate forever.\nexport function fullRefillMs(rule: IRateLimitRule): number {\n\treturn Math.ceil((rule.capacity / rule.refillPerSec) * 1000);\n}\n","import { consumeFromBucket, fullRefillMs, type IBucketState } from '../bucket';\nimport type { IConsumeResult, IRateLimitRule, IRateLimitStore } from '../types';\n\n// Single-instance store. Atomic by virtue of the single-threaded event loop —\n// consume() does no awaiting between read and write. Correct for one process;\n// use StoreAdapterStore or RedisStore when running multiple instances.\n\nexport class MemoryStore implements IRateLimitStore {\n\tprivate buckets = new Map<string, IBucketState>();\n\tprivate ops = 0;\n\tprivate readonly now: () => number;\n\n\t// Sweep lazily every N operations rather than on a timer, so the store\n\t// holds no open handle that keeps short-lived processes (tests, CLIs) alive.\n\tprivate static SWEEP_EVERY = 1024;\n\n\t// `now` is injectable so the time-based sweep is deterministically testable\n\t// (drive a fake clock instead of racing the wall clock); defaults to Date.now.\n\tconstructor(options?: { now?: () => number }) {\n\t\tthis.now = options?.now ?? (() => Date.now());\n\t}\n\n\tasync consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult> {\n\t\tconst now = this.now();\n\t\tconst { next, result } = consumeFromBucket(this.buckets.get(key) ?? null, rule, now);\n\t\tthis.buckets.set(key, next);\n\n\t\tif (++this.ops % MemoryStore.SWEEP_EVERY === 0) this.sweep(rule, now);\n\t\treturn result;\n\t}\n\n\tprivate sweep(rule: IRateLimitRule, nowMs: number): void {\n\t\tconst idleMs = fullRefillMs(rule);\n\t\tfor (const [key, state] of this.buckets) {\n\t\t\t// A bucket idle long enough to be full again is indistinguishable\n\t\t\t// from an absent one — drop it.\n\t\t\tif (nowMs - state.lastRefillMs > idleMs) this.buckets.delete(key);\n\t\t}\n\t}\n\n\t// Test/ops introspection.\n\tget size(): number {\n\t\treturn this.buckets.size;\n\t}\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IConsumeResult, IRateLimitRule, IRateLimitStore } from '../types';\n\n// Distributed store over the IStoreAdapter (PostgreSQL) every Fonderie module\n// already receives. Refill-then-consume happens in ONE upsert — the\n// ON CONFLICT UPDATE recomputes the bucket from the stored row inside the\n// row lock the statement takes, so N app instances hammering the same key\n// can never both win the last token. No transaction, no read-modify-write.\n//\n// TIME COMES FROM THE DATABASE, not the app. `clock_timestamp()` is evaluated\n// once in the VALUES clause and reused via EXCLUDED.last_refill_ms in the\n// UPDATE — so every app instance measures elapsed time against ONE\n// authoritative clock. This removes app-server clock skew from the refill\n// math entirely (making \"distributed-correct\" literally true, not\n// \"true assuming NTP\"). clock_timestamp() — not now()/transaction_timestamp()\n// — because we want real wall-clock at execution, and it MUST be captured\n// once: a second call would return a slightly later value and desync the two\n// places `now` is used.\n//\n// RETURNING only sees the post-update row, which cannot distinguish\n// \"allowed, bucket now low\" from \"denied, bucket unchanged\" — so the\n// allow/deny verdict is computed INSIDE the statement and persisted to the\n// `granted` column, then read back.\n//\n// Params: $1 key, $2 capacity, $3 cost, $4 refill_per_sec.\n// `refilled` = min(capacity, old.tokens + elapsed_sec * refill_per_sec),\n// where elapsed uses EXCLUDED.last_refill_ms (this call's DB `now`).\nconst REFILLED = `LEAST($2::double precision,\n\tfonderie_rate_limits.tokens\n\t+ GREATEST(0, EXCLUDED.last_refill_ms - fonderie_rate_limits.last_refill_ms) / 1000.0\n\t * $4::double precision)`;\n\nconst CONSUME_SQL = `\nINSERT INTO fonderie_rate_limits (key, tokens, last_refill_ms, granted)\nVALUES (\n\t$1,\n\tGREATEST(0, $2::double precision - $3::double precision),\n\t(EXTRACT(EPOCH FROM clock_timestamp()) * 1000.0),\n\t$2::double precision >= $3::double precision\n)\nON CONFLICT (key) DO UPDATE SET\n\ttokens = CASE\n\t\tWHEN ${REFILLED} >= $3::double precision THEN ${REFILLED} - $3::double precision\n\t\tELSE ${REFILLED}\n\tEND,\n\tgranted = ${REFILLED} >= $3::double precision,\n\tlast_refill_ms = EXCLUDED.last_refill_ms\nRETURNING tokens, granted\n`;\n\n// Idle rows (past a full refill) are dead weight; prune them using DB time too.\nconst CLEAN_SQL = `\nDELETE FROM fonderie_rate_limits\nWHERE last_refill_ms < (EXTRACT(EPOCH FROM clock_timestamp()) * 1000.0) - $1\n`;\n\nexport class StoreAdapterStore implements IRateLimitStore {\n\tprivate ops = 0;\n\tprivate static CLEAN_EVERY = 512;\n\n\tconstructor(private store: IStoreAdapter) {}\n\n\tasync consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult> {\n\t\tconst cost = rule.cost ?? 1;\n\n\t\tconst rows = await this.store.query<{ tokens: number | string; granted: boolean }>(\n\t\t\tCONSUME_SQL,\n\t\t\t[key, rule.capacity, cost, rule.refillPerSec],\n\t\t);\n\t\tconst row = rows[0];\n\t\tif (!row) throw new Error('[rate-limit] consume returned no row');\n\n\t\tconst tokens = Number(row.tokens);\n\n\t\tif (++this.ops % StoreAdapterStore.CLEAN_EVERY === 0) {\n\t\t\tconst idleMs = Math.ceil((rule.capacity / rule.refillPerSec) * 1000);\n\t\t\tthis.store.query(CLEAN_SQL, [idleMs]).catch(() => {});\n\t\t}\n\n\t\tif (row.granted) {\n\t\t\treturn { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 };\n\t\t}\n\t\treturn {\n\t\t\tallowed: false,\n\t\t\tremaining: 0,\n\t\t\tretryAfterMs: Math.ceil(((cost - tokens) / rule.refillPerSec) * 1000),\n\t\t};\n\t}\n}\n","import type { IConsumeResult, IRateLimitRule, IRateLimitStore, IRedisEvalClient } from '../types';\n\n// High-throughput distributed store. Accepts any client exposing eval()\n// (ioredis and node-redis both do) — this package depends on no Redis\n// library. Refill-then-consume runs as one Lua script: Redis executes\n// scripts atomically, so cross-instance races are impossible by\n// construction. PEXPIRE gives free key expiry at full-refill time.\n//\n// TIME COMES FROM REDIS, not the app. `redis.call('TIME')` returns\n// [seconds, microseconds] from the Redis server clock, so every app instance\n// measures elapsed time against ONE authoritative clock — app-server skew\n// can't affect the refill math. (Effects-replication, default since Redis 5,\n// permits a non-deterministic read before writes; we target Redis 7.)\nconst CONSUME_LUA = `\nlocal key = KEYS[1]\nlocal capacity = tonumber(ARGV[1])\nlocal cost = tonumber(ARGV[2])\nlocal refill_per_sec = tonumber(ARGV[3])\nlocal ttl_ms = tonumber(ARGV[4])\n\nlocal t = redis.call('TIME')\nlocal now_ms = (tonumber(t[1]) * 1000) + (tonumber(t[2]) / 1000)\n\nlocal state = redis.call('HMGET', key, 'tokens', 'last_refill_ms')\nlocal tokens = tonumber(state[1])\nlocal last_refill = tonumber(state[2])\n\nif tokens == nil then\n tokens = capacity\n last_refill = now_ms\nend\n\nlocal elapsed_sec = math.max(0, now_ms - last_refill) / 1000\nlocal refilled = math.min(capacity, tokens + elapsed_sec * refill_per_sec)\n\nlocal allowed = 0\nlocal new_tokens = refilled\nif refilled >= cost then\n allowed = 1\n new_tokens = refilled - cost\nend\n\nredis.call('HSET', key, 'tokens', new_tokens, 'last_refill_ms', now_ms)\nredis.call('PEXPIRE', key, ttl_ms)\n\nreturn { allowed, tostring(new_tokens) }\n`;\n\nexport class RedisStore implements IRateLimitStore {\n\tconstructor(\n\t\tprivate client: IRedisEvalClient,\n\t\tprivate keyPrefix = 'fonderie:rl:',\n\t) {}\n\n\tasync consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult> {\n\t\tconst cost = rule.cost ?? 1;\n\t\tconst ttlMs = Math.ceil((rule.capacity / rule.refillPerSec) * 1000);\n\n\t\tconst raw = (await this.client.eval(\n\t\t\tCONSUME_LUA,\n\t\t\t1,\n\t\t\tthis.keyPrefix + key,\n\t\t\trule.capacity,\n\t\t\tcost,\n\t\t\trule.refillPerSec,\n\t\t\tttlMs,\n\t\t)) as [number, string];\n\n\t\tconst allowed = raw[0] === 1;\n\t\tconst tokens = Number(raw[1]);\n\n\t\tif (allowed) {\n\t\t\treturn { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 };\n\t\t}\n\t\treturn {\n\t\t\tallowed: false,\n\t\t\tremaining: 0,\n\t\t\tretryAfterMs: Math.ceil(((cost - tokens) / rule.refillPerSec) * 1000),\n\t\t};\n\t}\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,yBAA2B;AAG3B,kBAAqC;AAgBrC,SAAS,OAAO,UAAkB,OAAyB;AAC1D,QAAM,QAAI,+BAAW,QAAQ,EAAE,OAAO,MAAM,KAAK,IAAI,CAAC,EAAE,OAAO,WAAW;AAC1E,SAAO,GAAG,KAAK,IAAI,CAAC;AACrB;AAUO,SAAS,KAAK,OAAsB;AAC1C,SAAO,CAAC,QAAQ;AACf,UAAM,KAAK,IAAI,KAAK,UAAU;AAC9B,QAAI,OAAO,OAAO,YAAY,GAAG,WAAW,EAAG,QAAO;AACtD,WAAO,OAAO,GAAG,KAAK,OAAO,WAAW,EAAE,CAAC;AAAA,EAC5C;AACD;AAGA,SAAS,WAAW,IAAoB;AACvC,MAAI,CAAC,GAAG,SAAS,GAAG,EAAG,QAAO;AAE9B,QAAM,CAAC,IAAI,IAAI,GAAG,MAAM,GAAG;AAC3B,QAAM,SAAS,KAAM,MAAM,IAAI;AAC/B,MAAI,OAAO,OAAO,CAAC,IAAI,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;AAC/C,MAAI,QAAQ,OAAO,CAAC,IAAI,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;AAChD,MAAI,OAAO,WAAW,GAAG;AACxB,UAAM,OAAO,IAAI,KAAK,SAAS,MAAM;AACrC,WAAO,CAAC,GAAG,MAAM,GAAG,MAAM,KAAK,IAAI,GAAG,IAAI,CAAC,EAAE,KAAK,GAAG,GAAG,GAAG,KAAK;AAAA,EACjE;AACA,SAAO,KAAK,MAAM,GAAG,CAAC,EAAE,KAAK,GAAG,IAAI;AACrC;AASO,SAAS,YAAY,OAAe,OAAsB;AAChE,SAAO,CAAC,QAAQ;AACf,UAAM,OAAO,IAAI,KAAK,MAAM;AAC5B,UAAM,IAAI,OAAO,KAAK;AACtB,QAAI,OAAO,MAAM,YAAY,EAAE,WAAW,EAAG,QAAO;AAEpD,UAAM,aAAa,EAAE,MAAM,GAAG,GAAG,EAAE,KAAK,EAAE,YAAY;AACtD,QAAI,WAAW,WAAW,EAAG,QAAO;AACpC,WAAO,OAAO,GAAG,KAAK,IAAI,KAAK,IAAI,UAAU;AAAA,EAC9C;AACD;AAiBO,SAAS,aAAa,QAAyC;AACrE,SAAO,OAAO,KAAK,SAAS;AAC3B,eAAW,SAAS,QAAQ;AAC3B,YAAM,MAAM,MAAM,IAAI,GAAG;AACzB,UAAI,QAAQ,KAAM;AAElB,UAAI;AACJ,UAAI;AACH,iBAAS,MAAM,MAAM,MAAM,QAAQ,KAAK,MAAM,IAAI;AAAA,MACnD,QAAQ;AACP,YAAI,MAAM,YAAY;AACrB,qBAAO;AAAA,YACN,iBAAK;AAAA,YACL;AAAA,YACA;AAAA,UACD;AAAA,QACD;AACA;AAAA,MACD;AAEA,UAAI,CAAC,OAAO,SAAS;AACpB,cAAM,WAAW,KAAK,KAAK,OAAO,eAAe,GAAI;AACrD,cAAM,UAAM;AAAA,UACX,iBAAK;AAAA,UACL;AAAA,UACA;AAAA,UACA,EAAE,YAAY,SAAS;AAAA,QACxB;AACA,YAAI,QAAQ,IAAI,mBAAmB,OAAO,MAAM,KAAK,QAAQ,CAAC;AAC9D,YAAI,QAAQ,IAAI,uBAAuB,GAAG;AAC1C,YAAI,QAAQ,IAAI,mBAAmB,OAAO,QAAQ,CAAC;AACnD,YAAI,QAAQ,IAAI,eAAe,OAAO,QAAQ,CAAC;AAC/C,eAAO;AAAA,MACR;AAAA,IACD;AACA,WAAO,KAAK;AAAA,EACb;AACD;;;ACnHO,SAAS,kBACf,OACA,MACA,OACiD;AACjD,QAAM,OAAO,KAAK,QAAQ;AAC1B,QAAM,aAAa,QAAQ,MAAM,SAAS,KAAK;AAC/C,QAAM,aAAa,QAAQ,MAAM,eAAe;AAEhD,QAAM,aAAa,KAAK,IAAI,GAAG,QAAQ,UAAU,IAAI;AACrD,QAAM,WAAW,KAAK,IAAI,KAAK,UAAU,aAAa,aAAa,KAAK,YAAY;AAEpF,MAAI,YAAY,MAAM;AACrB,UAAM,SAAS,WAAW;AAC1B,WAAO;AAAA,MACN,MAAM,EAAE,QAAQ,cAAc,MAAM;AAAA,MACpC,QAAQ,EAAE,SAAS,MAAM,WAAW,KAAK,MAAM,MAAM,GAAG,cAAc,EAAE;AAAA,IACzE;AAAA,EACD;AAEA,QAAM,UAAU,OAAO;AACvB,QAAM,eAAe,KAAK,KAAM,UAAU,KAAK,eAAgB,GAAI;AACnE,SAAO;AAAA,IACN,MAAM,EAAE,QAAQ,UAAU,cAAc,MAAM;AAAA,IAC9C,QAAQ,EAAE,SAAS,OAAO,WAAW,GAAG,aAAa;AAAA,EACtD;AACD;AAIO,SAAS,aAAa,MAA8B;AAC1D,SAAO,KAAK,KAAM,KAAK,WAAW,KAAK,eAAgB,GAAI;AAC5D;;;ACpCO,IAAM,cAAN,MAAM,aAAuC;AAAA,EAC3C,UAAU,oBAAI,IAA0B;AAAA,EACxC,MAAM;AAAA,EACG;AAAA;AAAA;AAAA,EAIjB,OAAe,cAAc;AAAA;AAAA;AAAA,EAI7B,YAAY,SAAkC;AAC7C,SAAK,MAAM,SAAS,QAAQ,MAAM,KAAK,IAAI;AAAA,EAC5C;AAAA,EAEA,MAAM,QAAQ,KAAa,MAA+C;AACzE,UAAM,MAAM,KAAK,IAAI;AACrB,UAAM,EAAE,MAAM,OAAO,IAAI,kBAAkB,KAAK,QAAQ,IAAI,GAAG,KAAK,MAAM,MAAM,GAAG;AACnF,SAAK,QAAQ,IAAI,KAAK,IAAI;AAE1B,QAAI,EAAE,KAAK,MAAM,aAAY,gBAAgB,EAAG,MAAK,MAAM,MAAM,GAAG;AACpE,WAAO;AAAA,EACR;AAAA,EAEQ,MAAM,MAAsB,OAAqB;AACxD,UAAM,SAAS,aAAa,IAAI;AAChC,eAAW,CAAC,KAAK,KAAK,KAAK,KAAK,SAAS;AAGxC,UAAI,QAAQ,MAAM,eAAe,OAAQ,MAAK,QAAQ,OAAO,GAAG;AAAA,IACjE;AAAA,EACD;AAAA;AAAA,EAGA,IAAI,OAAe;AAClB,WAAO,KAAK,QAAQ;AAAA,EACrB;AACD;;;AChBA,IAAM,WAAW;AAAA;AAAA;AAAA;AAKjB,IAAM,cAAc;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,SAUX,QAAQ,iCAAiC,QAAQ;AAAA,SACjD,QAAQ;AAAA;AAAA,aAEJ,QAAQ;AAAA;AAAA;AAAA;AAMrB,IAAM,YAAY;AAAA;AAAA;AAAA;AAKX,IAAM,oBAAN,MAAM,mBAA6C;AAAA,EAIzD,YAAoB,OAAsB;AAAtB;AAAA,EAAuB;AAAA,EAAvB;AAAA,EAHZ,MAAM;AAAA,EACd,OAAe,cAAc;AAAA,EAI7B,MAAM,QAAQ,KAAa,MAA+C;AACzE,UAAM,OAAO,KAAK,QAAQ;AAE1B,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B;AAAA,MACA,CAAC,KAAK,KAAK,UAAU,MAAM,KAAK,YAAY;AAAA,IAC7C;AACA,UAAM,MAAM,KAAK,CAAC;AAClB,QAAI,CAAC,IAAK,OAAM,IAAI,MAAM,sCAAsC;AAEhE,UAAM,SAAS,OAAO,IAAI,MAAM;AAEhC,QAAI,EAAE,KAAK,MAAM,mBAAkB,gBAAgB,GAAG;AACrD,YAAM,SAAS,KAAK,KAAM,KAAK,WAAW,KAAK,eAAgB,GAAI;AACnE,WAAK,MAAM,MAAM,WAAW,CAAC,MAAM,CAAC,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AAAA,IACrD;AAEA,QAAI,IAAI,SAAS;AAChB,aAAO,EAAE,SAAS,MAAM,WAAW,KAAK,MAAM,MAAM,GAAG,cAAc,EAAE;AAAA,IACxE;AACA,WAAO;AAAA,MACN,SAAS;AAAA,MACT,WAAW;AAAA,MACX,cAAc,KAAK,MAAO,OAAO,UAAU,KAAK,eAAgB,GAAI;AAAA,IACrE;AAAA,EACD;AACD;;;AC5EA,IAAM,cAAc;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmCb,IAAM,aAAN,MAA4C;AAAA,EAClD,YACS,QACA,YAAY,gBACnB;AAFO;AACA;AAAA,EACN;AAAA,EAFM;AAAA,EACA;AAAA,EAGT,MAAM,QAAQ,KAAa,MAA+C;AACzE,UAAM,OAAO,KAAK,QAAQ;AAC1B,UAAM,QAAQ,KAAK,KAAM,KAAK,WAAW,KAAK,eAAgB,GAAI;AAElE,UAAM,MAAO,MAAM,KAAK,OAAO;AAAA,MAC9B;AAAA,MACA;AAAA,MACA,KAAK,YAAY;AAAA,MACjB,KAAK;AAAA,MACL;AAAA,MACA,KAAK;AAAA,MACL;AAAA,IACD;AAEA,UAAM,UAAU,IAAI,CAAC,MAAM;AAC3B,UAAM,SAAS,OAAO,IAAI,CAAC,CAAC;AAE5B,QAAI,SAAS;AACZ,aAAO,EAAE,SAAS,MAAM,WAAW,KAAK,MAAM,MAAM,GAAG,cAAc,EAAE;AAAA,IACxE;AACA,WAAO;AAAA,MACN,SAAS;AAAA,MACT,WAAW;AAAA,MACX,cAAc,KAAK,MAAO,OAAO,UAAU,KAAK,eAAgB,GAAI;AAAA,IACrE;AAAA,EACD;AACD;","names":[]}
|
package/dist/index.d.cts
CHANGED
|
@@ -32,7 +32,11 @@ declare function rateLimit(...limits: IRateLimitOptions[]): Middleware;
|
|
|
32
32
|
declare class MemoryStore implements IRateLimitStore {
|
|
33
33
|
private buckets;
|
|
34
34
|
private ops;
|
|
35
|
+
private readonly now;
|
|
35
36
|
private static SWEEP_EVERY;
|
|
37
|
+
constructor(options?: {
|
|
38
|
+
now?: () => number;
|
|
39
|
+
});
|
|
36
40
|
consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult>;
|
|
37
41
|
private sweep;
|
|
38
42
|
get size(): number;
|
package/dist/index.d.ts
CHANGED
|
@@ -32,7 +32,11 @@ declare function rateLimit(...limits: IRateLimitOptions[]): Middleware;
|
|
|
32
32
|
declare class MemoryStore implements IRateLimitStore {
|
|
33
33
|
private buckets;
|
|
34
34
|
private ops;
|
|
35
|
+
private readonly now;
|
|
35
36
|
private static SWEEP_EVERY;
|
|
37
|
+
constructor(options?: {
|
|
38
|
+
now?: () => number;
|
|
39
|
+
});
|
|
36
40
|
consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult>;
|
|
37
41
|
private sweep;
|
|
38
42
|
get size(): number;
|
package/dist/index.js
CHANGED
|
@@ -100,11 +100,17 @@ function fullRefillMs(rule) {
|
|
|
100
100
|
var MemoryStore = class _MemoryStore {
|
|
101
101
|
buckets = /* @__PURE__ */ new Map();
|
|
102
102
|
ops = 0;
|
|
103
|
+
now;
|
|
103
104
|
// Sweep lazily every N operations rather than on a timer, so the store
|
|
104
105
|
// holds no open handle that keeps short-lived processes (tests, CLIs) alive.
|
|
105
106
|
static SWEEP_EVERY = 1024;
|
|
107
|
+
// `now` is injectable so the time-based sweep is deterministically testable
|
|
108
|
+
// (drive a fake clock instead of racing the wall clock); defaults to Date.now.
|
|
109
|
+
constructor(options) {
|
|
110
|
+
this.now = options?.now ?? (() => Date.now());
|
|
111
|
+
}
|
|
106
112
|
async consume(key, rule) {
|
|
107
|
-
const now =
|
|
113
|
+
const now = this.now();
|
|
108
114
|
const { next, result } = consumeFromBucket(this.buckets.get(key) ?? null, rule, now);
|
|
109
115
|
this.buckets.set(key, next);
|
|
110
116
|
if (++this.ops % _MemoryStore.SWEEP_EVERY === 0) this.sweep(rule, now);
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/middleware.ts","../src/bucket.ts","../src/stores/memory.ts","../src/stores/store-adapter.ts","../src/stores/redis.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\n\nimport type { IFonderieContext, Middleware } from '@fonderie/core';\nimport { HTTP, setApiResponse } from '@fonderie/core';\n\nimport type { IRateLimitRule, IRateLimitStore } from './types';\n\n// Key extractors. A limiter guards a scarce thing — name it in the key so\n// two limiters on the same route can't collide.\n//\n// Every key is hashed to a fixed-width digest before it reaches a store:\n// - bounds key size (an attacker can't blow up storage with 10KB \"emails\")\n// - keeps user identifiers (emails, IPs) OUT of the rate-limit table as\n// plaintext — no PII to leak or to forget under a deletion request\n// The `scope` prefix stays readable so operators can eyeball which limiter a\n// key belongs to; only the identifying tail is digested.\n\nexport type KeyFn = (ctx: IFonderieContext) => string | null;\n\nfunction hashed(scope: string, ...parts: string[]): string {\n\tconst h = createHash('sha256').update(parts.join('\\0')).digest('base64url');\n\treturn `${scope}:${h}`;\n}\n\n// Client IP, as resolved by the adapter into ctx.meta['clientIp'] (see\n// resolveClientIp in @fonderie/core/middlewares — trust-proxy aware). Returns\n// null when unavailable, which skips this limiter rather than collapsing every\n// request onto one shared key.\n//\n// IPv6 is keyed on the /64 prefix, not the full address: a single residential\n// IPv6 allocation is a /64 (2^64 addresses), so per-exact-address limiting is\n// trivially bypassed. IPv4 keys on the full address.\nexport function byIp(scope: string): KeyFn {\n\treturn (ctx) => {\n\t\tconst ip = ctx.meta['clientIp'];\n\t\tif (typeof ip !== 'string' || ip.length === 0) return null;\n\t\treturn hashed(`${scope}:ip`, ipv6Prefix(ip));\n\t};\n}\n\n// Collapse an IPv6 address to its /64 network prefix; pass IPv4 through.\nfunction ipv6Prefix(ip: string): string {\n\tif (!ip.includes(':')) return ip; // IPv4\n\t// Expand omitted groups enough to take the first four (the /64 network).\n\tconst [head] = ip.split('%'); // strip zone id\n\tconst groups = head!.split('::');\n\tlet left = groups[0] ? groups[0].split(':') : [];\n\tlet right = groups[1] ? groups[1].split(':') : [];\n\tif (groups.length === 2) {\n\t\tconst fill = 8 - left.length - right.length;\n\t\tleft = [...left, ...Array(Math.max(0, fill)).fill('0'), ...right];\n\t}\n\treturn left.slice(0, 4).join(':') + '::/64';\n}\n\n// A field of the request body — e.g. the login email — normalized so\n// \"Jane@x.com\" and \"jane@x.com \" share a bucket, then hashed.\n//\n// SECURITY: place this limiter AFTER validate() in the route chain so the\n// field is a bounded, well-typed string before it becomes a key. On an\n// unvalidated body a caller could submit huge or non-string values; the\n// length guard below is a backstop, not the primary control.\nexport function byBodyField(scope: string, field: string): KeyFn {\n\treturn (ctx) => {\n\t\tconst body = ctx.meta['body'] as Record<string, unknown> | undefined;\n\t\tconst v = body?.[field];\n\t\tif (typeof v !== 'string' || v.length === 0) return null;\n\t\t// Backstop cap: an oversized value can't reach the hash unbounded.\n\t\tconst normalized = v.slice(0, 320).trim().toLowerCase();\n\t\tif (normalized.length === 0) return null;\n\t\treturn hashed(`${scope}:${field}`, normalized);\n\t};\n}\n\nexport interface IRateLimitOptions {\n\tstore: IRateLimitStore;\n\trule: IRateLimitRule;\n\tkey: KeyFn;\n\t// Fail-open (default) keeps auth available when the store is down —\n\t// an outage shouldn't lock every user out. Flip to fail-closed for\n\t// endpoints where an unthrottled request is worse than a rejected one.\n\t// This is a deliberate availability-over-strictness default; see the\n\t// package README § Fail-open.\n\tfailClosed?: boolean;\n}\n\n// One or more limits guarding a route; ALL must allow. Emits the IETF\n// draft-ietf-httpapi-ratelimit-headers fields on the 429 (RateLimit-Limit /\n// -Remaining / -Reset in seconds, plus Retry-After).\nexport function rateLimit(...limits: IRateLimitOptions[]): Middleware {\n\treturn async (ctx, next) => {\n\t\tfor (const limit of limits) {\n\t\t\tconst key = limit.key(ctx);\n\t\t\tif (key === null) continue;\n\n\t\t\tlet result: Awaited<ReturnType<IRateLimitStore['consume']>>;\n\t\t\ttry {\n\t\t\t\tresult = await limit.store.consume(key, limit.rule);\n\t\t\t} catch {\n\t\t\t\tif (limit.failClosed) {\n\t\t\t\t\treturn setApiResponse(\n\t\t\t\t\t\tHTTP.TOO_MANY_REQUESTS,\n\t\t\t\t\t\t'RATE_LIMITED',\n\t\t\t\t\t\t'Rate limiter unavailable. Please try again later.',\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tcontinue; // fail-open\n\t\t\t}\n\n\t\t\tif (!result.allowed) {\n\t\t\t\tconst resetSec = Math.ceil(result.retryAfterMs / 1000);\n\t\t\t\tconst res = setApiResponse(\n\t\t\t\t\tHTTP.TOO_MANY_REQUESTS,\n\t\t\t\t\t'RATE_LIMITED',\n\t\t\t\t\t'Too many requests. Please try again later.',\n\t\t\t\t\t{ retryAfter: resetSec },\n\t\t\t\t);\n\t\t\t\tres.headers.set('RateLimit-Limit', String(limit.rule.capacity));\n\t\t\t\tres.headers.set('RateLimit-Remaining', '0');\n\t\t\t\tres.headers.set('RateLimit-Reset', String(resetSec));\n\t\t\t\tres.headers.set('Retry-After', String(resetSec));\n\t\t\t\treturn res;\n\t\t\t}\n\t\t}\n\t\treturn next();\n\t};\n}\n","import type { IConsumeResult, IRateLimitRule } from './types';\n\n// Pure token-bucket math, shared by every store: given the persisted state\n// (tokens, lastRefillMs) and the current time, refill then try to consume.\n// Stores are responsible only for applying this atomically.\n\nexport interface IBucketState {\n\ttokens: number;\n\tlastRefillMs: number;\n}\n\nexport function consumeFromBucket(\n\tstate: IBucketState | null,\n\trule: IRateLimitRule,\n\tnowMs: number,\n): { next: IBucketState; result: IConsumeResult } {\n\tconst cost = rule.cost ?? 1;\n\tconst prevTokens = state ? state.tokens : rule.capacity;\n\tconst prevRefill = state ? state.lastRefillMs : nowMs;\n\n\tconst elapsedSec = Math.max(0, nowMs - prevRefill) / 1000;\n\tconst refilled = Math.min(rule.capacity, prevTokens + elapsedSec * rule.refillPerSec);\n\n\tif (refilled >= cost) {\n\t\tconst tokens = refilled - cost;\n\t\treturn {\n\t\t\tnext: { tokens, lastRefillMs: nowMs },\n\t\t\tresult: { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 },\n\t\t};\n\t}\n\n\tconst deficit = cost - refilled;\n\tconst retryAfterMs = Math.ceil((deficit / rule.refillPerSec) * 1000);\n\treturn {\n\t\tnext: { tokens: refilled, lastRefillMs: nowMs },\n\t\tresult: { allowed: false, remaining: 0, retryAfterMs },\n\t};\n}\n\n// How long until a full (idle) bucket forgets a key entirely — used by\n// stores for expiry so old keys don't accumulate forever.\nexport function fullRefillMs(rule: IRateLimitRule): number {\n\treturn Math.ceil((rule.capacity / rule.refillPerSec) * 1000);\n}\n","import { consumeFromBucket, fullRefillMs, type IBucketState } from '../bucket';\nimport type { IConsumeResult, IRateLimitRule, IRateLimitStore } from '../types';\n\n// Single-instance store. Atomic by virtue of the single-threaded event loop —\n// consume() does no awaiting between read and write. Correct for one process;\n// use StoreAdapterStore or RedisStore when running multiple instances.\n\nexport class MemoryStore implements IRateLimitStore {\n\tprivate buckets = new Map<string, IBucketState>();\n\tprivate ops = 0;\n\n\t// Sweep lazily every N operations rather than on a timer, so the store\n\t// holds no open handle that keeps short-lived processes (tests, CLIs) alive.\n\tprivate static SWEEP_EVERY = 1024;\n\n\tasync consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult> {\n\t\tconst now = Date.now();\n\t\tconst { next, result } = consumeFromBucket(this.buckets.get(key) ?? null, rule, now);\n\t\tthis.buckets.set(key, next);\n\n\t\tif (++this.ops % MemoryStore.SWEEP_EVERY === 0) this.sweep(rule, now);\n\t\treturn result;\n\t}\n\n\tprivate sweep(rule: IRateLimitRule, nowMs: number): void {\n\t\tconst idleMs = fullRefillMs(rule);\n\t\tfor (const [key, state] of this.buckets) {\n\t\t\t// A bucket idle long enough to be full again is indistinguishable\n\t\t\t// from an absent one — drop it.\n\t\t\tif (nowMs - state.lastRefillMs > idleMs) this.buckets.delete(key);\n\t\t}\n\t}\n\n\t// Test/ops introspection.\n\tget size(): number {\n\t\treturn this.buckets.size;\n\t}\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IConsumeResult, IRateLimitRule, IRateLimitStore } from '../types';\n\n// Distributed store over the IStoreAdapter (PostgreSQL) every Fonderie module\n// already receives. Refill-then-consume happens in ONE upsert — the\n// ON CONFLICT UPDATE recomputes the bucket from the stored row inside the\n// row lock the statement takes, so N app instances hammering the same key\n// can never both win the last token. No transaction, no read-modify-write.\n//\n// TIME COMES FROM THE DATABASE, not the app. `clock_timestamp()` is evaluated\n// once in the VALUES clause and reused via EXCLUDED.last_refill_ms in the\n// UPDATE — so every app instance measures elapsed time against ONE\n// authoritative clock. This removes app-server clock skew from the refill\n// math entirely (making \"distributed-correct\" literally true, not\n// \"true assuming NTP\"). clock_timestamp() — not now()/transaction_timestamp()\n// — because we want real wall-clock at execution, and it MUST be captured\n// once: a second call would return a slightly later value and desync the two\n// places `now` is used.\n//\n// RETURNING only sees the post-update row, which cannot distinguish\n// \"allowed, bucket now low\" from \"denied, bucket unchanged\" — so the\n// allow/deny verdict is computed INSIDE the statement and persisted to the\n// `granted` column, then read back.\n//\n// Params: $1 key, $2 capacity, $3 cost, $4 refill_per_sec.\n// `refilled` = min(capacity, old.tokens + elapsed_sec * refill_per_sec),\n// where elapsed uses EXCLUDED.last_refill_ms (this call's DB `now`).\nconst REFILLED = `LEAST($2::double precision,\n\tfonderie_rate_limits.tokens\n\t+ GREATEST(0, EXCLUDED.last_refill_ms - fonderie_rate_limits.last_refill_ms) / 1000.0\n\t * $4::double precision)`;\n\nconst CONSUME_SQL = `\nINSERT INTO fonderie_rate_limits (key, tokens, last_refill_ms, granted)\nVALUES (\n\t$1,\n\tGREATEST(0, $2::double precision - $3::double precision),\n\t(EXTRACT(EPOCH FROM clock_timestamp()) * 1000.0),\n\t$2::double precision >= $3::double precision\n)\nON CONFLICT (key) DO UPDATE SET\n\ttokens = CASE\n\t\tWHEN ${REFILLED} >= $3::double precision THEN ${REFILLED} - $3::double precision\n\t\tELSE ${REFILLED}\n\tEND,\n\tgranted = ${REFILLED} >= $3::double precision,\n\tlast_refill_ms = EXCLUDED.last_refill_ms\nRETURNING tokens, granted\n`;\n\n// Idle rows (past a full refill) are dead weight; prune them using DB time too.\nconst CLEAN_SQL = `\nDELETE FROM fonderie_rate_limits\nWHERE last_refill_ms < (EXTRACT(EPOCH FROM clock_timestamp()) * 1000.0) - $1\n`;\n\nexport class StoreAdapterStore implements IRateLimitStore {\n\tprivate ops = 0;\n\tprivate static CLEAN_EVERY = 512;\n\n\tconstructor(private store: IStoreAdapter) {}\n\n\tasync consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult> {\n\t\tconst cost = rule.cost ?? 1;\n\n\t\tconst rows = await this.store.query<{ tokens: number | string; granted: boolean }>(\n\t\t\tCONSUME_SQL,\n\t\t\t[key, rule.capacity, cost, rule.refillPerSec],\n\t\t);\n\t\tconst row = rows[0];\n\t\tif (!row) throw new Error('[rate-limit] consume returned no row');\n\n\t\tconst tokens = Number(row.tokens);\n\n\t\tif (++this.ops % StoreAdapterStore.CLEAN_EVERY === 0) {\n\t\t\tconst idleMs = Math.ceil((rule.capacity / rule.refillPerSec) * 1000);\n\t\t\tthis.store.query(CLEAN_SQL, [idleMs]).catch(() => {});\n\t\t}\n\n\t\tif (row.granted) {\n\t\t\treturn { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 };\n\t\t}\n\t\treturn {\n\t\t\tallowed: false,\n\t\t\tremaining: 0,\n\t\t\tretryAfterMs: Math.ceil(((cost - tokens) / rule.refillPerSec) * 1000),\n\t\t};\n\t}\n}\n","import type { IConsumeResult, IRateLimitRule, IRateLimitStore, IRedisEvalClient } from '../types';\n\n// High-throughput distributed store. Accepts any client exposing eval()\n// (ioredis and node-redis both do) — this package depends on no Redis\n// library. Refill-then-consume runs as one Lua script: Redis executes\n// scripts atomically, so cross-instance races are impossible by\n// construction. PEXPIRE gives free key expiry at full-refill time.\n//\n// TIME COMES FROM REDIS, not the app. `redis.call('TIME')` returns\n// [seconds, microseconds] from the Redis server clock, so every app instance\n// measures elapsed time against ONE authoritative clock — app-server skew\n// can't affect the refill math. (Effects-replication, default since Redis 5,\n// permits a non-deterministic read before writes; we target Redis 7.)\nconst CONSUME_LUA = `\nlocal key = KEYS[1]\nlocal capacity = tonumber(ARGV[1])\nlocal cost = tonumber(ARGV[2])\nlocal refill_per_sec = tonumber(ARGV[3])\nlocal ttl_ms = tonumber(ARGV[4])\n\nlocal t = redis.call('TIME')\nlocal now_ms = (tonumber(t[1]) * 1000) + (tonumber(t[2]) / 1000)\n\nlocal state = redis.call('HMGET', key, 'tokens', 'last_refill_ms')\nlocal tokens = tonumber(state[1])\nlocal last_refill = tonumber(state[2])\n\nif tokens == nil then\n tokens = capacity\n last_refill = now_ms\nend\n\nlocal elapsed_sec = math.max(0, now_ms - last_refill) / 1000\nlocal refilled = math.min(capacity, tokens + elapsed_sec * refill_per_sec)\n\nlocal allowed = 0\nlocal new_tokens = refilled\nif refilled >= cost then\n allowed = 1\n new_tokens = refilled - cost\nend\n\nredis.call('HSET', key, 'tokens', new_tokens, 'last_refill_ms', now_ms)\nredis.call('PEXPIRE', key, ttl_ms)\n\nreturn { allowed, tostring(new_tokens) }\n`;\n\nexport class RedisStore implements IRateLimitStore {\n\tconstructor(\n\t\tprivate client: IRedisEvalClient,\n\t\tprivate keyPrefix = 'fonderie:rl:',\n\t) {}\n\n\tasync consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult> {\n\t\tconst cost = rule.cost ?? 1;\n\t\tconst ttlMs = Math.ceil((rule.capacity / rule.refillPerSec) * 1000);\n\n\t\tconst raw = (await this.client.eval(\n\t\t\tCONSUME_LUA,\n\t\t\t1,\n\t\t\tthis.keyPrefix + key,\n\t\t\trule.capacity,\n\t\t\tcost,\n\t\t\trule.refillPerSec,\n\t\t\tttlMs,\n\t\t)) as [number, string];\n\n\t\tconst allowed = raw[0] === 1;\n\t\tconst tokens = Number(raw[1]);\n\n\t\tif (allowed) {\n\t\t\treturn { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 };\n\t\t}\n\t\treturn {\n\t\t\tallowed: false,\n\t\t\tremaining: 0,\n\t\t\tretryAfterMs: Math.ceil(((cost - tokens) / rule.refillPerSec) * 1000),\n\t\t};\n\t}\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/middleware.ts","../src/bucket.ts","../src/stores/memory.ts","../src/stores/store-adapter.ts","../src/stores/redis.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\n\nimport type { IFonderieContext, Middleware } from '@fonderie/core';\nimport { HTTP, setApiResponse } from '@fonderie/core';\n\nimport type { IRateLimitRule, IRateLimitStore } from './types';\n\n// Key extractors. A limiter guards a scarce thing — name it in the key so\n// two limiters on the same route can't collide.\n//\n// Every key is hashed to a fixed-width digest before it reaches a store:\n// - bounds key size (an attacker can't blow up storage with 10KB \"emails\")\n// - keeps user identifiers (emails, IPs) OUT of the rate-limit table as\n// plaintext — no PII to leak or to forget under a deletion request\n// The `scope` prefix stays readable so operators can eyeball which limiter a\n// key belongs to; only the identifying tail is digested.\n\nexport type KeyFn = (ctx: IFonderieContext) => string | null;\n\nfunction hashed(scope: string, ...parts: string[]): string {\n\tconst h = createHash('sha256').update(parts.join('\\0')).digest('base64url');\n\treturn `${scope}:${h}`;\n}\n\n// Client IP, as resolved by the adapter into ctx.meta['clientIp'] (see\n// resolveClientIp in @fonderie/core/middlewares — trust-proxy aware). Returns\n// null when unavailable, which skips this limiter rather than collapsing every\n// request onto one shared key.\n//\n// IPv6 is keyed on the /64 prefix, not the full address: a single residential\n// IPv6 allocation is a /64 (2^64 addresses), so per-exact-address limiting is\n// trivially bypassed. IPv4 keys on the full address.\nexport function byIp(scope: string): KeyFn {\n\treturn (ctx) => {\n\t\tconst ip = ctx.meta['clientIp'];\n\t\tif (typeof ip !== 'string' || ip.length === 0) return null;\n\t\treturn hashed(`${scope}:ip`, ipv6Prefix(ip));\n\t};\n}\n\n// Collapse an IPv6 address to its /64 network prefix; pass IPv4 through.\nfunction ipv6Prefix(ip: string): string {\n\tif (!ip.includes(':')) return ip; // IPv4\n\t// Expand omitted groups enough to take the first four (the /64 network).\n\tconst [head] = ip.split('%'); // strip zone id\n\tconst groups = head!.split('::');\n\tlet left = groups[0] ? groups[0].split(':') : [];\n\tlet right = groups[1] ? groups[1].split(':') : [];\n\tif (groups.length === 2) {\n\t\tconst fill = 8 - left.length - right.length;\n\t\tleft = [...left, ...Array(Math.max(0, fill)).fill('0'), ...right];\n\t}\n\treturn left.slice(0, 4).join(':') + '::/64';\n}\n\n// A field of the request body — e.g. the login email — normalized so\n// \"Jane@x.com\" and \"jane@x.com \" share a bucket, then hashed.\n//\n// SECURITY: place this limiter AFTER validate() in the route chain so the\n// field is a bounded, well-typed string before it becomes a key. On an\n// unvalidated body a caller could submit huge or non-string values; the\n// length guard below is a backstop, not the primary control.\nexport function byBodyField(scope: string, field: string): KeyFn {\n\treturn (ctx) => {\n\t\tconst body = ctx.meta['body'] as Record<string, unknown> | undefined;\n\t\tconst v = body?.[field];\n\t\tif (typeof v !== 'string' || v.length === 0) return null;\n\t\t// Backstop cap: an oversized value can't reach the hash unbounded.\n\t\tconst normalized = v.slice(0, 320).trim().toLowerCase();\n\t\tif (normalized.length === 0) return null;\n\t\treturn hashed(`${scope}:${field}`, normalized);\n\t};\n}\n\nexport interface IRateLimitOptions {\n\tstore: IRateLimitStore;\n\trule: IRateLimitRule;\n\tkey: KeyFn;\n\t// Fail-open (default) keeps auth available when the store is down —\n\t// an outage shouldn't lock every user out. Flip to fail-closed for\n\t// endpoints where an unthrottled request is worse than a rejected one.\n\t// This is a deliberate availability-over-strictness default; see the\n\t// package README § Fail-open.\n\tfailClosed?: boolean;\n}\n\n// One or more limits guarding a route; ALL must allow. Emits the IETF\n// draft-ietf-httpapi-ratelimit-headers fields on the 429 (RateLimit-Limit /\n// -Remaining / -Reset in seconds, plus Retry-After).\nexport function rateLimit(...limits: IRateLimitOptions[]): Middleware {\n\treturn async (ctx, next) => {\n\t\tfor (const limit of limits) {\n\t\t\tconst key = limit.key(ctx);\n\t\t\tif (key === null) continue;\n\n\t\t\tlet result: Awaited<ReturnType<IRateLimitStore['consume']>>;\n\t\t\ttry {\n\t\t\t\tresult = await limit.store.consume(key, limit.rule);\n\t\t\t} catch {\n\t\t\t\tif (limit.failClosed) {\n\t\t\t\t\treturn setApiResponse(\n\t\t\t\t\t\tHTTP.TOO_MANY_REQUESTS,\n\t\t\t\t\t\t'RATE_LIMITED',\n\t\t\t\t\t\t'Rate limiter unavailable. Please try again later.',\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tcontinue; // fail-open\n\t\t\t}\n\n\t\t\tif (!result.allowed) {\n\t\t\t\tconst resetSec = Math.ceil(result.retryAfterMs / 1000);\n\t\t\t\tconst res = setApiResponse(\n\t\t\t\t\tHTTP.TOO_MANY_REQUESTS,\n\t\t\t\t\t'RATE_LIMITED',\n\t\t\t\t\t'Too many requests. Please try again later.',\n\t\t\t\t\t{ retryAfter: resetSec },\n\t\t\t\t);\n\t\t\t\tres.headers.set('RateLimit-Limit', String(limit.rule.capacity));\n\t\t\t\tres.headers.set('RateLimit-Remaining', '0');\n\t\t\t\tres.headers.set('RateLimit-Reset', String(resetSec));\n\t\t\t\tres.headers.set('Retry-After', String(resetSec));\n\t\t\t\treturn res;\n\t\t\t}\n\t\t}\n\t\treturn next();\n\t};\n}\n","import type { IConsumeResult, IRateLimitRule } from './types';\n\n// Pure token-bucket math, shared by every store: given the persisted state\n// (tokens, lastRefillMs) and the current time, refill then try to consume.\n// Stores are responsible only for applying this atomically.\n\nexport interface IBucketState {\n\ttokens: number;\n\tlastRefillMs: number;\n}\n\nexport function consumeFromBucket(\n\tstate: IBucketState | null,\n\trule: IRateLimitRule,\n\tnowMs: number,\n): { next: IBucketState; result: IConsumeResult } {\n\tconst cost = rule.cost ?? 1;\n\tconst prevTokens = state ? state.tokens : rule.capacity;\n\tconst prevRefill = state ? state.lastRefillMs : nowMs;\n\n\tconst elapsedSec = Math.max(0, nowMs - prevRefill) / 1000;\n\tconst refilled = Math.min(rule.capacity, prevTokens + elapsedSec * rule.refillPerSec);\n\n\tif (refilled >= cost) {\n\t\tconst tokens = refilled - cost;\n\t\treturn {\n\t\t\tnext: { tokens, lastRefillMs: nowMs },\n\t\t\tresult: { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 },\n\t\t};\n\t}\n\n\tconst deficit = cost - refilled;\n\tconst retryAfterMs = Math.ceil((deficit / rule.refillPerSec) * 1000);\n\treturn {\n\t\tnext: { tokens: refilled, lastRefillMs: nowMs },\n\t\tresult: { allowed: false, remaining: 0, retryAfterMs },\n\t};\n}\n\n// How long until a full (idle) bucket forgets a key entirely — used by\n// stores for expiry so old keys don't accumulate forever.\nexport function fullRefillMs(rule: IRateLimitRule): number {\n\treturn Math.ceil((rule.capacity / rule.refillPerSec) * 1000);\n}\n","import { consumeFromBucket, fullRefillMs, type IBucketState } from '../bucket';\nimport type { IConsumeResult, IRateLimitRule, IRateLimitStore } from '../types';\n\n// Single-instance store. Atomic by virtue of the single-threaded event loop —\n// consume() does no awaiting between read and write. Correct for one process;\n// use StoreAdapterStore or RedisStore when running multiple instances.\n\nexport class MemoryStore implements IRateLimitStore {\n\tprivate buckets = new Map<string, IBucketState>();\n\tprivate ops = 0;\n\tprivate readonly now: () => number;\n\n\t// Sweep lazily every N operations rather than on a timer, so the store\n\t// holds no open handle that keeps short-lived processes (tests, CLIs) alive.\n\tprivate static SWEEP_EVERY = 1024;\n\n\t// `now` is injectable so the time-based sweep is deterministically testable\n\t// (drive a fake clock instead of racing the wall clock); defaults to Date.now.\n\tconstructor(options?: { now?: () => number }) {\n\t\tthis.now = options?.now ?? (() => Date.now());\n\t}\n\n\tasync consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult> {\n\t\tconst now = this.now();\n\t\tconst { next, result } = consumeFromBucket(this.buckets.get(key) ?? null, rule, now);\n\t\tthis.buckets.set(key, next);\n\n\t\tif (++this.ops % MemoryStore.SWEEP_EVERY === 0) this.sweep(rule, now);\n\t\treturn result;\n\t}\n\n\tprivate sweep(rule: IRateLimitRule, nowMs: number): void {\n\t\tconst idleMs = fullRefillMs(rule);\n\t\tfor (const [key, state] of this.buckets) {\n\t\t\t// A bucket idle long enough to be full again is indistinguishable\n\t\t\t// from an absent one — drop it.\n\t\t\tif (nowMs - state.lastRefillMs > idleMs) this.buckets.delete(key);\n\t\t}\n\t}\n\n\t// Test/ops introspection.\n\tget size(): number {\n\t\treturn this.buckets.size;\n\t}\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IConsumeResult, IRateLimitRule, IRateLimitStore } from '../types';\n\n// Distributed store over the IStoreAdapter (PostgreSQL) every Fonderie module\n// already receives. Refill-then-consume happens in ONE upsert — the\n// ON CONFLICT UPDATE recomputes the bucket from the stored row inside the\n// row lock the statement takes, so N app instances hammering the same key\n// can never both win the last token. No transaction, no read-modify-write.\n//\n// TIME COMES FROM THE DATABASE, not the app. `clock_timestamp()` is evaluated\n// once in the VALUES clause and reused via EXCLUDED.last_refill_ms in the\n// UPDATE — so every app instance measures elapsed time against ONE\n// authoritative clock. This removes app-server clock skew from the refill\n// math entirely (making \"distributed-correct\" literally true, not\n// \"true assuming NTP\"). clock_timestamp() — not now()/transaction_timestamp()\n// — because we want real wall-clock at execution, and it MUST be captured\n// once: a second call would return a slightly later value and desync the two\n// places `now` is used.\n//\n// RETURNING only sees the post-update row, which cannot distinguish\n// \"allowed, bucket now low\" from \"denied, bucket unchanged\" — so the\n// allow/deny verdict is computed INSIDE the statement and persisted to the\n// `granted` column, then read back.\n//\n// Params: $1 key, $2 capacity, $3 cost, $4 refill_per_sec.\n// `refilled` = min(capacity, old.tokens + elapsed_sec * refill_per_sec),\n// where elapsed uses EXCLUDED.last_refill_ms (this call's DB `now`).\nconst REFILLED = `LEAST($2::double precision,\n\tfonderie_rate_limits.tokens\n\t+ GREATEST(0, EXCLUDED.last_refill_ms - fonderie_rate_limits.last_refill_ms) / 1000.0\n\t * $4::double precision)`;\n\nconst CONSUME_SQL = `\nINSERT INTO fonderie_rate_limits (key, tokens, last_refill_ms, granted)\nVALUES (\n\t$1,\n\tGREATEST(0, $2::double precision - $3::double precision),\n\t(EXTRACT(EPOCH FROM clock_timestamp()) * 1000.0),\n\t$2::double precision >= $3::double precision\n)\nON CONFLICT (key) DO UPDATE SET\n\ttokens = CASE\n\t\tWHEN ${REFILLED} >= $3::double precision THEN ${REFILLED} - $3::double precision\n\t\tELSE ${REFILLED}\n\tEND,\n\tgranted = ${REFILLED} >= $3::double precision,\n\tlast_refill_ms = EXCLUDED.last_refill_ms\nRETURNING tokens, granted\n`;\n\n// Idle rows (past a full refill) are dead weight; prune them using DB time too.\nconst CLEAN_SQL = `\nDELETE FROM fonderie_rate_limits\nWHERE last_refill_ms < (EXTRACT(EPOCH FROM clock_timestamp()) * 1000.0) - $1\n`;\n\nexport class StoreAdapterStore implements IRateLimitStore {\n\tprivate ops = 0;\n\tprivate static CLEAN_EVERY = 512;\n\n\tconstructor(private store: IStoreAdapter) {}\n\n\tasync consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult> {\n\t\tconst cost = rule.cost ?? 1;\n\n\t\tconst rows = await this.store.query<{ tokens: number | string; granted: boolean }>(\n\t\t\tCONSUME_SQL,\n\t\t\t[key, rule.capacity, cost, rule.refillPerSec],\n\t\t);\n\t\tconst row = rows[0];\n\t\tif (!row) throw new Error('[rate-limit] consume returned no row');\n\n\t\tconst tokens = Number(row.tokens);\n\n\t\tif (++this.ops % StoreAdapterStore.CLEAN_EVERY === 0) {\n\t\t\tconst idleMs = Math.ceil((rule.capacity / rule.refillPerSec) * 1000);\n\t\t\tthis.store.query(CLEAN_SQL, [idleMs]).catch(() => {});\n\t\t}\n\n\t\tif (row.granted) {\n\t\t\treturn { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 };\n\t\t}\n\t\treturn {\n\t\t\tallowed: false,\n\t\t\tremaining: 0,\n\t\t\tretryAfterMs: Math.ceil(((cost - tokens) / rule.refillPerSec) * 1000),\n\t\t};\n\t}\n}\n","import type { IConsumeResult, IRateLimitRule, IRateLimitStore, IRedisEvalClient } from '../types';\n\n// High-throughput distributed store. Accepts any client exposing eval()\n// (ioredis and node-redis both do) — this package depends on no Redis\n// library. Refill-then-consume runs as one Lua script: Redis executes\n// scripts atomically, so cross-instance races are impossible by\n// construction. PEXPIRE gives free key expiry at full-refill time.\n//\n// TIME COMES FROM REDIS, not the app. `redis.call('TIME')` returns\n// [seconds, microseconds] from the Redis server clock, so every app instance\n// measures elapsed time against ONE authoritative clock — app-server skew\n// can't affect the refill math. (Effects-replication, default since Redis 5,\n// permits a non-deterministic read before writes; we target Redis 7.)\nconst CONSUME_LUA = `\nlocal key = KEYS[1]\nlocal capacity = tonumber(ARGV[1])\nlocal cost = tonumber(ARGV[2])\nlocal refill_per_sec = tonumber(ARGV[3])\nlocal ttl_ms = tonumber(ARGV[4])\n\nlocal t = redis.call('TIME')\nlocal now_ms = (tonumber(t[1]) * 1000) + (tonumber(t[2]) / 1000)\n\nlocal state = redis.call('HMGET', key, 'tokens', 'last_refill_ms')\nlocal tokens = tonumber(state[1])\nlocal last_refill = tonumber(state[2])\n\nif tokens == nil then\n tokens = capacity\n last_refill = now_ms\nend\n\nlocal elapsed_sec = math.max(0, now_ms - last_refill) / 1000\nlocal refilled = math.min(capacity, tokens + elapsed_sec * refill_per_sec)\n\nlocal allowed = 0\nlocal new_tokens = refilled\nif refilled >= cost then\n allowed = 1\n new_tokens = refilled - cost\nend\n\nredis.call('HSET', key, 'tokens', new_tokens, 'last_refill_ms', now_ms)\nredis.call('PEXPIRE', key, ttl_ms)\n\nreturn { allowed, tostring(new_tokens) }\n`;\n\nexport class RedisStore implements IRateLimitStore {\n\tconstructor(\n\t\tprivate client: IRedisEvalClient,\n\t\tprivate keyPrefix = 'fonderie:rl:',\n\t) {}\n\n\tasync consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult> {\n\t\tconst cost = rule.cost ?? 1;\n\t\tconst ttlMs = Math.ceil((rule.capacity / rule.refillPerSec) * 1000);\n\n\t\tconst raw = (await this.client.eval(\n\t\t\tCONSUME_LUA,\n\t\t\t1,\n\t\t\tthis.keyPrefix + key,\n\t\t\trule.capacity,\n\t\t\tcost,\n\t\t\trule.refillPerSec,\n\t\t\tttlMs,\n\t\t)) as [number, string];\n\n\t\tconst allowed = raw[0] === 1;\n\t\tconst tokens = Number(raw[1]);\n\n\t\tif (allowed) {\n\t\t\treturn { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 };\n\t\t}\n\t\treturn {\n\t\t\tallowed: false,\n\t\t\tremaining: 0,\n\t\t\tretryAfterMs: Math.ceil(((cost - tokens) / rule.refillPerSec) * 1000),\n\t\t};\n\t}\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonderie/rate-limit",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.4",
|
|
4
4
|
"description": "Distributed rate limiting for @fonderiejs — atomic token bucket over memory, PostgreSQL, or Redis, with standard RateLimit-* headers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"fonderiejs",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"check": "biome check --write src"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@fonderie/core": "^0.
|
|
42
|
+
"@fonderie/core": "^0.8.0",
|
|
43
43
|
"@fonderie/store": "^0.2.0"
|
|
44
44
|
},
|
|
45
45
|
"peerDependenciesMeta": {
|