@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.
@@ -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