@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.
Files changed (105) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +58 -0
  3. package/dist/app-workflows.d.ts +31 -0
  4. package/dist/app-workflows.d.ts.map +1 -0
  5. package/dist/app-workflows.js +110 -0
  6. package/dist/app.d.ts +45 -0
  7. package/dist/app.d.ts.map +1 -0
  8. package/dist/app.js +403 -0
  9. package/dist/auth/crypto.d.ts +16 -0
  10. package/dist/auth/crypto.d.ts.map +1 -0
  11. package/dist/auth/crypto.js +66 -0
  12. package/dist/auth/index.d.ts +5 -0
  13. package/dist/auth/index.d.ts.map +1 -0
  14. package/dist/auth/index.js +3 -0
  15. package/dist/auth/require-user.d.ts +3 -0
  16. package/dist/auth/require-user.d.ts.map +1 -0
  17. package/dist/auth/require-user.js +8 -0
  18. package/dist/auth/session-jwt.d.ts +7 -0
  19. package/dist/auth/session-jwt.d.ts.map +1 -0
  20. package/dist/auth/session-jwt.js +23 -0
  21. package/dist/composition.d.ts +67 -0
  22. package/dist/composition.d.ts.map +1 -0
  23. package/dist/composition.js +46 -0
  24. package/dist/document-download.d.ts +30 -0
  25. package/dist/document-download.d.ts.map +1 -0
  26. package/dist/document-download.js +102 -0
  27. package/dist/index.d.ts +17 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +9 -0
  30. package/dist/lib/db-selector.d.ts +24 -0
  31. package/dist/lib/db-selector.d.ts.map +1 -0
  32. package/dist/lib/db-selector.js +28 -0
  33. package/dist/lib/execution-ctx.d.ts +16 -0
  34. package/dist/lib/execution-ctx.d.ts.map +1 -0
  35. package/dist/lib/execution-ctx.js +16 -0
  36. package/dist/lib/public-paths.d.ts +19 -0
  37. package/dist/lib/public-paths.d.ts.map +1 -0
  38. package/dist/lib/public-paths.js +27 -0
  39. package/dist/lib/request-event-bus.d.ts +21 -0
  40. package/dist/lib/request-event-bus.d.ts.map +1 -0
  41. package/dist/lib/request-event-bus.js +43 -0
  42. package/dist/middleware/auth.d.ts +10 -0
  43. package/dist/middleware/auth.d.ts.map +1 -0
  44. package/dist/middleware/auth.js +280 -0
  45. package/dist/middleware/body-size.d.ts +7 -0
  46. package/dist/middleware/body-size.d.ts.map +1 -0
  47. package/dist/middleware/body-size.js +20 -0
  48. package/dist/middleware/cors.d.ts +6 -0
  49. package/dist/middleware/cors.d.ts.map +1 -0
  50. package/dist/middleware/cors.js +94 -0
  51. package/dist/middleware/db.d.ts +43 -0
  52. package/dist/middleware/db.d.ts.map +1 -0
  53. package/dist/middleware/db.js +78 -0
  54. package/dist/middleware/error-boundary.d.ts +5 -0
  55. package/dist/middleware/error-boundary.d.ts.map +1 -0
  56. package/dist/middleware/error-boundary.js +76 -0
  57. package/dist/middleware/idempotency-key.d.ts +97 -0
  58. package/dist/middleware/idempotency-key.d.ts.map +1 -0
  59. package/dist/middleware/idempotency-key.js +235 -0
  60. package/dist/middleware/index.d.ts +14 -0
  61. package/dist/middleware/index.d.ts.map +1 -0
  62. package/dist/middleware/index.js +13 -0
  63. package/dist/middleware/logger.d.ts +5 -0
  64. package/dist/middleware/logger.d.ts.map +1 -0
  65. package/dist/middleware/logger.js +27 -0
  66. package/dist/middleware/metrics.d.ts +55 -0
  67. package/dist/middleware/metrics.d.ts.map +1 -0
  68. package/dist/middleware/metrics.js +94 -0
  69. package/dist/middleware/public-cache.d.ts +44 -0
  70. package/dist/middleware/public-cache.d.ts.map +1 -0
  71. package/dist/middleware/public-cache.js +205 -0
  72. package/dist/middleware/rate-limit.d.ts +214 -0
  73. package/dist/middleware/rate-limit.d.ts.map +1 -0
  74. package/dist/middleware/rate-limit.js +240 -0
  75. package/dist/middleware/request-db.d.ts +42 -0
  76. package/dist/middleware/request-db.d.ts.map +1 -0
  77. package/dist/middleware/request-db.js +62 -0
  78. package/dist/middleware/require-actor.d.ts +28 -0
  79. package/dist/middleware/require-actor.d.ts.map +1 -0
  80. package/dist/middleware/require-actor.js +89 -0
  81. package/dist/middleware/require-permission.d.ts +9 -0
  82. package/dist/middleware/require-permission.d.ts.map +1 -0
  83. package/dist/middleware/require-permission.js +62 -0
  84. package/dist/middleware/security-headers.d.ts +10 -0
  85. package/dist/middleware/security-headers.d.ts.map +1 -0
  86. package/dist/middleware/security-headers.js +19 -0
  87. package/dist/module.d.ts +41 -0
  88. package/dist/module.d.ts.map +1 -0
  89. package/dist/module.js +1 -0
  90. package/dist/plugin.d.ts +66 -0
  91. package/dist/plugin.d.ts.map +1 -0
  92. package/dist/plugin.js +37 -0
  93. package/dist/public-capability.d.ts +46 -0
  94. package/dist/public-capability.d.ts.map +1 -0
  95. package/dist/public-capability.js +140 -0
  96. package/dist/public-document-delivery.d.ts +111 -0
  97. package/dist/public-document-delivery.d.ts.map +1 -0
  98. package/dist/public-document-delivery.js +234 -0
  99. package/dist/types.d.ts +318 -0
  100. package/dist/types.d.ts.map +1 -0
  101. package/dist/types.js +29 -0
  102. package/dist/validation.d.ts +36 -0
  103. package/dist/validation.d.ts.map +1 -0
  104. package/dist/validation.js +106 -0
  105. 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":"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"}
@@ -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
+ };