@codaijs/keel 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/sail-installer.test.js +25 -25
- package/dist/sail-installer.js +174 -174
- package/dist/scaffold.js +68 -68
- package/package.json +58 -58
- package/sails/_template/addon.json +20 -20
- package/sails/_template/install.ts +402 -402
- package/sails/admin-dashboard/README.md +117 -117
- package/sails/admin-dashboard/addon.json +28 -28
- package/sails/admin-dashboard/files/backend/middleware/admin.ts +34 -34
- package/sails/admin-dashboard/files/backend/routes/admin.ts +243 -243
- package/sails/admin-dashboard/files/frontend/components/admin/StatsCard.tsx +40 -40
- package/sails/admin-dashboard/files/frontend/components/admin/UsersTable.tsx +240 -240
- package/sails/admin-dashboard/files/frontend/hooks/useAdmin.ts +149 -149
- package/sails/admin-dashboard/files/frontend/pages/admin/Dashboard.tsx +173 -173
- package/sails/admin-dashboard/files/frontend/pages/admin/UserDetail.tsx +203 -203
- package/sails/admin-dashboard/install.ts +305 -305
- package/sails/analytics/README.md +178 -178
- package/sails/analytics/addon.json +27 -27
- package/sails/analytics/files/frontend/components/AnalyticsProvider.tsx +58 -58
- package/sails/analytics/files/frontend/hooks/useAnalytics.ts +64 -64
- package/sails/analytics/files/frontend/lib/analytics.ts +103 -103
- package/sails/analytics/install.ts +297 -297
- package/sails/file-uploads/addon.json +30 -30
- package/sails/file-uploads/files/backend/routes/files.ts +198 -198
- package/sails/file-uploads/files/backend/schema/files.ts +36 -36
- package/sails/file-uploads/files/backend/services/file-storage.ts +128 -128
- package/sails/file-uploads/files/frontend/components/FileList.tsx +248 -248
- package/sails/file-uploads/files/frontend/components/FileUploadButton.tsx +147 -147
- package/sails/file-uploads/files/frontend/hooks/useFileUpload.ts +106 -106
- package/sails/file-uploads/files/frontend/hooks/useFiles.ts +118 -118
- package/sails/file-uploads/files/frontend/pages/Files.tsx +37 -37
- package/sails/file-uploads/install.ts +466 -466
- package/sails/gdpr/README.md +174 -174
- package/sails/gdpr/addon.json +27 -27
- package/sails/gdpr/files/backend/routes/gdpr.ts +140 -140
- package/sails/gdpr/files/backend/services/gdpr.ts +293 -293
- package/sails/gdpr/files/frontend/components/auth/ConsentCheckboxes.tsx +97 -97
- package/sails/gdpr/files/frontend/components/gdpr/AccountDeletionRequest.tsx +192 -192
- package/sails/gdpr/files/frontend/components/gdpr/DataExportButton.tsx +75 -75
- package/sails/gdpr/files/frontend/pages/PrivacyPolicy.tsx +186 -186
- package/sails/gdpr/install.ts +756 -756
- package/sails/google-oauth/README.md +121 -121
- package/sails/google-oauth/addon.json +22 -22
- package/sails/google-oauth/files/GoogleButton.tsx +50 -50
- package/sails/google-oauth/install.ts +252 -252
- package/sails/i18n/README.md +193 -193
- package/sails/i18n/addon.json +30 -30
- package/sails/i18n/files/frontend/components/LanguageSwitcher.tsx +108 -108
- package/sails/i18n/files/frontend/hooks/useLanguage.ts +31 -31
- package/sails/i18n/files/frontend/lib/i18n.ts +32 -32
- package/sails/i18n/files/frontend/locales/de/common.json +44 -44
- package/sails/i18n/files/frontend/locales/en/common.json +44 -44
- package/sails/i18n/install.ts +407 -407
- package/sails/push-notifications/README.md +163 -163
- package/sails/push-notifications/addon.json +31 -31
- package/sails/push-notifications/files/backend/routes/notifications.ts +153 -153
- package/sails/push-notifications/files/backend/schema/notifications.ts +31 -31
- package/sails/push-notifications/files/backend/services/notifications.ts +117 -117
- package/sails/push-notifications/files/frontend/components/PushNotificationInit.tsx +12 -12
- package/sails/push-notifications/files/frontend/hooks/usePushNotifications.ts +154 -154
- package/sails/push-notifications/install.ts +384 -384
- package/sails/r2-storage/addon.json +29 -29
- package/sails/r2-storage/files/backend/services/storage.ts +71 -71
- package/sails/r2-storage/files/frontend/components/ProfilePictureUpload.tsx +167 -167
- package/sails/r2-storage/install.ts +412 -412
- package/sails/rate-limiting/addon.json +20 -20
- package/sails/rate-limiting/files/backend/middleware/rate-limit-store.ts +104 -104
- package/sails/rate-limiting/files/backend/middleware/rate-limit.ts +137 -137
- package/sails/rate-limiting/install.ts +300 -300
- package/sails/registry.json +107 -107
- package/sails/stripe/README.md +214 -214
- package/sails/stripe/addon.json +24 -24
- package/sails/stripe/files/backend/routes/stripe.ts +154 -154
- package/sails/stripe/files/backend/schema/stripe.ts +74 -74
- package/sails/stripe/files/backend/services/stripe.ts +224 -224
- package/sails/stripe/files/frontend/components/SubscriptionStatus.tsx +135 -135
- package/sails/stripe/files/frontend/hooks/useSubscription.ts +86 -86
- package/sails/stripe/files/frontend/pages/Checkout.tsx +116 -116
- package/sails/stripe/files/frontend/pages/Pricing.tsx +226 -226
- package/sails/stripe/install.ts +378 -378
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "rate-limiting",
|
|
3
|
-
"displayName": "API Rate Limiting",
|
|
4
|
-
"description": "In-memory sliding window rate limiting middleware for API routes. No external dependencies required.",
|
|
5
|
-
"version": "1.0.0",
|
|
6
|
-
"compatibility": ">=1.0.0",
|
|
7
|
-
"requiredEnvVars": [],
|
|
8
|
-
"dependencies": {
|
|
9
|
-
"backend": {},
|
|
10
|
-
"frontend": {}
|
|
11
|
-
},
|
|
12
|
-
"modifies": {
|
|
13
|
-
"backend": ["src/index.ts", "src/env.ts"],
|
|
14
|
-
"frontend": []
|
|
15
|
-
},
|
|
16
|
-
"adds": {
|
|
17
|
-
"backend": ["src/middleware/rate-limit.ts", "src/middleware/rate-limit-store.ts"],
|
|
18
|
-
"frontend": []
|
|
19
|
-
}
|
|
20
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "rate-limiting",
|
|
3
|
+
"displayName": "API Rate Limiting",
|
|
4
|
+
"description": "In-memory sliding window rate limiting middleware for API routes. No external dependencies required.",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"compatibility": ">=1.0.0",
|
|
7
|
+
"requiredEnvVars": [],
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"backend": {},
|
|
10
|
+
"frontend": {}
|
|
11
|
+
},
|
|
12
|
+
"modifies": {
|
|
13
|
+
"backend": ["src/index.ts", "src/env.ts"],
|
|
14
|
+
"frontend": []
|
|
15
|
+
},
|
|
16
|
+
"adds": {
|
|
17
|
+
"backend": ["src/middleware/rate-limit.ts", "src/middleware/rate-limit-store.ts"],
|
|
18
|
+
"frontend": []
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -1,104 +1,104 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Rate Limit Store Abstraction
|
|
3
|
-
*
|
|
4
|
-
* Provides an interface and in-memory implementation for rate limit tracking.
|
|
5
|
-
* This abstraction allows swapping to a Redis-backed store in production
|
|
6
|
-
* without changing the middleware logic.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
// Interface
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
|
|
13
|
-
export interface RateLimitEntry {
|
|
14
|
-
/** Number of requests made in the current window. */
|
|
15
|
-
count: number;
|
|
16
|
-
/** Timestamp (ms) when the current window resets. */
|
|
17
|
-
resetAt: number;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface RateLimitStore {
|
|
21
|
-
/**
|
|
22
|
-
* Increment the request count for `key` within a window of `windowMs`.
|
|
23
|
-
* If the key does not exist or the window has expired a new window is
|
|
24
|
-
* started automatically.
|
|
25
|
-
*/
|
|
26
|
-
increment(key: string, windowMs: number): Promise<RateLimitEntry>;
|
|
27
|
-
|
|
28
|
-
/** Decrement the count for `key` (useful for undoing a counted request). */
|
|
29
|
-
decrement(key: string): Promise<void>;
|
|
30
|
-
|
|
31
|
-
/** Reset (delete) the entry for `key`. */
|
|
32
|
-
reset(key: string): Promise<void>;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// ---------------------------------------------------------------------------
|
|
36
|
-
// In-memory implementation
|
|
37
|
-
// ---------------------------------------------------------------------------
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Simple in-memory store backed by a `Map`.
|
|
41
|
-
*
|
|
42
|
-
* A periodic cleanup timer prunes expired entries every `cleanupIntervalMs`
|
|
43
|
-
* (default 5 minutes) so the map does not grow unboundedly.
|
|
44
|
-
*/
|
|
45
|
-
export class MemoryStore implements RateLimitStore {
|
|
46
|
-
private store = new Map<string, RateLimitEntry>();
|
|
47
|
-
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
48
|
-
|
|
49
|
-
constructor(cleanupIntervalMs = 5 * 60 * 1000) {
|
|
50
|
-
this.cleanupTimer = setInterval(() => {
|
|
51
|
-
this.prune();
|
|
52
|
-
}, cleanupIntervalMs);
|
|
53
|
-
|
|
54
|
-
// Allow the Node process to exit even if the timer is still active.
|
|
55
|
-
if (this.cleanupTimer && typeof this.cleanupTimer === "object" && "unref" in this.cleanupTimer) {
|
|
56
|
-
this.cleanupTimer.unref();
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async increment(key: string, windowMs: number): Promise<RateLimitEntry> {
|
|
61
|
-
const now = Date.now();
|
|
62
|
-
const existing = this.store.get(key);
|
|
63
|
-
|
|
64
|
-
if (existing && existing.resetAt > now) {
|
|
65
|
-
// Window still active — increment.
|
|
66
|
-
existing.count += 1;
|
|
67
|
-
return { count: existing.count, resetAt: existing.resetAt };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// No entry or window expired — start a fresh window.
|
|
71
|
-
const entry: RateLimitEntry = { count: 1, resetAt: now + windowMs };
|
|
72
|
-
this.store.set(key, entry);
|
|
73
|
-
return { count: 1, resetAt: entry.resetAt };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
async decrement(key: string): Promise<void> {
|
|
77
|
-
const entry = this.store.get(key);
|
|
78
|
-
if (entry && entry.count > 0) {
|
|
79
|
-
entry.count -= 1;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async reset(key: string): Promise<void> {
|
|
84
|
-
this.store.delete(key);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Remove all expired entries from the map. */
|
|
88
|
-
private prune(): void {
|
|
89
|
-
const now = Date.now();
|
|
90
|
-
for (const [key, entry] of this.store) {
|
|
91
|
-
if (entry.resetAt <= now) {
|
|
92
|
-
this.store.delete(key);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Stop the cleanup timer (useful for tests / graceful shutdown). */
|
|
98
|
-
destroy(): void {
|
|
99
|
-
if (this.cleanupTimer) {
|
|
100
|
-
clearInterval(this.cleanupTimer);
|
|
101
|
-
this.cleanupTimer = null;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Rate Limit Store Abstraction
|
|
3
|
+
*
|
|
4
|
+
* Provides an interface and in-memory implementation for rate limit tracking.
|
|
5
|
+
* This abstraction allows swapping to a Redis-backed store in production
|
|
6
|
+
* without changing the middleware logic.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Interface
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export interface RateLimitEntry {
|
|
14
|
+
/** Number of requests made in the current window. */
|
|
15
|
+
count: number;
|
|
16
|
+
/** Timestamp (ms) when the current window resets. */
|
|
17
|
+
resetAt: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RateLimitStore {
|
|
21
|
+
/**
|
|
22
|
+
* Increment the request count for `key` within a window of `windowMs`.
|
|
23
|
+
* If the key does not exist or the window has expired a new window is
|
|
24
|
+
* started automatically.
|
|
25
|
+
*/
|
|
26
|
+
increment(key: string, windowMs: number): Promise<RateLimitEntry>;
|
|
27
|
+
|
|
28
|
+
/** Decrement the count for `key` (useful for undoing a counted request). */
|
|
29
|
+
decrement(key: string): Promise<void>;
|
|
30
|
+
|
|
31
|
+
/** Reset (delete) the entry for `key`. */
|
|
32
|
+
reset(key: string): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// In-memory implementation
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Simple in-memory store backed by a `Map`.
|
|
41
|
+
*
|
|
42
|
+
* A periodic cleanup timer prunes expired entries every `cleanupIntervalMs`
|
|
43
|
+
* (default 5 minutes) so the map does not grow unboundedly.
|
|
44
|
+
*/
|
|
45
|
+
export class MemoryStore implements RateLimitStore {
|
|
46
|
+
private store = new Map<string, RateLimitEntry>();
|
|
47
|
+
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
48
|
+
|
|
49
|
+
constructor(cleanupIntervalMs = 5 * 60 * 1000) {
|
|
50
|
+
this.cleanupTimer = setInterval(() => {
|
|
51
|
+
this.prune();
|
|
52
|
+
}, cleanupIntervalMs);
|
|
53
|
+
|
|
54
|
+
// Allow the Node process to exit even if the timer is still active.
|
|
55
|
+
if (this.cleanupTimer && typeof this.cleanupTimer === "object" && "unref" in this.cleanupTimer) {
|
|
56
|
+
this.cleanupTimer.unref();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async increment(key: string, windowMs: number): Promise<RateLimitEntry> {
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
const existing = this.store.get(key);
|
|
63
|
+
|
|
64
|
+
if (existing && existing.resetAt > now) {
|
|
65
|
+
// Window still active — increment.
|
|
66
|
+
existing.count += 1;
|
|
67
|
+
return { count: existing.count, resetAt: existing.resetAt };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// No entry or window expired — start a fresh window.
|
|
71
|
+
const entry: RateLimitEntry = { count: 1, resetAt: now + windowMs };
|
|
72
|
+
this.store.set(key, entry);
|
|
73
|
+
return { count: 1, resetAt: entry.resetAt };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async decrement(key: string): Promise<void> {
|
|
77
|
+
const entry = this.store.get(key);
|
|
78
|
+
if (entry && entry.count > 0) {
|
|
79
|
+
entry.count -= 1;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async reset(key: string): Promise<void> {
|
|
84
|
+
this.store.delete(key);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Remove all expired entries from the map. */
|
|
88
|
+
private prune(): void {
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
for (const [key, entry] of this.store) {
|
|
91
|
+
if (entry.resetAt <= now) {
|
|
92
|
+
this.store.delete(key);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Stop the cleanup timer (useful for tests / graceful shutdown). */
|
|
98
|
+
destroy(): void {
|
|
99
|
+
if (this.cleanupTimer) {
|
|
100
|
+
clearInterval(this.cleanupTimer);
|
|
101
|
+
this.cleanupTimer = null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -1,137 +1,137 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sliding-window rate limiting middleware for Express.
|
|
3
|
-
*
|
|
4
|
-
* Uses an in-memory store by default (no Redis required). You can swap in any
|
|
5
|
-
* implementation of `RateLimitStore` for distributed deployments.
|
|
6
|
-
*
|
|
7
|
-
* Usage:
|
|
8
|
-
* import { apiLimiter, authLimiter, createRateLimiter } from "./middleware/rate-limit.js";
|
|
9
|
-
*
|
|
10
|
-
* app.use("/api", apiLimiter); // 100 req / 15 min
|
|
11
|
-
* app.use("/api/auth", authLimiter); // 10 req / 15 min
|
|
12
|
-
*
|
|
13
|
-
* // Custom:
|
|
14
|
-
* app.use("/api/special", createRateLimiter({ windowMs: 60_000, maxRequests: 5 }));
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type { Request, Response, NextFunction } from "express";
|
|
18
|
-
import { MemoryStore } from "./rate-limit-store.js";
|
|
19
|
-
import type { RateLimitStore } from "./rate-limit-store.js";
|
|
20
|
-
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
// Types
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
|
|
25
|
-
export interface RateLimitOptions {
|
|
26
|
-
/** Time window in milliseconds. Default: 15 minutes. */
|
|
27
|
-
windowMs?: number;
|
|
28
|
-
/** Maximum number of requests allowed in the window. Default: 100. */
|
|
29
|
-
maxRequests?: number;
|
|
30
|
-
/** Extract the key used to identify the client. Defaults to IP, or userId when authenticated. */
|
|
31
|
-
keyGenerator?: (req: Request) => string;
|
|
32
|
-
/** Store implementation. Defaults to `MemoryStore`. */
|
|
33
|
-
store?: RateLimitStore;
|
|
34
|
-
/** Custom message returned when the limit is exceeded. */
|
|
35
|
-
message?: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
// Defaults from environment (optional overrides)
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
|
|
42
|
-
const DEFAULT_WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000;
|
|
43
|
-
const DEFAULT_MAX_REQUESTS = Number(process.env.RATE_LIMIT_MAX_REQUESTS) || 100;
|
|
44
|
-
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
// Shared store — one MemoryStore instance for the entire process
|
|
47
|
-
// ---------------------------------------------------------------------------
|
|
48
|
-
|
|
49
|
-
const sharedStore = new MemoryStore();
|
|
50
|
-
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
// Key generator
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
|
|
55
|
-
function defaultKeyGenerator(req: Request): string {
|
|
56
|
-
// Prefer the authenticated user id when available so that rate limits are
|
|
57
|
-
// per-user rather than per-IP for logged-in users.
|
|
58
|
-
const userId = (req as Record<string, unknown>).user
|
|
59
|
-
? ((req as Record<string, unknown>).user as { id?: string })?.id
|
|
60
|
-
: undefined;
|
|
61
|
-
|
|
62
|
-
if (userId) return `user:${userId}`;
|
|
63
|
-
|
|
64
|
-
// Fall back to IP address.
|
|
65
|
-
const forwarded = req.headers["x-forwarded-for"];
|
|
66
|
-
const ip =
|
|
67
|
-
typeof forwarded === "string"
|
|
68
|
-
? forwarded.split(",")[0].trim()
|
|
69
|
-
: req.socket.remoteAddress ?? "unknown";
|
|
70
|
-
|
|
71
|
-
return `ip:${ip}`;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ---------------------------------------------------------------------------
|
|
75
|
-
// Factory
|
|
76
|
-
// ---------------------------------------------------------------------------
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Create a rate-limiting middleware with the given options.
|
|
80
|
-
*/
|
|
81
|
-
export function createRateLimiter(options: RateLimitOptions = {}) {
|
|
82
|
-
const {
|
|
83
|
-
windowMs = DEFAULT_WINDOW_MS,
|
|
84
|
-
maxRequests = DEFAULT_MAX_REQUESTS,
|
|
85
|
-
keyGenerator = defaultKeyGenerator,
|
|
86
|
-
store = sharedStore,
|
|
87
|
-
message = "Too many requests, please try again later.",
|
|
88
|
-
} = options;
|
|
89
|
-
|
|
90
|
-
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
91
|
-
const key = keyGenerator(req);
|
|
92
|
-
|
|
93
|
-
try {
|
|
94
|
-
const { count, resetAt } = await store.increment(key, windowMs);
|
|
95
|
-
|
|
96
|
-
// Always set informational headers.
|
|
97
|
-
res.setHeader("X-RateLimit-Limit", String(maxRequests));
|
|
98
|
-
res.setHeader("X-RateLimit-Remaining", String(Math.max(0, maxRequests - count)));
|
|
99
|
-
res.setHeader("X-RateLimit-Reset", String(Math.ceil(resetAt / 1000)));
|
|
100
|
-
|
|
101
|
-
if (count > maxRequests) {
|
|
102
|
-
const retryAfterSeconds = Math.ceil((resetAt - Date.now()) / 1000);
|
|
103
|
-
res.setHeader("Retry-After", String(retryAfterSeconds));
|
|
104
|
-
res.status(429).json({ error: message });
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
next();
|
|
109
|
-
} catch (err) {
|
|
110
|
-
// If the store fails we let the request through rather than blocking.
|
|
111
|
-
console.error("[rate-limit] Store error:", err);
|
|
112
|
-
next();
|
|
113
|
-
}
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// ---------------------------------------------------------------------------
|
|
118
|
-
// Preset limiters
|
|
119
|
-
// ---------------------------------------------------------------------------
|
|
120
|
-
|
|
121
|
-
/** General API limiter — 100 requests per 15 minutes. */
|
|
122
|
-
export const apiLimiter = createRateLimiter({
|
|
123
|
-
windowMs: DEFAULT_WINDOW_MS,
|
|
124
|
-
maxRequests: DEFAULT_MAX_REQUESTS,
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
/** Auth limiter — 10 requests per 15 minutes (login, signup). */
|
|
128
|
-
export const authLimiter = createRateLimiter({
|
|
129
|
-
windowMs: 15 * 60 * 1000,
|
|
130
|
-
maxRequests: 10,
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
/** Strict limiter — 5 requests per 15 minutes (password reset, sensitive ops). */
|
|
134
|
-
export const strictLimiter = createRateLimiter({
|
|
135
|
-
windowMs: 15 * 60 * 1000,
|
|
136
|
-
maxRequests: 5,
|
|
137
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* Sliding-window rate limiting middleware for Express.
|
|
3
|
+
*
|
|
4
|
+
* Uses an in-memory store by default (no Redis required). You can swap in any
|
|
5
|
+
* implementation of `RateLimitStore` for distributed deployments.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { apiLimiter, authLimiter, createRateLimiter } from "./middleware/rate-limit.js";
|
|
9
|
+
*
|
|
10
|
+
* app.use("/api", apiLimiter); // 100 req / 15 min
|
|
11
|
+
* app.use("/api/auth", authLimiter); // 10 req / 15 min
|
|
12
|
+
*
|
|
13
|
+
* // Custom:
|
|
14
|
+
* app.use("/api/special", createRateLimiter({ windowMs: 60_000, maxRequests: 5 }));
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Request, Response, NextFunction } from "express";
|
|
18
|
+
import { MemoryStore } from "./rate-limit-store.js";
|
|
19
|
+
import type { RateLimitStore } from "./rate-limit-store.js";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Types
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export interface RateLimitOptions {
|
|
26
|
+
/** Time window in milliseconds. Default: 15 minutes. */
|
|
27
|
+
windowMs?: number;
|
|
28
|
+
/** Maximum number of requests allowed in the window. Default: 100. */
|
|
29
|
+
maxRequests?: number;
|
|
30
|
+
/** Extract the key used to identify the client. Defaults to IP, or userId when authenticated. */
|
|
31
|
+
keyGenerator?: (req: Request) => string;
|
|
32
|
+
/** Store implementation. Defaults to `MemoryStore`. */
|
|
33
|
+
store?: RateLimitStore;
|
|
34
|
+
/** Custom message returned when the limit is exceeded. */
|
|
35
|
+
message?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Defaults from environment (optional overrides)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const DEFAULT_WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000;
|
|
43
|
+
const DEFAULT_MAX_REQUESTS = Number(process.env.RATE_LIMIT_MAX_REQUESTS) || 100;
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Shared store — one MemoryStore instance for the entire process
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
const sharedStore = new MemoryStore();
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Key generator
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
function defaultKeyGenerator(req: Request): string {
|
|
56
|
+
// Prefer the authenticated user id when available so that rate limits are
|
|
57
|
+
// per-user rather than per-IP for logged-in users.
|
|
58
|
+
const userId = (req as Record<string, unknown>).user
|
|
59
|
+
? ((req as Record<string, unknown>).user as { id?: string })?.id
|
|
60
|
+
: undefined;
|
|
61
|
+
|
|
62
|
+
if (userId) return `user:${userId}`;
|
|
63
|
+
|
|
64
|
+
// Fall back to IP address.
|
|
65
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
66
|
+
const ip =
|
|
67
|
+
typeof forwarded === "string"
|
|
68
|
+
? forwarded.split(",")[0].trim()
|
|
69
|
+
: req.socket.remoteAddress ?? "unknown";
|
|
70
|
+
|
|
71
|
+
return `ip:${ip}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Factory
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create a rate-limiting middleware with the given options.
|
|
80
|
+
*/
|
|
81
|
+
export function createRateLimiter(options: RateLimitOptions = {}) {
|
|
82
|
+
const {
|
|
83
|
+
windowMs = DEFAULT_WINDOW_MS,
|
|
84
|
+
maxRequests = DEFAULT_MAX_REQUESTS,
|
|
85
|
+
keyGenerator = defaultKeyGenerator,
|
|
86
|
+
store = sharedStore,
|
|
87
|
+
message = "Too many requests, please try again later.",
|
|
88
|
+
} = options;
|
|
89
|
+
|
|
90
|
+
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
91
|
+
const key = keyGenerator(req);
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const { count, resetAt } = await store.increment(key, windowMs);
|
|
95
|
+
|
|
96
|
+
// Always set informational headers.
|
|
97
|
+
res.setHeader("X-RateLimit-Limit", String(maxRequests));
|
|
98
|
+
res.setHeader("X-RateLimit-Remaining", String(Math.max(0, maxRequests - count)));
|
|
99
|
+
res.setHeader("X-RateLimit-Reset", String(Math.ceil(resetAt / 1000)));
|
|
100
|
+
|
|
101
|
+
if (count > maxRequests) {
|
|
102
|
+
const retryAfterSeconds = Math.ceil((resetAt - Date.now()) / 1000);
|
|
103
|
+
res.setHeader("Retry-After", String(retryAfterSeconds));
|
|
104
|
+
res.status(429).json({ error: message });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
next();
|
|
109
|
+
} catch (err) {
|
|
110
|
+
// If the store fails we let the request through rather than blocking.
|
|
111
|
+
console.error("[rate-limit] Store error:", err);
|
|
112
|
+
next();
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Preset limiters
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
/** General API limiter — 100 requests per 15 minutes. */
|
|
122
|
+
export const apiLimiter = createRateLimiter({
|
|
123
|
+
windowMs: DEFAULT_WINDOW_MS,
|
|
124
|
+
maxRequests: DEFAULT_MAX_REQUESTS,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/** Auth limiter — 10 requests per 15 minutes (login, signup). */
|
|
128
|
+
export const authLimiter = createRateLimiter({
|
|
129
|
+
windowMs: 15 * 60 * 1000,
|
|
130
|
+
maxRequests: 10,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
/** Strict limiter — 5 requests per 15 minutes (password reset, sensitive ops). */
|
|
134
|
+
export const strictLimiter = createRateLimiter({
|
|
135
|
+
windowMs: 15 * 60 * 1000,
|
|
136
|
+
maxRequests: 5,
|
|
137
|
+
});
|