@voyant-travel/hono 0.109.1
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/LICENSE +201 -0
- package/README.md +58 -0
- package/dist/app-workflows.d.ts +31 -0
- package/dist/app-workflows.d.ts.map +1 -0
- package/dist/app-workflows.js +110 -0
- package/dist/app.d.ts +45 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +403 -0
- package/dist/auth/crypto.d.ts +16 -0
- package/dist/auth/crypto.d.ts.map +1 -0
- package/dist/auth/crypto.js +66 -0
- package/dist/auth/index.d.ts +5 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +3 -0
- package/dist/auth/require-user.d.ts +3 -0
- package/dist/auth/require-user.d.ts.map +1 -0
- package/dist/auth/require-user.js +8 -0
- package/dist/auth/session-jwt.d.ts +7 -0
- package/dist/auth/session-jwt.d.ts.map +1 -0
- package/dist/auth/session-jwt.js +23 -0
- package/dist/composition.d.ts +67 -0
- package/dist/composition.d.ts.map +1 -0
- package/dist/composition.js +46 -0
- package/dist/document-download.d.ts +30 -0
- package/dist/document-download.d.ts.map +1 -0
- package/dist/document-download.js +102 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/lib/db-selector.d.ts +24 -0
- package/dist/lib/db-selector.d.ts.map +1 -0
- package/dist/lib/db-selector.js +28 -0
- package/dist/lib/execution-ctx.d.ts +16 -0
- package/dist/lib/execution-ctx.d.ts.map +1 -0
- package/dist/lib/execution-ctx.js +16 -0
- package/dist/lib/public-paths.d.ts +19 -0
- package/dist/lib/public-paths.d.ts.map +1 -0
- package/dist/lib/public-paths.js +27 -0
- package/dist/lib/request-event-bus.d.ts +21 -0
- package/dist/lib/request-event-bus.d.ts.map +1 -0
- package/dist/lib/request-event-bus.js +43 -0
- package/dist/middleware/auth.d.ts +10 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +280 -0
- package/dist/middleware/body-size.d.ts +7 -0
- package/dist/middleware/body-size.d.ts.map +1 -0
- package/dist/middleware/body-size.js +20 -0
- package/dist/middleware/cors.d.ts +6 -0
- package/dist/middleware/cors.d.ts.map +1 -0
- package/dist/middleware/cors.js +94 -0
- package/dist/middleware/db.d.ts +43 -0
- package/dist/middleware/db.d.ts.map +1 -0
- package/dist/middleware/db.js +78 -0
- package/dist/middleware/error-boundary.d.ts +5 -0
- package/dist/middleware/error-boundary.d.ts.map +1 -0
- package/dist/middleware/error-boundary.js +76 -0
- package/dist/middleware/idempotency-key.d.ts +97 -0
- package/dist/middleware/idempotency-key.d.ts.map +1 -0
- package/dist/middleware/idempotency-key.js +235 -0
- package/dist/middleware/index.d.ts +14 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +13 -0
- package/dist/middleware/logger.d.ts +5 -0
- package/dist/middleware/logger.d.ts.map +1 -0
- package/dist/middleware/logger.js +27 -0
- package/dist/middleware/metrics.d.ts +55 -0
- package/dist/middleware/metrics.d.ts.map +1 -0
- package/dist/middleware/metrics.js +94 -0
- package/dist/middleware/public-cache.d.ts +44 -0
- package/dist/middleware/public-cache.d.ts.map +1 -0
- package/dist/middleware/public-cache.js +205 -0
- package/dist/middleware/rate-limit.d.ts +214 -0
- package/dist/middleware/rate-limit.d.ts.map +1 -0
- package/dist/middleware/rate-limit.js +240 -0
- package/dist/middleware/request-db.d.ts +42 -0
- package/dist/middleware/request-db.d.ts.map +1 -0
- package/dist/middleware/request-db.js +62 -0
- package/dist/middleware/require-actor.d.ts +28 -0
- package/dist/middleware/require-actor.d.ts.map +1 -0
- package/dist/middleware/require-actor.js +89 -0
- package/dist/middleware/require-permission.d.ts +9 -0
- package/dist/middleware/require-permission.d.ts.map +1 -0
- package/dist/middleware/require-permission.js +62 -0
- package/dist/middleware/security-headers.d.ts +10 -0
- package/dist/middleware/security-headers.d.ts.map +1 -0
- package/dist/middleware/security-headers.js +19 -0
- package/dist/module.d.ts +41 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +1 -0
- package/dist/plugin.d.ts +66 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +37 -0
- package/dist/public-capability.d.ts +46 -0
- package/dist/public-capability.d.ts.map +1 -0
- package/dist/public-capability.js +140 -0
- package/dist/public-document-delivery.d.ts +111 -0
- package/dist/public-document-delivery.d.ts.map +1 -0
- package/dist/public-document-delivery.js +234 -0
- package/dist/types.d.ts +318 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +29 -0
- package/dist/validation.d.ts +36 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +106 -0
- package/package.json +156 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { tryGetExecutionCtx } from "../lib/execution-ctx.js";
|
|
2
|
+
const DEFAULT_PREFIXES = ["/v1/public/"];
|
|
3
|
+
const DEFAULT_MAX_KV_BODY_BYTES = 2 * 1024 * 1024;
|
|
4
|
+
const KV_KEY_PREFIX = "respcache:v1:";
|
|
5
|
+
/** Cloudflare KV rejects expirationTtl below 60 seconds. */
|
|
6
|
+
const KV_MIN_TTL_SECONDS = 60;
|
|
7
|
+
/**
|
|
8
|
+
* Headers never persisted into the shared cache: per-request identifiers
|
|
9
|
+
* and CORS grants are recomputed for every requester (the cors middleware
|
|
10
|
+
* runs upstream and decorates cache hits like any other response).
|
|
11
|
+
*/
|
|
12
|
+
function isUncacheableHeader(name) {
|
|
13
|
+
const lower = name.toLowerCase();
|
|
14
|
+
return lower === "set-cookie" || lower === "x-request-id" || lower.startsWith("access-control-");
|
|
15
|
+
}
|
|
16
|
+
function parseCacheControl(value) {
|
|
17
|
+
if (!value)
|
|
18
|
+
return { isPublic: false, sMaxage: null };
|
|
19
|
+
let isPublic = false;
|
|
20
|
+
let sMaxage = null;
|
|
21
|
+
for (const part of value.split(",")) {
|
|
22
|
+
const directive = part.trim().toLowerCase();
|
|
23
|
+
if (directive === "public")
|
|
24
|
+
isPublic = true;
|
|
25
|
+
else if (directive === "private" || directive === "no-store") {
|
|
26
|
+
return { isPublic: false, sMaxage: null };
|
|
27
|
+
}
|
|
28
|
+
else if (directive.startsWith("s-maxage=")) {
|
|
29
|
+
const parsed = Number.parseInt(directive.slice("s-maxage=".length), 10);
|
|
30
|
+
if (Number.isFinite(parsed) && parsed > 0)
|
|
31
|
+
sMaxage = parsed;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { isPublic, sMaxage };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* `caches.default` is available on regular Cloudflare Workers but
|
|
38
|
+
* DISABLED inside Workers-for-Platforms namespaced scripts (access or
|
|
39
|
+
* use throws). Probe once, and demote to "unavailable" on any runtime
|
|
40
|
+
* failure so namespaced deployments settle on the KV fallback after a
|
|
41
|
+
* single failed attempt.
|
|
42
|
+
*/
|
|
43
|
+
let cacheApiState = "unknown";
|
|
44
|
+
function getCacheApi() {
|
|
45
|
+
if (cacheApiState === "unavailable")
|
|
46
|
+
return undefined;
|
|
47
|
+
try {
|
|
48
|
+
const candidate = globalThis.caches?.default;
|
|
49
|
+
if (candidate && typeof candidate.match === "function") {
|
|
50
|
+
cacheApiState = "available";
|
|
51
|
+
return candidate;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// fall through to unavailable
|
|
56
|
+
}
|
|
57
|
+
cacheApiState = "unavailable";
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
function markCacheApiUnavailable() {
|
|
61
|
+
cacheApiState = "unavailable";
|
|
62
|
+
}
|
|
63
|
+
/** Test hook — resets the memoized Cache API probe state. */
|
|
64
|
+
export function resetPublicCacheStateForTests() {
|
|
65
|
+
cacheApiState = "unknown";
|
|
66
|
+
}
|
|
67
|
+
function sanitizedResponseCopy(res) {
|
|
68
|
+
const headers = new Headers();
|
|
69
|
+
res.headers.forEach((value, name) => {
|
|
70
|
+
if (!isUncacheableHeader(name))
|
|
71
|
+
headers.set(name, value);
|
|
72
|
+
});
|
|
73
|
+
headers.set("x-voyant-cache", "hit");
|
|
74
|
+
return new Response(res.body, { status: res.status, headers });
|
|
75
|
+
}
|
|
76
|
+
function kvKeyFor(url) {
|
|
77
|
+
return `${KV_KEY_PREFIX}${url}`;
|
|
78
|
+
}
|
|
79
|
+
async function kvMatch(kv, url) {
|
|
80
|
+
try {
|
|
81
|
+
const entry = await kv.get(kvKeyFor(url), { type: "json" });
|
|
82
|
+
if (!entry || typeof entry.body !== "string")
|
|
83
|
+
return undefined;
|
|
84
|
+
const headers = new Headers(entry.headers);
|
|
85
|
+
headers.set("x-voyant-cache", "hit");
|
|
86
|
+
return new Response(entry.body, { status: entry.status, headers });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function kvStore(kv, url, res, ttlSeconds, maxBodyBytes) {
|
|
93
|
+
try {
|
|
94
|
+
const body = await res.text();
|
|
95
|
+
if (body.length > maxBodyBytes)
|
|
96
|
+
return;
|
|
97
|
+
const headers = [];
|
|
98
|
+
res.headers.forEach((value, name) => {
|
|
99
|
+
if (!isUncacheableHeader(name))
|
|
100
|
+
headers.push([name, value]);
|
|
101
|
+
});
|
|
102
|
+
const entry = { status: res.status, headers, body };
|
|
103
|
+
await kv.put(kvKeyFor(url), JSON.stringify(entry), {
|
|
104
|
+
expirationTtl: Math.max(KV_MIN_TTL_SECONDS, ttlSeconds),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// cache writes are best-effort — never surface to the request
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Shared response cache for the public API surface.
|
|
113
|
+
*
|
|
114
|
+
* Fail-closed by design: a response is only ever cached when the route
|
|
115
|
+
* explicitly marked it shareable — `Cache-Control` containing `public`
|
|
116
|
+
* AND a positive `s-maxage` — and it carries no `Set-Cookie`. Routes
|
|
117
|
+
* emit `private`/`no-store` (or nothing) to opt out, so personalized
|
|
118
|
+
* endpoints under `/v1/public/*` (customer portal, verification) are
|
|
119
|
+
* never cached by accident.
|
|
120
|
+
*
|
|
121
|
+
* Cache hits are served before auth, the DB middleware, and the runtime
|
|
122
|
+
* bootstrap — a hit costs no Postgres connection, no session lookup,
|
|
123
|
+
* and no module-graph instantiation, which is the entire point under
|
|
124
|
+
* storefront load (#1686).
|
|
125
|
+
*
|
|
126
|
+
* Backend selection: Cache API (`caches.default`) where the runtime
|
|
127
|
+
* provides it; otherwise the `env.CACHE` KV binding when present;
|
|
128
|
+
* otherwise the middleware is a transparent no-op.
|
|
129
|
+
*/
|
|
130
|
+
export function publicResponseCache(options = {}) {
|
|
131
|
+
const prefixes = options.pathPrefixes ?? DEFAULT_PREFIXES;
|
|
132
|
+
const maxKvBodyBytes = options.maxKvBodyBytes ?? DEFAULT_MAX_KV_BODY_BYTES;
|
|
133
|
+
return async (c, next) => {
|
|
134
|
+
if (c.req.method !== "GET")
|
|
135
|
+
return next();
|
|
136
|
+
const path = c.req.path;
|
|
137
|
+
if (!prefixes.some((prefix) => path.startsWith(prefix)))
|
|
138
|
+
return next();
|
|
139
|
+
// Standard escape hatch: a requester (or a debugging operator) can
|
|
140
|
+
// force revalidation with `Cache-Control: no-cache`.
|
|
141
|
+
const requestDirective = c.req.header("cache-control")?.toLowerCase() ?? "";
|
|
142
|
+
const bypass = requestDirective.includes("no-cache") || requestDirective.includes("no-store");
|
|
143
|
+
const url = c.req.url;
|
|
144
|
+
const cacheApi = getCacheApi();
|
|
145
|
+
const kv = !cacheApi ? c.env.CACHE : undefined;
|
|
146
|
+
if (!bypass) {
|
|
147
|
+
if (cacheApi) {
|
|
148
|
+
try {
|
|
149
|
+
const hit = await cacheApi.match(url);
|
|
150
|
+
if (hit)
|
|
151
|
+
return sanitizedResponseCopy(hit);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
markCacheApiUnavailable();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else if (kv) {
|
|
158
|
+
const hit = await kvMatch(kv, url);
|
|
159
|
+
if (hit)
|
|
160
|
+
return hit;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
await next();
|
|
164
|
+
const res = c.res;
|
|
165
|
+
if (!res || res.status !== 200)
|
|
166
|
+
return;
|
|
167
|
+
if (res.headers.has("set-cookie"))
|
|
168
|
+
return;
|
|
169
|
+
const { isPublic, sMaxage } = parseCacheControl(res.headers.get("cache-control"));
|
|
170
|
+
if (!isPublic || !sMaxage)
|
|
171
|
+
return;
|
|
172
|
+
const backendCacheApi = getCacheApi();
|
|
173
|
+
const backendKv = !backendCacheApi ? c.env.CACHE : undefined;
|
|
174
|
+
if (!backendCacheApi && !backendKv)
|
|
175
|
+
return;
|
|
176
|
+
const copy = res.clone();
|
|
177
|
+
const store = (async () => {
|
|
178
|
+
if (backendCacheApi) {
|
|
179
|
+
try {
|
|
180
|
+
const headers = new Headers();
|
|
181
|
+
copy.headers.forEach((value, name) => {
|
|
182
|
+
if (!isUncacheableHeader(name))
|
|
183
|
+
headers.set(name, value);
|
|
184
|
+
});
|
|
185
|
+
await backendCacheApi.put(url, new Response(copy.body, { status: copy.status, headers }));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
markCacheApiUnavailable();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const fallbackKv = backendKv ?? c.env.CACHE;
|
|
193
|
+
if (fallbackKv) {
|
|
194
|
+
await kvStore(fallbackKv, url, copy, sMaxage, maxKvBodyBytes);
|
|
195
|
+
}
|
|
196
|
+
})();
|
|
197
|
+
const executionCtx = tryGetExecutionCtx(c);
|
|
198
|
+
if (executionCtx) {
|
|
199
|
+
executionCtx.waitUntil(store);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
await store;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { KVStore } from "@voyant-travel/utils/cache";
|
|
2
|
+
import type { MiddlewareHandler } from "hono";
|
|
3
|
+
/**
|
|
4
|
+
* Distributed-capable rate limiting (security finding C2).
|
|
5
|
+
*
|
|
6
|
+
* The limiter is split into two halves:
|
|
7
|
+
*
|
|
8
|
+
* - a {@link RateLimitStore} — the counting backend. Three implementations
|
|
9
|
+
* ship with the framework: the Cloudflare native Rate Limiting binding
|
|
10
|
+
* (truly distributed, limits configured in wrangler), a KV-backed
|
|
11
|
+
* fixed-window counter (best-effort — see {@link createKvRateLimitStore}),
|
|
12
|
+
* and an in-memory Map for Node/dev/tests (per-isolate).
|
|
13
|
+
* - the enforcement surface — the {@link rateLimit} Hono middleware and the
|
|
14
|
+
* imperative {@link enforceRateLimit} helper that route packages
|
|
15
|
+
* (storefront, bookings, …) call directly inside handlers.
|
|
16
|
+
*
|
|
17
|
+
* Keys always carry a client dimension: `lim:<bucket>:<clientKey>` where
|
|
18
|
+
* `clientKey` is derived by {@link clientIpKey} (`cf-connecting-ip`, then
|
|
19
|
+
* the first hop of `x-forwarded-for`, else `"anon"`). Window-keying is the
|
|
20
|
+
* store's responsibility — the KV backend appends `:<windowKey>` so its
|
|
21
|
+
* stored keys read `lim:<bucket>:<clientKey>:<windowKey>`; the CF binding
|
|
22
|
+
* tracks its own configured period per key; the memory store keeps a
|
|
23
|
+
* `resetAt` per key.
|
|
24
|
+
*
|
|
25
|
+
* `createApp` mounts default policies (tight on `/auth/*` POSTs, moderate
|
|
26
|
+
* on unauthenticated public writes) — see `config.rateLimit` in types.ts.
|
|
27
|
+
*/
|
|
28
|
+
/** Result of a single limit check against a {@link RateLimitStore}. */
|
|
29
|
+
export interface RateLimitResult {
|
|
30
|
+
allowed: boolean;
|
|
31
|
+
/** Requests left in the current window, when the backend can tell. */
|
|
32
|
+
remaining?: number;
|
|
33
|
+
/** Seconds until the caller should retry, when the backend can tell. */
|
|
34
|
+
retryAfterSeconds?: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A counting backend for the limiter. `limit()` records one hit for `key`
|
|
38
|
+
* and reports whether the caller is still within `max` per `windowSeconds`.
|
|
39
|
+
*/
|
|
40
|
+
export interface RateLimitStore {
|
|
41
|
+
limit(key: string, opts: {
|
|
42
|
+
max: number;
|
|
43
|
+
windowSeconds: number;
|
|
44
|
+
}): Promise<RateLimitResult>;
|
|
45
|
+
}
|
|
46
|
+
/** A named limit applied to one logical traffic class. */
|
|
47
|
+
export interface RateLimitPolicy {
|
|
48
|
+
/**
|
|
49
|
+
* Namespace for the counter — requests in different buckets never share
|
|
50
|
+
* a window (e.g. `"auth"`, `"public-write"`, `"booking-lookup"`).
|
|
51
|
+
*/
|
|
52
|
+
bucket: string;
|
|
53
|
+
/** Maximum requests per window per client. */
|
|
54
|
+
max: number;
|
|
55
|
+
/** Window length in seconds. */
|
|
56
|
+
windowSeconds: number;
|
|
57
|
+
/**
|
|
58
|
+
* Explicit backend. When omitted, {@link enforceRateLimit} resolves one
|
|
59
|
+
* from the environment via {@link resolveRateLimitStore}.
|
|
60
|
+
*/
|
|
61
|
+
store?: RateLimitStore;
|
|
62
|
+
/** Override the client dimension (defaults to {@link clientIpKey}). */
|
|
63
|
+
clientKey?: (c: RateLimitRequestContext) => string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* `createApp({ rateLimit })` configuration. Defaults (when the key is
|
|
67
|
+
* omitted entirely) are: `auth` = 10 POSTs/min/IP on `/auth/*`,
|
|
68
|
+
* `publicWrite` = 60 writes/min/IP on `/v1/public/*` + `publicPaths`.
|
|
69
|
+
* Set the whole config to `false` to disable, or set an individual
|
|
70
|
+
* policy to `false` to disable just that policy.
|
|
71
|
+
*/
|
|
72
|
+
export interface RateLimitConfig {
|
|
73
|
+
/**
|
|
74
|
+
* Explicit store, or a function-of-bindings for stores built from env
|
|
75
|
+
* bindings (Workers env is only available per request). When omitted —
|
|
76
|
+
* or when the function returns `undefined` — resolution falls through
|
|
77
|
+
* to `c.env.RATE_LIMITER` (CF binding) → `c.env.RATE_LIMIT` (KV) →
|
|
78
|
+
* in-memory.
|
|
79
|
+
*/
|
|
80
|
+
store?: RateLimitStore | ((env: unknown) => RateLimitStore | undefined);
|
|
81
|
+
/** POSTs to `/auth/*`. Default `{ max: 10, windowSeconds: 60 }`. */
|
|
82
|
+
auth?: false | RateLimitRule;
|
|
83
|
+
/**
|
|
84
|
+
* Writes (POST/PUT/PATCH/DELETE) to `/v1/public/*` and to
|
|
85
|
+
* `config.publicPaths`. Default `{ max: 60, windowSeconds: 60 }`.
|
|
86
|
+
*/
|
|
87
|
+
publicWrite?: false | RateLimitRule;
|
|
88
|
+
}
|
|
89
|
+
/** A bare max-per-window pair for the built-in `createApp` policies. */
|
|
90
|
+
export interface RateLimitRule {
|
|
91
|
+
max: number;
|
|
92
|
+
windowSeconds: number;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The Cloudflare native Rate Limiting binding shape (wrangler
|
|
96
|
+
* `[[ratelimits]]`). The binding's `limit`/`period` are fixed in wrangler
|
|
97
|
+
* config — the policy's `max`/`windowSeconds` are advisory for headers
|
|
98
|
+
* only. One shared binding works across policies because the bucket is
|
|
99
|
+
* part of the key; deployments wanting different limits per policy bind
|
|
100
|
+
* one ratelimiter per policy and pass it via `policy.store`.
|
|
101
|
+
*/
|
|
102
|
+
export interface CloudflareRateLimiterBinding {
|
|
103
|
+
limit(opts: {
|
|
104
|
+
key: string;
|
|
105
|
+
}): Promise<{
|
|
106
|
+
success: boolean;
|
|
107
|
+
}>;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Minimal structural view of a Hono context — enough for key derivation,
|
|
111
|
+
* store resolution, and response headers — so route packages can call
|
|
112
|
+
* {@link enforceRateLimit} without generics gymnastics.
|
|
113
|
+
*/
|
|
114
|
+
export interface RateLimitRequestContext {
|
|
115
|
+
req: {
|
|
116
|
+
method: string;
|
|
117
|
+
url: string;
|
|
118
|
+
header(name: string): string | undefined;
|
|
119
|
+
};
|
|
120
|
+
env: unknown;
|
|
121
|
+
header(name: string, value: string): void;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Derive the client dimension for rate-limit keys: `cf-connecting-ip`
|
|
125
|
+
* (set by Cloudflare, not spoofable through the edge), else the first
|
|
126
|
+
* hop of `x-forwarded-for`, else `"anon"`. Exported so route packages
|
|
127
|
+
* key their own limiters and idempotency scopes consistently.
|
|
128
|
+
*/
|
|
129
|
+
export declare function clientIpKey(c: {
|
|
130
|
+
req: {
|
|
131
|
+
header(name: string): string | undefined;
|
|
132
|
+
};
|
|
133
|
+
}): string;
|
|
134
|
+
/**
|
|
135
|
+
* In-memory fixed-window store for Node, dev, and tests. Per-isolate —
|
|
136
|
+
* on Workers every isolate counts independently, so treat it as a last
|
|
137
|
+
* resort (still vastly better than nothing: an abusive client hammering
|
|
138
|
+
* one isolate is throttled by that isolate). Expired windows are pruned
|
|
139
|
+
* periodically on access; a hard `maxEntries` cap bounds memory.
|
|
140
|
+
*/
|
|
141
|
+
export declare function createMemoryRateLimitStore(options?: {
|
|
142
|
+
maxEntries?: number;
|
|
143
|
+
}): RateLimitStore;
|
|
144
|
+
/**
|
|
145
|
+
* KV-backed fixed-window store.
|
|
146
|
+
*
|
|
147
|
+
* **Best-effort by construction**: KV is eventually consistent and the
|
|
148
|
+
* counter is a non-atomic read-modify-write, so concurrent requests
|
|
149
|
+
* across PoPs undercount — a determined attacker gets somewhat more than
|
|
150
|
+
* `max` through before the window converges. With per-client keys this
|
|
151
|
+
* is still a real brake on brute force and write floods; deployments
|
|
152
|
+
* needing exact distributed limits should bind the Cloudflare Rate
|
|
153
|
+
* Limiting binding (`RATE_LIMITER`) instead.
|
|
154
|
+
*
|
|
155
|
+
* Stored keys are `lim:<bucket>:<clientKey>:<windowKey>` (the window
|
|
156
|
+
* suffix is appended here) with a TTL of `max(60, windowSeconds * 2)` —
|
|
157
|
+
* KV's TTL floor is 60s.
|
|
158
|
+
*/
|
|
159
|
+
export declare function createKvRateLimitStore(kv: KVStore): RateLimitStore;
|
|
160
|
+
/**
|
|
161
|
+
* Adapter for the Cloudflare native Rate Limiting binding. Truly
|
|
162
|
+
* distributed and atomic; the actual limit/period are fixed in wrangler
|
|
163
|
+
* config, so the policy's `max`/`windowSeconds` only inform the
|
|
164
|
+
* `Retry-After` hint. `remaining` is never reported (the binding does
|
|
165
|
+
* not expose it).
|
|
166
|
+
*/
|
|
167
|
+
export declare function createCloudflareRateLimitStore(binding: CloudflareRateLimiterBinding): RateLimitStore;
|
|
168
|
+
/** Test-only: re-arm the once-per-isolate missing-store warning. */
|
|
169
|
+
export declare function resetRateLimitWarningsForTests(): void;
|
|
170
|
+
/**
|
|
171
|
+
* Resolve the best available store from the environment:
|
|
172
|
+
* `c.env.RATE_LIMITER` (CF Rate Limiting binding) → `c.env.RATE_LIMIT`
|
|
173
|
+
* (KV) → in-memory fallback. **Fails open into the memory store** —
|
|
174
|
+
* rate limiting must never break Node/headless deployments that bind
|
|
175
|
+
* neither — but warns once per isolate outside dev/test so a
|
|
176
|
+
* production deploy without a distributed backend is visible in logs.
|
|
177
|
+
*/
|
|
178
|
+
export declare function resolveRateLimitStore(c: {
|
|
179
|
+
env: unknown;
|
|
180
|
+
}, memoryFallback?: RateLimitStore): RateLimitStore;
|
|
181
|
+
/**
|
|
182
|
+
* Imperative limit check for route handlers. Records one hit for the
|
|
183
|
+
* calling client against `policy` and returns `null` when allowed or a
|
|
184
|
+
* ready-to-return `429` Response (with `Retry-After` and the
|
|
185
|
+
* `X-RateLimit-*` headers the backend can populate) when over the limit.
|
|
186
|
+
*
|
|
187
|
+
* Fails open when the store itself errors — a broken KV namespace must
|
|
188
|
+
* not take the API down.
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* const limited = await enforceRateLimit(c, {
|
|
192
|
+
* bucket: "booking-lookup",
|
|
193
|
+
* max: 20,
|
|
194
|
+
* windowSeconds: 60,
|
|
195
|
+
* })
|
|
196
|
+
* if (limited) return limited
|
|
197
|
+
*/
|
|
198
|
+
export declare function enforceRateLimit(c: RateLimitRequestContext, policy: RateLimitPolicy): Promise<Response | null>;
|
|
199
|
+
/**
|
|
200
|
+
* Hono middleware form of {@link enforceRateLimit}. Mount on a route or
|
|
201
|
+
* group:
|
|
202
|
+
*
|
|
203
|
+
* app.post("/v1/public/leads", rateLimit({ bucket: "leads", max: 30, windowSeconds: 60 }), handler)
|
|
204
|
+
*/
|
|
205
|
+
export declare function rateLimit(policy: RateLimitPolicy): MiddlewareHandler;
|
|
206
|
+
/**
|
|
207
|
+
* @deprecated Legacy constants from the pre-C2 limiter, retained for
|
|
208
|
+
* import compatibility. Configure limits per policy instead.
|
|
209
|
+
*/
|
|
210
|
+
export declare const LIVE_LIMITS: {
|
|
211
|
+
readonly burst: 30;
|
|
212
|
+
readonly rpm: 3000;
|
|
213
|
+
};
|
|
214
|
+
//# sourceMappingURL=rate-limit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../../src/middleware/rate-limit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,4BAA4B,CAAA;AACzD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAA;AAE7C;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,uEAAuE;AACvE,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,OAAO,CAAA;IAChB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,wEAAwE;IACxE,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,eAAe,CAAC,CAAA;CAC3F;AAED,0DAA0D;AAC1D,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,MAAM,EAAE,MAAM,CAAA;IACd,8CAA8C;IAC9C,GAAG,EAAE,MAAM,CAAA;IACX,gCAAgC;IAChC,aAAa,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,KAAK,CAAC,EAAE,cAAc,CAAA;IACtB,uEAAuE;IACvE,SAAS,CAAC,EAAE,CAAC,CAAC,EAAE,uBAAuB,KAAK,MAAM,CAAA;CACnD;AAED;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,cAAc,GAAG,CAAC,CAAC,GAAG,EAAE,OAAO,KAAK,cAAc,GAAG,SAAS,CAAC,CAAA;IACvE,oEAAoE;IACpE,IAAI,CAAC,EAAE,KAAK,GAAG,aAAa,CAAA;IAC5B;;;OAGG;IACH,WAAW,CAAC,EAAE,KAAK,GAAG,aAAa,CAAA;CACpC;AAED,wEAAwE;AACxE,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAA;IACX,aAAa,EAAE,MAAM,CAAA;CACtB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,4BAA4B;IAC3C,KAAK,CAAC,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAA;CAC5D;AAED;;;;GAIG;AACH,MAAM,WAAW,uBAAuB;IACtC,GAAG,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;KAAE,CAAA;IAC9E,GAAG,EAAE,OAAO,CAAA;IACZ,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAC1C;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE;IAAE,GAAG,EAAE;QAAE,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;KAAE,CAAA;CAAE,GAAG,MAAM,CAM5F;AAID;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,CAAC,EAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,cAAc,CAyC5F;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,sBAAsB,CAAC,EAAE,EAAE,OAAO,GAAG,cAAc,CAmBlE;AAED;;;;;;GAMG;AACH,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,4BAA4B,GACpC,cAAc,CAShB;AAeD,oEAAoE;AACpE,wBAAgB,8BAA8B,IAAI,IAAI,CAErD;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,CAAC,EAAE;IAAE,GAAG,EAAE,OAAO,CAAA;CAAE,EACnB,cAAc,GAAE,cAAkC,GACjD,cAAc,CAoChB;AAID;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,gBAAgB,CACpC,CAAC,EAAE,uBAAuB,EAC1B,MAAM,EAAE,eAAe,GACtB,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CA+B1B;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,iBAAiB,CAOpE;AAED;;;GAGG;AACH,eAAO,MAAM,WAAW;;;CAGd,CAAA"}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive the client dimension for rate-limit keys: `cf-connecting-ip`
|
|
3
|
+
* (set by Cloudflare, not spoofable through the edge), else the first
|
|
4
|
+
* hop of `x-forwarded-for`, else `"anon"`. Exported so route packages
|
|
5
|
+
* key their own limiters and idempotency scopes consistently.
|
|
6
|
+
*/
|
|
7
|
+
export function clientIpKey(c) {
|
|
8
|
+
const cfIp = c.req.header("cf-connecting-ip")?.trim();
|
|
9
|
+
if (cfIp)
|
|
10
|
+
return cfIp;
|
|
11
|
+
const firstHop = c.req.header("x-forwarded-for")?.split(",")[0]?.trim();
|
|
12
|
+
if (firstHop)
|
|
13
|
+
return firstHop;
|
|
14
|
+
return "anon";
|
|
15
|
+
}
|
|
16
|
+
// ---- Stores ----
|
|
17
|
+
/**
|
|
18
|
+
* In-memory fixed-window store for Node, dev, and tests. Per-isolate —
|
|
19
|
+
* on Workers every isolate counts independently, so treat it as a last
|
|
20
|
+
* resort (still vastly better than nothing: an abusive client hammering
|
|
21
|
+
* one isolate is throttled by that isolate). Expired windows are pruned
|
|
22
|
+
* periodically on access; a hard `maxEntries` cap bounds memory.
|
|
23
|
+
*/
|
|
24
|
+
export function createMemoryRateLimitStore(options) {
|
|
25
|
+
const windows = new Map();
|
|
26
|
+
const maxEntries = options?.maxEntries ?? 10_000;
|
|
27
|
+
let lastPruneMs = 0;
|
|
28
|
+
function prune(nowMs) {
|
|
29
|
+
if (nowMs - lastPruneMs < 30_000 && windows.size <= maxEntries)
|
|
30
|
+
return;
|
|
31
|
+
lastPruneMs = nowMs;
|
|
32
|
+
for (const [key, win] of windows) {
|
|
33
|
+
if (win.resetAtMs <= nowMs)
|
|
34
|
+
windows.delete(key);
|
|
35
|
+
}
|
|
36
|
+
if (windows.size > maxEntries) {
|
|
37
|
+
// Still over after dropping expired windows — evict in insertion
|
|
38
|
+
// order (oldest windows first) to stay bounded.
|
|
39
|
+
const overflow = windows.size - maxEntries;
|
|
40
|
+
let dropped = 0;
|
|
41
|
+
for (const key of windows.keys()) {
|
|
42
|
+
windows.delete(key);
|
|
43
|
+
if (++dropped >= overflow)
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
async limit(key, { max, windowSeconds }) {
|
|
50
|
+
const nowMs = Date.now();
|
|
51
|
+
prune(nowMs);
|
|
52
|
+
const existing = windows.get(key);
|
|
53
|
+
const win = existing && existing.resetAtMs > nowMs
|
|
54
|
+
? existing
|
|
55
|
+
: { count: 0, resetAtMs: nowMs + windowSeconds * 1000 };
|
|
56
|
+
win.count += 1;
|
|
57
|
+
windows.set(key, win);
|
|
58
|
+
return {
|
|
59
|
+
allowed: win.count <= max,
|
|
60
|
+
remaining: Math.max(0, max - win.count),
|
|
61
|
+
retryAfterSeconds: Math.max(1, Math.ceil((win.resetAtMs - nowMs) / 1000)),
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* KV-backed fixed-window store.
|
|
68
|
+
*
|
|
69
|
+
* **Best-effort by construction**: KV is eventually consistent and the
|
|
70
|
+
* counter is a non-atomic read-modify-write, so concurrent requests
|
|
71
|
+
* across PoPs undercount — a determined attacker gets somewhat more than
|
|
72
|
+
* `max` through before the window converges. With per-client keys this
|
|
73
|
+
* is still a real brake on brute force and write floods; deployments
|
|
74
|
+
* needing exact distributed limits should bind the Cloudflare Rate
|
|
75
|
+
* Limiting binding (`RATE_LIMITER`) instead.
|
|
76
|
+
*
|
|
77
|
+
* Stored keys are `lim:<bucket>:<clientKey>:<windowKey>` (the window
|
|
78
|
+
* suffix is appended here) with a TTL of `max(60, windowSeconds * 2)` —
|
|
79
|
+
* KV's TTL floor is 60s.
|
|
80
|
+
*/
|
|
81
|
+
export function createKvRateLimitStore(kv) {
|
|
82
|
+
return {
|
|
83
|
+
async limit(key, { max, windowSeconds }) {
|
|
84
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
85
|
+
const windowKey = Math.floor(nowSeconds / windowSeconds);
|
|
86
|
+
const storageKey = `${key}:${windowKey}`;
|
|
87
|
+
const raw = await kv.get(storageKey);
|
|
88
|
+
const current = raw ? Number(raw) || 0 : 0;
|
|
89
|
+
const next = current + 1;
|
|
90
|
+
await kv.put(storageKey, String(next), {
|
|
91
|
+
expirationTtl: Math.max(60, windowSeconds * 2),
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
allowed: next <= max,
|
|
95
|
+
remaining: Math.max(0, max - next),
|
|
96
|
+
retryAfterSeconds: Math.max(1, windowSeconds - (nowSeconds % windowSeconds)),
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Adapter for the Cloudflare native Rate Limiting binding. Truly
|
|
103
|
+
* distributed and atomic; the actual limit/period are fixed in wrangler
|
|
104
|
+
* config, so the policy's `max`/`windowSeconds` only inform the
|
|
105
|
+
* `Retry-After` hint. `remaining` is never reported (the binding does
|
|
106
|
+
* not expose it).
|
|
107
|
+
*/
|
|
108
|
+
export function createCloudflareRateLimitStore(binding) {
|
|
109
|
+
return {
|
|
110
|
+
async limit(key, { windowSeconds }) {
|
|
111
|
+
const { success } = await binding.limit({ key });
|
|
112
|
+
return success
|
|
113
|
+
? { allowed: true }
|
|
114
|
+
: { allowed: false, retryAfterSeconds: Math.max(1, windowSeconds) };
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
// ---- Store resolution ----
|
|
119
|
+
const bindingStoreCache = new WeakMap();
|
|
120
|
+
const kvStoreCache = new WeakMap();
|
|
121
|
+
const sharedMemoryStore = createMemoryRateLimitStore();
|
|
122
|
+
let warnedNoDistributedStore = false;
|
|
123
|
+
function isDevLikeEnv() {
|
|
124
|
+
const proc = globalThis.process;
|
|
125
|
+
const nodeEnv = proc?.env?.NODE_ENV;
|
|
126
|
+
return nodeEnv === "test" || nodeEnv === "development";
|
|
127
|
+
}
|
|
128
|
+
/** Test-only: re-arm the once-per-isolate missing-store warning. */
|
|
129
|
+
export function resetRateLimitWarningsForTests() {
|
|
130
|
+
warnedNoDistributedStore = false;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Resolve the best available store from the environment:
|
|
134
|
+
* `c.env.RATE_LIMITER` (CF Rate Limiting binding) → `c.env.RATE_LIMIT`
|
|
135
|
+
* (KV) → in-memory fallback. **Fails open into the memory store** —
|
|
136
|
+
* rate limiting must never break Node/headless deployments that bind
|
|
137
|
+
* neither — but warns once per isolate outside dev/test so a
|
|
138
|
+
* production deploy without a distributed backend is visible in logs.
|
|
139
|
+
*/
|
|
140
|
+
export function resolveRateLimitStore(c, memoryFallback = sharedMemoryStore) {
|
|
141
|
+
const env = (c.env ?? {});
|
|
142
|
+
const binding = env.RATE_LIMITER;
|
|
143
|
+
if (binding && typeof binding.limit === "function") {
|
|
144
|
+
let store = bindingStoreCache.get(binding);
|
|
145
|
+
if (!store) {
|
|
146
|
+
store = createCloudflareRateLimitStore(binding);
|
|
147
|
+
bindingStoreCache.set(binding, store);
|
|
148
|
+
}
|
|
149
|
+
return store;
|
|
150
|
+
}
|
|
151
|
+
const kv = env.RATE_LIMIT;
|
|
152
|
+
if (kv && typeof kv.get === "function" && typeof kv.put === "function") {
|
|
153
|
+
let store = kvStoreCache.get(kv);
|
|
154
|
+
if (!store) {
|
|
155
|
+
store = createKvRateLimitStore(kv);
|
|
156
|
+
kvStoreCache.set(kv, store);
|
|
157
|
+
}
|
|
158
|
+
return store;
|
|
159
|
+
}
|
|
160
|
+
if (!warnedNoDistributedStore && !isDevLikeEnv()) {
|
|
161
|
+
warnedNoDistributedStore = true;
|
|
162
|
+
console.warn("[voyant] rate-limit: no distributed store available (bind a Cloudflare " +
|
|
163
|
+
"ratelimiter as RATE_LIMITER or a KV namespace as RATE_LIMIT). " +
|
|
164
|
+
"Falling back to a per-isolate in-memory limiter — limits apply " +
|
|
165
|
+
"per instance, not fleet-wide.");
|
|
166
|
+
}
|
|
167
|
+
return memoryFallback;
|
|
168
|
+
}
|
|
169
|
+
// ---- Enforcement ----
|
|
170
|
+
/**
|
|
171
|
+
* Imperative limit check for route handlers. Records one hit for the
|
|
172
|
+
* calling client against `policy` and returns `null` when allowed or a
|
|
173
|
+
* ready-to-return `429` Response (with `Retry-After` and the
|
|
174
|
+
* `X-RateLimit-*` headers the backend can populate) when over the limit.
|
|
175
|
+
*
|
|
176
|
+
* Fails open when the store itself errors — a broken KV namespace must
|
|
177
|
+
* not take the API down.
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* const limited = await enforceRateLimit(c, {
|
|
181
|
+
* bucket: "booking-lookup",
|
|
182
|
+
* max: 20,
|
|
183
|
+
* windowSeconds: 60,
|
|
184
|
+
* })
|
|
185
|
+
* if (limited) return limited
|
|
186
|
+
*/
|
|
187
|
+
export async function enforceRateLimit(c, policy) {
|
|
188
|
+
const store = policy.store ?? resolveRateLimitStore(c);
|
|
189
|
+
const clientKey = (policy.clientKey ?? clientIpKey)(c);
|
|
190
|
+
const key = `lim:${policy.bucket}:${clientKey}`;
|
|
191
|
+
let result;
|
|
192
|
+
try {
|
|
193
|
+
result = await store.limit(key, { max: policy.max, windowSeconds: policy.windowSeconds });
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// Fail open: the limiter is a brake, not a load-bearing dependency.
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
c.header("X-RateLimit-Limit", String(policy.max));
|
|
200
|
+
if (result.remaining !== undefined) {
|
|
201
|
+
c.header("X-RateLimit-Remaining", String(result.remaining));
|
|
202
|
+
}
|
|
203
|
+
if (result.allowed)
|
|
204
|
+
return null;
|
|
205
|
+
const retryAfterSeconds = result.retryAfterSeconds ?? policy.windowSeconds;
|
|
206
|
+
return new Response(JSON.stringify({ error: "Too Many Requests", code: "rate_limited" }), {
|
|
207
|
+
status: 429,
|
|
208
|
+
headers: {
|
|
209
|
+
"content-type": "application/json",
|
|
210
|
+
"x-content-type-options": "nosniff",
|
|
211
|
+
"retry-after": String(retryAfterSeconds),
|
|
212
|
+
"x-ratelimit-limit": String(policy.max),
|
|
213
|
+
"x-ratelimit-remaining": "0",
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Hono middleware form of {@link enforceRateLimit}. Mount on a route or
|
|
219
|
+
* group:
|
|
220
|
+
*
|
|
221
|
+
* app.post("/v1/public/leads", rateLimit({ bucket: "leads", max: 30, windowSeconds: 60 }), handler)
|
|
222
|
+
*/
|
|
223
|
+
export function rateLimit(policy) {
|
|
224
|
+
return async (c, next) => {
|
|
225
|
+
if (c.req.method === "OPTIONS")
|
|
226
|
+
return next();
|
|
227
|
+
const limited = await enforceRateLimit(c, policy);
|
|
228
|
+
if (limited)
|
|
229
|
+
return limited;
|
|
230
|
+
return next();
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* @deprecated Legacy constants from the pre-C2 limiter, retained for
|
|
235
|
+
* import compatibility. Configure limits per policy instead.
|
|
236
|
+
*/
|
|
237
|
+
export const LIVE_LIMITS = {
|
|
238
|
+
burst: 30,
|
|
239
|
+
rpm: 3000,
|
|
240
|
+
};
|