@od-oneapp/security 2026.1.1301
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/README.md +807 -0
- package/dist/client-next.d.mts +2 -0
- package/dist/client-next.mjs +17 -0
- package/dist/client-next.mjs.map +1 -0
- package/dist/client.d.mts +5 -0
- package/dist/client.d.mts.map +1 -0
- package/dist/client.mjs +48 -0
- package/dist/client.mjs.map +1 -0
- package/dist/env-DvTVXAjh.d.mts +163 -0
- package/dist/env-DvTVXAjh.d.mts.map +1 -0
- package/dist/rate-limit-DStYbhoa.mjs +736 -0
- package/dist/rate-limit-DStYbhoa.mjs.map +1 -0
- package/dist/server-next.d.mts +30 -0
- package/dist/server-next.d.mts.map +1 -0
- package/dist/server-next.mjs +269 -0
- package/dist/server-next.mjs.map +1 -0
- package/dist/server.d.mts +2 -0
- package/dist/server.mjs +3 -0
- package/package.json +80 -0
- package/src/client-next.ts +13 -0
- package/src/client.ts +47 -0
- package/src/server-next.ts +347 -0
- package/src/server.ts +14 -0
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { Ratelimit } from "@integrations/upstash/redis-client";
|
|
4
|
+
import { getServerClient } from "@od-oneapp/db-upstash-redis/server";
|
|
5
|
+
import { logError, logWarn } from "@od-oneapp/shared/logger";
|
|
6
|
+
import { createEnv } from "@t3-oss/env-core";
|
|
7
|
+
import { z } from "zod/v4";
|
|
8
|
+
|
|
9
|
+
//#region env.ts
|
|
10
|
+
/**
|
|
11
|
+
* @fileoverview env.ts
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Default logger implementation using structured JSON logging
|
|
15
|
+
*/
|
|
16
|
+
let loggerInstance = {
|
|
17
|
+
warn: (message, context) => {
|
|
18
|
+
if (process.env.NODE_ENV !== "test") logWarn(message, context);
|
|
19
|
+
},
|
|
20
|
+
error: (message, context) => {
|
|
21
|
+
if (process.env.NODE_ENV !== "test") logError(message, {
|
|
22
|
+
...context,
|
|
23
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Set a custom logger implementation for the security package
|
|
29
|
+
*
|
|
30
|
+
* This allows you to integrate your preferred logging solution (pino, winston, etc.)
|
|
31
|
+
* with the security package's internal logging.
|
|
32
|
+
*
|
|
33
|
+
* @param customLogger - Custom logger implementation conforming to the Logger interface
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```typescript
|
|
37
|
+
* import { setLogger } from '@od-oneapp/security/server';
|
|
38
|
+
* import pino from 'pino';
|
|
39
|
+
*
|
|
40
|
+
* const logger = pino({ level: 'info' });
|
|
41
|
+
* setLogger({
|
|
42
|
+
* warn: (msg, ctx) => logger.warn(ctx, msg),
|
|
43
|
+
* error: (msg, ctx) => logger.error(ctx, msg),
|
|
44
|
+
* });
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* // Winston integration
|
|
50
|
+
* import winston from 'winston';
|
|
51
|
+
* import { setLogger } from '@od-oneapp/security/server';
|
|
52
|
+
*
|
|
53
|
+
* const logger = winston.createLogger({
|
|
54
|
+
* level: 'info',
|
|
55
|
+
* format: winston.format.json(),
|
|
56
|
+
* transports: [new winston.transports.Console()],
|
|
57
|
+
* });
|
|
58
|
+
*
|
|
59
|
+
* setLogger({
|
|
60
|
+
* warn: (msg, ctx) => logger.warn(msg, ctx),
|
|
61
|
+
* error: (msg, ctx) => logger.error(msg, ctx),
|
|
62
|
+
* });
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
function setLogger(customLogger) {
|
|
66
|
+
loggerInstance = customLogger;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Get the current logger instance
|
|
70
|
+
*
|
|
71
|
+
* Returns the active logger (default JSON logger or custom logger set via setLogger).
|
|
72
|
+
* The default logger outputs structured JSON logs to console and is silent in test environments.
|
|
73
|
+
*
|
|
74
|
+
* @returns The current logger instance
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```typescript
|
|
78
|
+
* import { getLogger } from '@od-oneapp/security/server';
|
|
79
|
+
*
|
|
80
|
+
* const logger = getLogger();
|
|
81
|
+
* logger.warn('Rate limiting disabled', { reason: 'no Redis config' });
|
|
82
|
+
* logger.error('Security check failed', { error: 'timeout' });
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
function getLogger() {
|
|
86
|
+
return loggerInstance;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Validated environment variables for the security package
|
|
90
|
+
*
|
|
91
|
+
* Type-safe environment configuration with automatic validation using @t3-oss/env-core.
|
|
92
|
+
* In production, validation errors throw. In development, they log warnings and use fallback values.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```typescript
|
|
96
|
+
* import { env } from '@od-oneapp/security/server';
|
|
97
|
+
*
|
|
98
|
+
* // Access validated environment variables
|
|
99
|
+
* const arcjetKey = env.ARCJET_KEY;
|
|
100
|
+
* const redisToken = env.UPSTASH_REDIS_REST_TOKEN;
|
|
101
|
+
* const isProduction = env.NODE_ENV === 'production';
|
|
102
|
+
* ```
|
|
103
|
+
*
|
|
104
|
+
* @see {@link https://env.t3.gg/docs/core | @t3-oss/env-core Documentation}
|
|
105
|
+
*/
|
|
106
|
+
const env = createEnv({
|
|
107
|
+
server: {
|
|
108
|
+
ARCJET_KEY: z.string().startsWith("ajkey_").optional(),
|
|
109
|
+
UPSTASH_REDIS_REST_TOKEN: z.string().min(1).optional(),
|
|
110
|
+
UPSTASH_REDIS_REST_URL: z.string().url().optional(),
|
|
111
|
+
NODE_ENV: z.enum([
|
|
112
|
+
"development",
|
|
113
|
+
"test",
|
|
114
|
+
"production"
|
|
115
|
+
]).default("development")
|
|
116
|
+
},
|
|
117
|
+
runtimeEnv: process.env,
|
|
118
|
+
emptyStringAsUndefined: true,
|
|
119
|
+
onValidationError: (error) => {
|
|
120
|
+
const message = Array.isArray(error) ? error.map((e) => e.message).join(", ") : String(error);
|
|
121
|
+
if (process.env.NODE_ENV === "production") {
|
|
122
|
+
loggerInstance.error("Security environment validation failed in production", {
|
|
123
|
+
error: message,
|
|
124
|
+
env: process.env.NODE_ENV,
|
|
125
|
+
module: "@od-oneapp/security"
|
|
126
|
+
});
|
|
127
|
+
throw new Error(`Security configuration error: ${message}`);
|
|
128
|
+
}
|
|
129
|
+
loggerInstance.warn("Security environment validation failed", {
|
|
130
|
+
error: message,
|
|
131
|
+
env: process.env.NODE_ENV,
|
|
132
|
+
module: "@od-oneapp/security"
|
|
133
|
+
});
|
|
134
|
+
throw new Error(`Security environment validation failed: ${message}`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
/**
|
|
138
|
+
* Safe access to environment variables with fallback handling
|
|
139
|
+
*
|
|
140
|
+
* Provides resilient access to environment variables in non-Next.js contexts
|
|
141
|
+
* (Node.js, workers, tests). Returns validated env or fallback values if validation fails.
|
|
142
|
+
*
|
|
143
|
+
* **Semantics:**
|
|
144
|
+
* - If `env` is successfully validated by `@t3-oss/env-core`, returns the validated env object
|
|
145
|
+
* - If validation fails (e.g., invalid format), returns fallback values matching the same structure
|
|
146
|
+
* - The return type is always compatible with `SecurityEnv` (typeof env)
|
|
147
|
+
* - Helper functions (`isProduction`, `hasArcjetConfig`, `hasUpstashConfig`) rely on this
|
|
148
|
+
* consistent return structure for type safety
|
|
149
|
+
*
|
|
150
|
+
* @returns Environment variables object (validated or fallback values)
|
|
151
|
+
* Always matches the structure of `SecurityEnv` for type safety
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* ```typescript
|
|
155
|
+
* import { safeEnv } from '@od-oneapp/security/server';
|
|
156
|
+
*
|
|
157
|
+
* const env = safeEnv();
|
|
158
|
+
* if (env.ARCJET_KEY) {
|
|
159
|
+
* // Bot protection is configured
|
|
160
|
+
* }
|
|
161
|
+
* ```
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* ```typescript
|
|
165
|
+
* // Use in rate limiter initialization
|
|
166
|
+
* const env = safeEnv();
|
|
167
|
+
* const isProduction = env.NODE_ENV === 'production';
|
|
168
|
+
* if (!env.UPSTASH_REDIS_REST_TOKEN && isProduction) {
|
|
169
|
+
* throw new Error('Redis required in production');
|
|
170
|
+
* }
|
|
171
|
+
* ```
|
|
172
|
+
*/
|
|
173
|
+
function safeEnv() {
|
|
174
|
+
return env;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Check if the current environment is production
|
|
178
|
+
*
|
|
179
|
+
* Useful for conditional logic that should only run in production
|
|
180
|
+
* (e.g., strict rate limiting, fail-closed security checks).
|
|
181
|
+
*
|
|
182
|
+
* @returns True if NODE_ENV is 'production'
|
|
183
|
+
*
|
|
184
|
+
* @example
|
|
185
|
+
* ```typescript
|
|
186
|
+
* import { isProduction } from '@od-oneapp/security/server';
|
|
187
|
+
*
|
|
188
|
+
* if (isProduction()) {
|
|
189
|
+
* // Enforce strict security policies
|
|
190
|
+
* await secure([], request); // Block all bots
|
|
191
|
+
* } else {
|
|
192
|
+
* // Development: allow some bots for testing
|
|
193
|
+
* await secure(['GOOGLEBOT'], request);
|
|
194
|
+
* }
|
|
195
|
+
* ```
|
|
196
|
+
*/
|
|
197
|
+
function isProduction() {
|
|
198
|
+
return safeEnv().NODE_ENV === "production";
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Check if Arcjet is configured
|
|
202
|
+
*
|
|
203
|
+
* Returns true if ARCJET_KEY environment variable is set.
|
|
204
|
+
* Use this to conditionally enable bot detection when Arcjet is available.
|
|
205
|
+
*
|
|
206
|
+
* @returns True if ARCJET_KEY environment variable is set
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* ```typescript
|
|
210
|
+
* import { hasArcjetConfig, secure } from '@od-oneapp/security/server/next';
|
|
211
|
+
*
|
|
212
|
+
* export async function POST(request: Request) {
|
|
213
|
+
* if (hasArcjetConfig()) {
|
|
214
|
+
* await secure(['GOOGLEBOT', 'BINGBOT'], request);
|
|
215
|
+
* }
|
|
216
|
+
* // Continue with request processing
|
|
217
|
+
* }
|
|
218
|
+
* ```
|
|
219
|
+
*
|
|
220
|
+
* @see {@link https://docs.arcjet.com | Arcjet Documentation}
|
|
221
|
+
*/
|
|
222
|
+
function hasArcjetConfig() {
|
|
223
|
+
const envVars = safeEnv();
|
|
224
|
+
return Boolean(envVars.ARCJET_KEY);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Check if Upstash Redis is configured
|
|
228
|
+
*
|
|
229
|
+
* Returns true if both UPSTASH_REDIS_REST_TOKEN and UPSTASH_REDIS_REST_URL
|
|
230
|
+
* environment variables are set. Use this to conditionally enable rate limiting.
|
|
231
|
+
*
|
|
232
|
+
* @returns True if both UPSTASH_REDIS_REST_TOKEN and UPSTASH_REDIS_REST_URL are set
|
|
233
|
+
*
|
|
234
|
+
* @example
|
|
235
|
+
* ```typescript
|
|
236
|
+
* import { hasUpstashConfig, createRateLimiter } from '@od-oneapp/security/server';
|
|
237
|
+
* import { Ratelimit } from '@upstash/ratelimit';
|
|
238
|
+
*
|
|
239
|
+
* export async function POST(request: Request) {
|
|
240
|
+
* if (hasUpstashConfig()) {
|
|
241
|
+
* const limiter = createRateLimiter({
|
|
242
|
+
* limiter: Ratelimit.slidingWindow(10, '1 m'),
|
|
243
|
+
* });
|
|
244
|
+
* const result = await limiter.limit('user-123');
|
|
245
|
+
* if (!result.success) {
|
|
246
|
+
* return new Response('Rate limited', { status: 429 });
|
|
247
|
+
* }
|
|
248
|
+
* }
|
|
249
|
+
* // Continue with request processing
|
|
250
|
+
* }
|
|
251
|
+
* ```
|
|
252
|
+
*
|
|
253
|
+
* @see {@link https://upstash.com/docs/oss/sdks/ts/ratelimit/overview | Upstash Rate Limiting}
|
|
254
|
+
*/
|
|
255
|
+
function hasUpstashConfig() {
|
|
256
|
+
const envVars = safeEnv();
|
|
257
|
+
return Boolean(envVars.UPSTASH_REDIS_REST_TOKEN && envVars.UPSTASH_REDIS_REST_URL);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
//#endregion
|
|
261
|
+
//#region rate-limit.ts
|
|
262
|
+
/**
|
|
263
|
+
* Sliding window rate limiting algorithm
|
|
264
|
+
*
|
|
265
|
+
* Provides smooth rate limiting by distributing requests across a rolling time window.
|
|
266
|
+
* More accurate than fixed windows as it prevents burst traffic at window boundaries.
|
|
267
|
+
*
|
|
268
|
+
* @param tokens - Maximum number of requests allowed in the time window
|
|
269
|
+
* @param window - Time window duration (e.g., '10 s', '1 m', '1 h')
|
|
270
|
+
* @returns Rate limiter configuration
|
|
271
|
+
*
|
|
272
|
+
* @example
|
|
273
|
+
* ```typescript
|
|
274
|
+
* const limiter = createRateLimiter({
|
|
275
|
+
* limiter: slidingWindow(100, '1 m'), // 100 requests per minute
|
|
276
|
+
* });
|
|
277
|
+
* ```
|
|
278
|
+
*/
|
|
279
|
+
const { slidingWindow } = Ratelimit;
|
|
280
|
+
/**
|
|
281
|
+
* Fixed window rate limiting algorithm
|
|
282
|
+
*
|
|
283
|
+
* Resets the counter at fixed time intervals. Simpler but can allow bursts
|
|
284
|
+
* at window boundaries (e.g., 100 requests at 00:59, 100 more at 01:00).
|
|
285
|
+
*
|
|
286
|
+
* @param tokens - Maximum number of requests allowed in the time window
|
|
287
|
+
* @param window - Time window duration (e.g., '10 s', '1 m', '1 h')
|
|
288
|
+
* @returns Rate limiter configuration
|
|
289
|
+
*
|
|
290
|
+
* @example
|
|
291
|
+
* ```typescript
|
|
292
|
+
* const limiter = createRateLimiter({
|
|
293
|
+
* limiter: fixedWindow(1000, '1 h'), // 1000 requests per hour
|
|
294
|
+
* });
|
|
295
|
+
* ```
|
|
296
|
+
*/
|
|
297
|
+
const { fixedWindow } = Ratelimit;
|
|
298
|
+
/**
|
|
299
|
+
* Token bucket rate limiting algorithm
|
|
300
|
+
*
|
|
301
|
+
* Allows burst traffic while maintaining average rate. Tokens refill at a constant rate,
|
|
302
|
+
* and requests consume tokens. Good for APIs that need to handle occasional spikes.
|
|
303
|
+
*
|
|
304
|
+
* @param refillRate - Rate at which tokens are refilled
|
|
305
|
+
* @param interval - Refill interval (e.g., '10 s', '1 m')
|
|
306
|
+
* @returns Rate limiter configuration
|
|
307
|
+
*
|
|
308
|
+
* @example
|
|
309
|
+
* ```typescript
|
|
310
|
+
* const limiter = createRateLimiter({
|
|
311
|
+
* limiter: tokenBucket(50, '10 s'), // 50 tokens, refills every 10s
|
|
312
|
+
* });
|
|
313
|
+
* ```
|
|
314
|
+
*/
|
|
315
|
+
const { tokenBucket } = Ratelimit;
|
|
316
|
+
/**
|
|
317
|
+
* No-op rate limiter implementation for development when Redis is not configured
|
|
318
|
+
* This is type-safe and doesn't bypass TypeScript's type checking
|
|
319
|
+
*/
|
|
320
|
+
var NoOpRateLimiter = class {
|
|
321
|
+
logger = getLogger();
|
|
322
|
+
async getRemaining(_identifier) {
|
|
323
|
+
this.logger.warn("Rate limiting is disabled - no Redis configuration", { nodeEnv: safeEnv().NODE_ENV });
|
|
324
|
+
return {
|
|
325
|
+
remaining: 999999,
|
|
326
|
+
reset: Date.now() + 6e4,
|
|
327
|
+
limit: 999999
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
async limit(_identifier) {
|
|
331
|
+
this.logger.warn("Rate limiting is disabled - no Redis configuration", {
|
|
332
|
+
message: "Set UPSTASH_REDIS_REST_TOKEN and UPSTASH_REDIS_REST_URL to enable rate limiting",
|
|
333
|
+
nodeEnv: safeEnv().NODE_ENV
|
|
334
|
+
});
|
|
335
|
+
return {
|
|
336
|
+
success: true,
|
|
337
|
+
limit: 999999,
|
|
338
|
+
remaining: 999999,
|
|
339
|
+
reset: Date.now() + 6e4,
|
|
340
|
+
pending: Promise.resolve(void 0)
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
async resetUsedTokens(_identifier) {}
|
|
344
|
+
};
|
|
345
|
+
/**
|
|
346
|
+
* Hash identifiers containing unsafe characters (emails, URLs, etc.)
|
|
347
|
+
*
|
|
348
|
+
* Creates a stable SHA-256 hash for identifiers with special characters that
|
|
349
|
+
* aren't allowed in rate limit keys. Use this for emails, user IDs with slashes,
|
|
350
|
+
* or any identifier that fails `validateIdentifier()`.
|
|
351
|
+
*
|
|
352
|
+
* **Note:** This function truncates the SHA-256 hash from 64 to 32 hex characters,
|
|
353
|
+
* reducing entropy from 256 bits to 128 bits. This is intentional and provides
|
|
354
|
+
* sufficient collision resistance for rate limiting use cases while keeping
|
|
355
|
+
* identifier lengths manageable. 128 bits of entropy is cryptographically secure
|
|
356
|
+
* and exceeds the collision resistance needed for rate limit identifiers.
|
|
357
|
+
*
|
|
358
|
+
* @param identifier - The identifier to hash (can contain any characters)
|
|
359
|
+
* @returns Hexadecimal hash (32 characters, safe for rate limiting)
|
|
360
|
+
*
|
|
361
|
+
* @example
|
|
362
|
+
* ```typescript
|
|
363
|
+
* // For email addresses
|
|
364
|
+
* const userId = hashIdentifier('user@example.com');
|
|
365
|
+
* await applyRateLimit(userId, 'api');
|
|
366
|
+
* ```
|
|
367
|
+
*
|
|
368
|
+
* @example
|
|
369
|
+
* ```typescript
|
|
370
|
+
* // For URLs
|
|
371
|
+
* const pathHash = hashIdentifier('/api/users/123/profile');
|
|
372
|
+
* await applyRateLimit(pathHash, 'api');
|
|
373
|
+
* ```
|
|
374
|
+
*/
|
|
375
|
+
function hashIdentifier(identifier) {
|
|
376
|
+
return createHash("sha256").update(identifier).digest("hex").slice(0, 32);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Wraps a promise with a timeout
|
|
380
|
+
* @param promise - The promise to wrap
|
|
381
|
+
* @param timeoutMs - Timeout in milliseconds
|
|
382
|
+
* @param errorMessage - Error message to throw on timeout
|
|
383
|
+
* @returns The promise result or throws on timeout
|
|
384
|
+
*/
|
|
385
|
+
async function withTimeout(promise, timeoutMs, errorMessage) {
|
|
386
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
387
|
+
setTimeout(() => reject(new Error(errorMessage)), timeoutMs);
|
|
388
|
+
});
|
|
389
|
+
return Promise.race([promise, timeout]);
|
|
390
|
+
}
|
|
391
|
+
const DEFAULT_RATE_LIMIT_TIMEOUT_MS = 5e3;
|
|
392
|
+
const rateLimitInfoCache = /* @__PURE__ */ new Map();
|
|
393
|
+
const CACHE_TTL_MS = 1e3;
|
|
394
|
+
const MAX_CACHE_SIZE = 1e3;
|
|
395
|
+
const pendingRequests = /* @__PURE__ */ new Map();
|
|
396
|
+
/**
|
|
397
|
+
* Cleanup expired cache entries periodically
|
|
398
|
+
* Runs every 5 seconds to prevent memory leaks
|
|
399
|
+
*/
|
|
400
|
+
function cleanupCache() {
|
|
401
|
+
const now = Date.now();
|
|
402
|
+
const entries = Array.from(rateLimitInfoCache.entries());
|
|
403
|
+
for (const [key, value] of entries) if (value.expires <= now) rateLimitInfoCache.delete(key);
|
|
404
|
+
if (rateLimitInfoCache.size > MAX_CACHE_SIZE) {
|
|
405
|
+
const toDelete = entries.sort((a, b) => a[1].expires - b[1].expires).slice(0, rateLimitInfoCache.size - MAX_CACHE_SIZE);
|
|
406
|
+
toDelete.forEach(([key]) => rateLimitInfoCache.delete(key));
|
|
407
|
+
getLogger().warn("Rate limit cache size limit exceeded, evicted old entries", {
|
|
408
|
+
evicted: toDelete.length,
|
|
409
|
+
remaining: rateLimitInfoCache.size
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
setInterval(() => {
|
|
414
|
+
try {
|
|
415
|
+
cleanupCache();
|
|
416
|
+
} catch (error) {
|
|
417
|
+
getLogger().error("Cache cleanup failed", { error: error instanceof Error ? error.message : String(error) });
|
|
418
|
+
}
|
|
419
|
+
}, 5e3).unref();
|
|
420
|
+
/**
|
|
421
|
+
* Create a rate limiter with shared Redis backend
|
|
422
|
+
*
|
|
423
|
+
* Returns a configured rate limiter using the shared Upstash Redis instance.
|
|
424
|
+
* In development without Redis, returns a no-op limiter with `disabled: true`.
|
|
425
|
+
* In production without Redis, throws an error.
|
|
426
|
+
*
|
|
427
|
+
* @param props - Rate limiter configuration (limiter algorithm, prefix, analytics)
|
|
428
|
+
* @returns Configured Ratelimit instance
|
|
429
|
+
* @throws {Error} In production if Redis is not configured
|
|
430
|
+
*
|
|
431
|
+
* @example
|
|
432
|
+
* ```typescript
|
|
433
|
+
* const limiter = createRateLimiter({
|
|
434
|
+
* limiter: slidingWindow(100, '1 m'),
|
|
435
|
+
* prefix: 'api:v1',
|
|
436
|
+
* });
|
|
437
|
+
* const result = await limiter.limit('user-123');
|
|
438
|
+
* ```
|
|
439
|
+
*
|
|
440
|
+
* @example
|
|
441
|
+
* ```typescript
|
|
442
|
+
* // Custom burst handling
|
|
443
|
+
* const burstLimiter = createRateLimiter({
|
|
444
|
+
* limiter: tokenBucket(50, '10 s'),
|
|
445
|
+
* prefix: 'uploads',
|
|
446
|
+
* analytics: true,
|
|
447
|
+
* });
|
|
448
|
+
* ```
|
|
449
|
+
*/
|
|
450
|
+
const createRateLimiter = (props) => {
|
|
451
|
+
const env = safeEnv();
|
|
452
|
+
if (!env.UPSTASH_REDIS_REST_TOKEN || !env.UPSTASH_REDIS_REST_URL) {
|
|
453
|
+
if (env.NODE_ENV === "production") throw new Error("Rate limiting requires Redis configuration. Set UPSTASH_REDIS_REST_TOKEN and UPSTASH_REDIS_REST_URL environment variables.");
|
|
454
|
+
return new NoOpRateLimiter();
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
const redisClient = getServerClient();
|
|
458
|
+
return new Ratelimit({
|
|
459
|
+
limiter: props.limiter,
|
|
460
|
+
prefix: props.prefix ?? "forge",
|
|
461
|
+
redis: redisClient.redis,
|
|
462
|
+
analytics: props.analytics ?? false,
|
|
463
|
+
ephemeralCache: props.ephemeralCache ?? void 0
|
|
464
|
+
});
|
|
465
|
+
} catch (error) {
|
|
466
|
+
getLogger().error("Failed to create rate limiter", {
|
|
467
|
+
error: error instanceof Error ? error.message : String(error),
|
|
468
|
+
nodeEnv: env.NODE_ENV
|
|
469
|
+
});
|
|
470
|
+
if (env.NODE_ENV === "production") throw new Error("Rate limiter initialization failed. Check Redis configuration.");
|
|
471
|
+
return new NoOpRateLimiter();
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
/**
|
|
475
|
+
* Default rate limiter configurations for common use cases
|
|
476
|
+
*
|
|
477
|
+
* Readonly configurations optimized for different endpoint types.
|
|
478
|
+
* All use sliding window algorithm for smooth rate limiting.
|
|
479
|
+
*
|
|
480
|
+
* @example
|
|
481
|
+
* ```typescript
|
|
482
|
+
* // Use pre-configured limiters
|
|
483
|
+
* const result = await rateLimiters.api.limit('user-123');
|
|
484
|
+
*
|
|
485
|
+
* // Or create custom limiter from config
|
|
486
|
+
* const customLimiter = createRateLimiter(rateLimitConfigs.api);
|
|
487
|
+
* ```
|
|
488
|
+
*/
|
|
489
|
+
const rateLimitConfigs = {
|
|
490
|
+
api: {
|
|
491
|
+
limiter: Ratelimit.slidingWindow(100, "1 m"),
|
|
492
|
+
prefix: "ratelimit:api"
|
|
493
|
+
},
|
|
494
|
+
auth: {
|
|
495
|
+
limiter: Ratelimit.slidingWindow(5, "15 m"),
|
|
496
|
+
prefix: "ratelimit:auth"
|
|
497
|
+
},
|
|
498
|
+
upload: {
|
|
499
|
+
limiter: Ratelimit.slidingWindow(10, "1 h"),
|
|
500
|
+
prefix: "ratelimit:upload"
|
|
501
|
+
},
|
|
502
|
+
webhook: {
|
|
503
|
+
limiter: Ratelimit.slidingWindow(1e3, "1 h"),
|
|
504
|
+
prefix: "ratelimit:webhook"
|
|
505
|
+
},
|
|
506
|
+
search: {
|
|
507
|
+
limiter: Ratelimit.slidingWindow(500, "1 m"),
|
|
508
|
+
prefix: "ratelimit:search"
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
/**
|
|
512
|
+
* Pre-configured, ready-to-use rate limiters
|
|
513
|
+
*
|
|
514
|
+
* Instantiated limiters for common use cases. Use these directly
|
|
515
|
+
* or create custom limiters with `createRateLimiter()`.
|
|
516
|
+
*
|
|
517
|
+
* @example
|
|
518
|
+
* ```typescript
|
|
519
|
+
* // Direct usage
|
|
520
|
+
* const result = await rateLimiters.api.limit('192.168.1.1');
|
|
521
|
+
* if (!result.success) {
|
|
522
|
+
* return new Response('Too many requests', { status: 429 });
|
|
523
|
+
* }
|
|
524
|
+
* ```
|
|
525
|
+
*
|
|
526
|
+
* @example
|
|
527
|
+
* ```typescript
|
|
528
|
+
* // Different limiters for different endpoints
|
|
529
|
+
* await rateLimiters.auth.limit(userId); // Strict limits
|
|
530
|
+
* await rateLimiters.search.limit(ip); // Higher limits
|
|
531
|
+
* ```
|
|
532
|
+
*/
|
|
533
|
+
const rateLimiters = {
|
|
534
|
+
get api() {
|
|
535
|
+
return getCachedRateLimiter("api");
|
|
536
|
+
},
|
|
537
|
+
set api(value) {
|
|
538
|
+
cachedRateLimiters.api = value;
|
|
539
|
+
},
|
|
540
|
+
get auth() {
|
|
541
|
+
return getCachedRateLimiter("auth");
|
|
542
|
+
},
|
|
543
|
+
set auth(value) {
|
|
544
|
+
cachedRateLimiters.auth = value;
|
|
545
|
+
},
|
|
546
|
+
get upload() {
|
|
547
|
+
return getCachedRateLimiter("upload");
|
|
548
|
+
},
|
|
549
|
+
set upload(value) {
|
|
550
|
+
cachedRateLimiters.upload = value;
|
|
551
|
+
},
|
|
552
|
+
get webhook() {
|
|
553
|
+
return getCachedRateLimiter("webhook");
|
|
554
|
+
},
|
|
555
|
+
set webhook(value) {
|
|
556
|
+
cachedRateLimiters.webhook = value;
|
|
557
|
+
},
|
|
558
|
+
get search() {
|
|
559
|
+
return getCachedRateLimiter("search");
|
|
560
|
+
},
|
|
561
|
+
set search(value) {
|
|
562
|
+
cachedRateLimiters.search = value;
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
const cachedRateLimiters = {};
|
|
566
|
+
function getCachedRateLimiter(key) {
|
|
567
|
+
const existing = cachedRateLimiters[key];
|
|
568
|
+
if (existing) return existing;
|
|
569
|
+
const created = createRateLimiter(rateLimitConfigs[key]);
|
|
570
|
+
cachedRateLimiters[key] = created;
|
|
571
|
+
return created;
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Validates a rate limit identifier
|
|
575
|
+
*
|
|
576
|
+
* @param identifier - The identifier to validate
|
|
577
|
+
* @returns Validated identifier (unchanged if valid)
|
|
578
|
+
* @throws {Error} If identifier is invalid
|
|
579
|
+
*
|
|
580
|
+
* @remarks
|
|
581
|
+
* Valid characters: alphanumeric, hyphens, underscores, colons, dots
|
|
582
|
+
* For identifiers with special characters (emails, etc.), use hashIdentifier()
|
|
583
|
+
*
|
|
584
|
+
* @example
|
|
585
|
+
* ```typescript
|
|
586
|
+
* // Valid
|
|
587
|
+
* validateIdentifier('user-123');
|
|
588
|
+
* validateIdentifier('192.168.1.1');
|
|
589
|
+
* validateIdentifier('api:v1:users');
|
|
590
|
+
*
|
|
591
|
+
* // Invalid - use hashIdentifier instead
|
|
592
|
+
* hashIdentifier('user@example.com');
|
|
593
|
+
* ```
|
|
594
|
+
*/
|
|
595
|
+
function validateIdentifier(identifier) {
|
|
596
|
+
if (!identifier || typeof identifier !== "string") throw new Error("Rate limit identifier must be a non-empty string");
|
|
597
|
+
if (identifier.length > 255) throw new Error("Rate limit identifier must be 255 characters or less");
|
|
598
|
+
if (/[^a-zA-Z0-9\-_:.]/.test(identifier)) throw new Error(`Rate limit identifier contains invalid characters. Only alphanumeric, hyphens, underscores, colons, and dots are allowed. Got: "${identifier}". Consider using hashIdentifier() for email addresses or complex identifiers.`);
|
|
599
|
+
return identifier;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Apply rate limiting to a request
|
|
603
|
+
* @param identifier - Unique identifier for the request (e.g., IP address, user ID).
|
|
604
|
+
* Must be a non-empty string, max 255 characters.
|
|
605
|
+
* Invalid characters will be sanitized.
|
|
606
|
+
* @param type - Type of rate limiter to use. Defaults to 'api'.
|
|
607
|
+
* Options: 'api', 'auth', 'upload', 'webhook', 'search'
|
|
608
|
+
* @returns Rate limit result with success status and limit information
|
|
609
|
+
* @throws {Error} If identifier is invalid or type doesn't exist
|
|
610
|
+
*
|
|
611
|
+
* @example
|
|
612
|
+
* ```typescript
|
|
613
|
+
* const result = await applyRateLimit('192.168.1.1', 'api');
|
|
614
|
+
* if (!result.success) {
|
|
615
|
+
* return new Response('Too many requests', { status: 429 });
|
|
616
|
+
* }
|
|
617
|
+
* ```
|
|
618
|
+
*
|
|
619
|
+
* @example
|
|
620
|
+
* ```typescript
|
|
621
|
+
* // For authenticated users
|
|
622
|
+
* const result = await applyRateLimit(userId, 'auth');
|
|
623
|
+
* ```
|
|
624
|
+
*/
|
|
625
|
+
const applyRateLimit = async (identifier, type = "api") => {
|
|
626
|
+
const validatedIdentifier = validateIdentifier(identifier);
|
|
627
|
+
if (!(type in rateLimiters)) throw new Error(`Invalid rate limiter type: ${type}. Must be one of: ${Object.keys(rateLimiters).join(", ")}`);
|
|
628
|
+
const limiter = rateLimiters[type];
|
|
629
|
+
try {
|
|
630
|
+
const result = await withTimeout(limiter.limit(validatedIdentifier), DEFAULT_RATE_LIMIT_TIMEOUT_MS, "Rate limit check timed out");
|
|
631
|
+
return {
|
|
632
|
+
success: result.success,
|
|
633
|
+
limit: result.limit,
|
|
634
|
+
remaining: result.remaining,
|
|
635
|
+
reset: result.reset,
|
|
636
|
+
retryAfter: result.success ? void 0 : result.reset - Date.now()
|
|
637
|
+
};
|
|
638
|
+
} catch (error) {
|
|
639
|
+
if (safeEnv().NODE_ENV === "production") throw error;
|
|
640
|
+
return {
|
|
641
|
+
success: true,
|
|
642
|
+
limit: 999999,
|
|
643
|
+
remaining: 999999,
|
|
644
|
+
reset: Date.now() + 6e4,
|
|
645
|
+
disabled: true
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
/**
|
|
650
|
+
* Check if a request is rate limited
|
|
651
|
+
*
|
|
652
|
+
* @param identifier - Unique identifier for the request (e.g., IP address, user ID).
|
|
653
|
+
* Must be a non-empty string, max 255 characters.
|
|
654
|
+
* Invalid characters will be sanitized.
|
|
655
|
+
* @param type - Type of rate limiter to check. Defaults to 'api'.
|
|
656
|
+
* Options: 'api', 'auth', 'upload', 'webhook', 'search'
|
|
657
|
+
* @returns True if request should be blocked due to rate limiting
|
|
658
|
+
* @throws {Error} If identifier is invalid or type doesn't exist
|
|
659
|
+
*
|
|
660
|
+
* @example
|
|
661
|
+
* ```typescript
|
|
662
|
+
* const isLimited = await isRateLimited('192.168.1.1', 'api');
|
|
663
|
+
* if (isLimited) {
|
|
664
|
+
* return new Response('Too many requests', { status: 429 });
|
|
665
|
+
* }
|
|
666
|
+
* ```
|
|
667
|
+
*/
|
|
668
|
+
const isRateLimited = async (identifier, type = "api") => {
|
|
669
|
+
return !(await applyRateLimit(identifier, type)).success;
|
|
670
|
+
};
|
|
671
|
+
/**
|
|
672
|
+
* Get rate limit info without applying limits
|
|
673
|
+
* Uses request coalescing to prevent cache stampede
|
|
674
|
+
*
|
|
675
|
+
* @param identifier - Unique identifier for the request (e.g., IP address, user ID).
|
|
676
|
+
* Must be a non-empty string, max 255 characters.
|
|
677
|
+
* Only alphanumeric, hyphens, underscores, colons, dots allowed.
|
|
678
|
+
* @param type - Type of rate limiter to query. Defaults to 'api'.
|
|
679
|
+
* Options: 'api', 'auth', 'upload', 'webhook', 'search'
|
|
680
|
+
* @returns Rate limit information excluding success status
|
|
681
|
+
* @throws {Error} If identifier is invalid or type doesn't exist
|
|
682
|
+
*
|
|
683
|
+
* @example
|
|
684
|
+
* ```typescript
|
|
685
|
+
* const info = await getRateLimitInfo('192.168.1.1', 'api');
|
|
686
|
+
* console.log(`Limit: ${info.limit}, Remaining: ${info.remaining}`);
|
|
687
|
+
* ```
|
|
688
|
+
*
|
|
689
|
+
* @example
|
|
690
|
+
* ```typescript
|
|
691
|
+
* // For identifiers with special characters, use hashIdentifier
|
|
692
|
+
* const safeId = hashIdentifier('user@example.com');
|
|
693
|
+
* const info = await getRateLimitInfo(safeId, 'api');
|
|
694
|
+
* ```
|
|
695
|
+
*/
|
|
696
|
+
const getRateLimitInfo = async (identifier, type = "api") => {
|
|
697
|
+
const validatedIdentifier = validateIdentifier(identifier);
|
|
698
|
+
if (!(type in rateLimiters)) throw new Error(`Invalid rate limiter type: ${type}. Must be one of: ${Object.keys(rateLimiters).join(", ")}`);
|
|
699
|
+
const limiter = rateLimiters[type];
|
|
700
|
+
const cacheKey = `${type}:${validatedIdentifier}`;
|
|
701
|
+
const cached = rateLimitInfoCache.get(cacheKey);
|
|
702
|
+
if (cached && cached.expires > Date.now()) return cached.data;
|
|
703
|
+
const pending = pendingRequests.get(cacheKey);
|
|
704
|
+
if (pending) return pending;
|
|
705
|
+
const requestPromise = (async () => {
|
|
706
|
+
try {
|
|
707
|
+
const result = await withTimeout(limiter.limit(validatedIdentifier), DEFAULT_RATE_LIMIT_TIMEOUT_MS, "Rate limit info check timed out");
|
|
708
|
+
const data = {
|
|
709
|
+
limit: result.limit,
|
|
710
|
+
remaining: result.remaining,
|
|
711
|
+
reset: result.reset
|
|
712
|
+
};
|
|
713
|
+
rateLimitInfoCache.set(cacheKey, {
|
|
714
|
+
data,
|
|
715
|
+
expires: Date.now() + CACHE_TTL_MS
|
|
716
|
+
});
|
|
717
|
+
return data;
|
|
718
|
+
} catch (error) {
|
|
719
|
+
if (safeEnv().NODE_ENV === "production") throw error;
|
|
720
|
+
return {
|
|
721
|
+
limit: 999999,
|
|
722
|
+
remaining: 999999,
|
|
723
|
+
reset: Date.now() + 6e4,
|
|
724
|
+
disabled: true
|
|
725
|
+
};
|
|
726
|
+
} finally {
|
|
727
|
+
pendingRequests.delete(cacheKey);
|
|
728
|
+
}
|
|
729
|
+
})();
|
|
730
|
+
pendingRequests.set(cacheKey, requestPromise);
|
|
731
|
+
return requestPromise;
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
//#endregion
|
|
735
|
+
export { setLogger as _, hashIdentifier as a, rateLimiters as c, env as d, getLogger as f, safeEnv as g, isProduction as h, getRateLimitInfo as i, slidingWindow as l, hasUpstashConfig as m, createRateLimiter as n, isRateLimited as o, hasArcjetConfig as p, fixedWindow as r, rateLimitConfigs as s, applyRateLimit as t, tokenBucket as u };
|
|
736
|
+
//# sourceMappingURL=rate-limit-DStYbhoa.mjs.map
|