From 2a35af22584e93b1075c9a62e3422199f0d45c3b Mon Sep 17 00:00:00 2001 From: akargi Date: Thu, 23 Apr 2026 22:59:22 +0100 Subject: [PATCH] feat: Implement comprehensive rate limiting system - Added per-user rate limiting (free/premium/enterprise tiers) - Added per-endpoint rate limiting (auth, user, property endpoints) - Added IP-based rate limiting for unauthenticated requests - Implemented RateLimitGuard with 429 Too Many Requests responses - Added rate limit headers (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After) - Created RateLimitService for all rate limit operations - Added rate limit decorators (@StrictRateLimit, @ModerateRateLimit, @LooseRateLimit, @NoRateLimit) - Implemented RateLimitAdminController for rate limit management - Added rate limit monitoring and metrics - Strict auth endpoints: 5 requests per 15 minutes - User/property creation: 10-20 requests per hour - Data retrieval: 100 requests per minute - Global IP limit: 1000 requests per 15 minutes - Integrated with Redis cache for distributed rate limiting - Added RateLimitHeadersInterceptor for response header management --- package-lock.json | 642 ++++++++++++++---- src/auth/auth.module.ts | 26 +- .../rate-limit-admin.controller.ts | 188 +++++ src/auth/decorators/rate-limit.decorator.ts | 73 ++ src/auth/examples/rate-limit.examples.ts | 211 ++++++ src/auth/guards/rate-limit.guard.ts | 159 +++++ .../rate-limit-headers.interceptor.ts | 38 ++ src/auth/modules/rate-limit.module.ts | 17 + src/auth/rate-limit.config.ts | 177 +++++ src/auth/rate-limit.service.ts | 209 ++++++ src/main.ts | 18 +- 11 files changed, 1609 insertions(+), 149 deletions(-) create mode 100644 src/auth/controllers/rate-limit-admin.controller.ts create mode 100644 src/auth/decorators/rate-limit.decorator.ts create mode 100644 src/auth/examples/rate-limit.examples.ts create mode 100644 src/auth/guards/rate-limit.guard.ts create mode 100644 src/auth/interceptors/rate-limit-headers.interceptor.ts create mode 100644 src/auth/modules/rate-limit.module.ts create mode 100644 src/auth/rate-limit.config.ts create mode 100644 src/auth/rate-limit.service.ts diff --git a/package-lock.json b/package-lock.json index 9a001786..ba89021b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -791,15 +791,6 @@ "keyv": "^5.6.0" } }, - "node_modules/@cacheable/utils/node_modules/keyv": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", - "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "license": "MIT", - "dependencies": { - "@keyv/serialize": "^1.1.1" - } - }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -2283,7 +2274,7 @@ "version": "6.19.2", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.2.tgz", "integrity": "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", @@ -2296,14 +2287,14 @@ "version": "6.19.2", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.2.tgz", "integrity": "sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "6.19.2", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.2.tgz", "integrity": "sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -2317,14 +2308,14 @@ "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "6.19.2", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.2.tgz", "integrity": "sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.2", @@ -2336,48 +2327,32 @@ "version": "6.19.2", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.2.tgz", "integrity": "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.2" } }, - "node_modules/@redis/bloom": { - "version": "5.12.1", - "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", - "integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==", - "license": "MIT", - "engines": { - "node": ">= 18.19.0" - }, - "peerDependencies": { - "@redis/client": "^5.12.1" - } - }, "node_modules/@redis/client": { - "version": "5.12.1", - "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", - "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", "dependencies": { - "cluster-key-slot": "1.1.2" + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" }, "engines": { - "node": ">= 18.19.0" - }, - "peerDependencies": { - "@node-rs/xxhash": "^1.1.0", - "@opentelemetry/api": ">=1 <2" - }, - "peerDependenciesMeta": { - "@node-rs/xxhash": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - } + "node": ">=14" } }, + "node_modules/@redis/client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/@redis/graph": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", @@ -2387,42 +2362,6 @@ "@redis/client": "^1.0.0" } }, - "node_modules/@redis/json": { - "version": "5.12.1", - "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz", - "integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==", - "license": "MIT", - "engines": { - "node": ">= 18.19.0" - }, - "peerDependencies": { - "@redis/client": "^5.12.1" - } - }, - "node_modules/@redis/search": { - "version": "5.12.1", - "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz", - "integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==", - "license": "MIT", - "engines": { - "node": ">= 18.19.0" - }, - "peerDependencies": { - "@redis/client": "^5.12.1" - } - }, - "node_modules/@redis/time-series": { - "version": "5.12.1", - "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz", - "integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==", - "license": "MIT", - "engines": { - "node": ">= 18.19.0" - }, - "peerDependencies": { - "@redis/client": "^5.12.1" - } - }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -2464,7 +2403,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tokenizer/inflate": { @@ -3250,6 +3189,37 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -3850,7 +3820,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.3", @@ -3879,7 +3849,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -3895,7 +3865,7 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -3905,7 +3875,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -3946,20 +3916,6 @@ "@redis/client": "^1.0.0" } }, - "node_modules/cache-manager-redis-store/node_modules/@redis/client": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", - "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", - "license": "MIT", - "dependencies": { - "cluster-key-slot": "1.1.2", - "generic-pool": "3.9.0", - "yallist": "4.0.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/cache-manager-redis-store/node_modules/@redis/json": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", @@ -4004,21 +3960,6 @@ "@redis/time-series": "1.1.0" } }, - "node_modules/cache-manager-redis-store/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, - "node_modules/cache-manager/node_modules/keyv": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", - "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "license": "MIT", - "dependencies": { - "@keyv/serialize": "^1.1.1" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -4183,7 +4124,7 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "consola": "^3.2.3" @@ -4193,7 +4134,7 @@ "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -4398,7 +4339,7 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/consola": { @@ -4407,6 +4348,20 @@ "integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==", "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", @@ -4432,6 +4387,16 @@ "node": ">= 0.6" } }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -4599,7 +4564,7 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -4640,7 +4605,7 @@ "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/depd": { @@ -4656,7 +4621,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/destroy": { @@ -4776,7 +4741,7 @@ "version": "3.18.4", "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -4814,7 +4779,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=14" @@ -5258,11 +5223,155 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/express/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "peer": true, + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/external-editor": { @@ -5284,7 +5393,7 @@ "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "dev": true, + "devOptional": true, "funding": [ { "type": "individual", @@ -5467,6 +5576,28 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -5532,6 +5663,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/flat-cache/node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/flat-cache/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -5676,6 +5817,16 @@ "node": ">= 0.6" } }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-monkey": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", @@ -5807,7 +5958,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -5825,7 +5976,7 @@ "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -6361,6 +6512,13 @@ "node": ">=8" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT", + "peer": true + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -7402,13 +7560,12 @@ } }, "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "@keyv/serialize": "^1.1.1" } }, "node_modules/kleur": { @@ -7647,6 +7804,19 @@ "node": ">= 4.0.0" } }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -7700,6 +7870,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", @@ -7816,6 +7996,16 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -7873,7 +8063,7 @@ "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/node-gyp-build": { @@ -7928,7 +8118,7 @@ "version": "0.6.5", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "citty": "^0.2.0", @@ -7946,7 +8136,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz", "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/object-assign": { @@ -7974,7 +8164,7 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/on-finished": { @@ -7993,7 +8183,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -8274,7 +8463,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/pause": { @@ -8286,7 +8475,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/pg": { @@ -8481,7 +8670,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "confbox": "^0.2.2", @@ -8609,7 +8798,7 @@ "version": "6.19.2", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz", "integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -8672,7 +8861,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "individual", @@ -8759,7 +8948,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "defu": "^6.1.4", @@ -8829,6 +9018,78 @@ "node": ">= 18.19.0" } }, + "node_modules/redis/node_modules/@redis/bloom": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", + "integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/redis/node_modules/@redis/client": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/redis/node_modules/@redis/json": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz", + "integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/redis/node_modules/@redis/search": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz", + "integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/redis/node_modules/@redis/time-series": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz", + "integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, "node_modules/reflect-metadata": { "version": "0.1.14", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", @@ -8939,6 +9200,34 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -9094,6 +9383,50 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -9104,6 +9437,26 @@ "randombytes": "^2.1.0" } }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -9761,7 +10114,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -10106,7 +10459,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -10491,7 +10844,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 34e828ca..48e53f22 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -6,14 +6,34 @@ import { EmailModule } from '../email/email.module'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { LoginRateLimitService } from './login-rate-limit.service'; +import { RateLimitService } from './rate-limit.service'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { ApiKeyAuthGuard } from './guards/api-key-auth.guard'; import { RolesGuard } from './guards/roles.guard'; +import { RateLimitGuard } from './guards/rate-limit.guard'; +import { RateLimitHeadersInterceptor } from './interceptors/rate-limit-headers.interceptor'; +import { RateLimitAdminController } from './controllers/rate-limit-admin.controller'; @Module({ imports: [PrismaModule, UsersModule, SessionsModule, EmailModule], - controllers: [AuthController], - providers: [AuthService, LoginRateLimitService, JwtAuthGuard, ApiKeyAuthGuard, RolesGuard], - exports: [AuthService, RolesGuard, LoginRateLimitService], + controllers: [AuthController, RateLimitAdminController], + providers: [ + AuthService, + LoginRateLimitService, + RateLimitService, + JwtAuthGuard, + ApiKeyAuthGuard, + RolesGuard, + RateLimitGuard, + RateLimitHeadersInterceptor, + ], + exports: [ + AuthService, + RolesGuard, + LoginRateLimitService, + RateLimitService, + RateLimitGuard, + RateLimitHeadersInterceptor, + ], }) export class AuthModule {} diff --git a/src/auth/controllers/rate-limit-admin.controller.ts b/src/auth/controllers/rate-limit-admin.controller.ts new file mode 100644 index 00000000..3d241cf1 --- /dev/null +++ b/src/auth/controllers/rate-limit-admin.controller.ts @@ -0,0 +1,188 @@ +import { + Controller, + Get, + Post, + Delete, + Param, + UseGuards, + HttpCode, + HttpStatus, + Body, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { RateLimitService, RateLimitStatus } from '../rate-limit.service'; +import { SkipRateLimit } from '../guards/rate-limit.guard'; + +@ApiTags('Admin - Rate Limiting') +@Controller('admin/rate-limits') +@ApiBearerAuth('JWT') +export class RateLimitAdminController { + constructor(private rateLimitService: RateLimitService) {} + + @Get('user/:userId') + @ApiOperation({ + summary: 'Get rate limit status for a user', + description: 'Retrieve current rate limit status and remaining requests for a user', + }) + @ApiResponse({ + status: 200, + description: 'Rate limit status retrieved successfully', + schema: { + example: { + user: { + limit: 5000, + remaining: 4999, + reset: 1703088000, + isExceeded: false, + }, + reset: '2024-12-21T00:00:00Z', + }, + }, + }) + async getUserRateLimitStatus( + @Param('userId') userId: string, + ): Promise { + return this.rateLimitService.getUserRateLimitStats(userId); + } + + @Get('endpoint/:endpoint') + @SkipRateLimit() + @ApiOperation({ + summary: 'Get rate limit status for an endpoint', + description: 'Retrieve current rate limit status for a specific endpoint', + }) + @ApiResponse({ + status: 200, + description: 'Endpoint rate limit status retrieved successfully', + }) + async getEndpointRateLimitStatus( + @Param('endpoint') endpoint: string, + ): Promise { + return this.rateLimitService.checkEndpointRateLimit(endpoint); + } + + @Delete('user/:userId/reset') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Reset rate limit for a user', + description: 'Reset rate limit counter for a specific user', + }) + @ApiResponse({ + status: 204, + description: 'Rate limit reset successfully', + }) + async resetUserRateLimit(@Param('userId') userId: string): Promise { + return this.rateLimitService.resetUserRateLimit(userId); + } + + @Delete('ip/:ip/reset') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Reset rate limit for an IP', + description: 'Reset rate limit counter for a specific IP address', + }) + @ApiResponse({ + status: 204, + description: 'IP rate limit reset successfully', + }) + async resetIpRateLimit(@Param('ip') ip: string): Promise { + return this.rateLimitService.resetIpRateLimit(ip); + } + + @Delete('endpoint/:endpoint/reset') + @HttpCode(HttpStatus.NO_CONTENT) + @SkipRateLimit() + @ApiOperation({ + summary: 'Reset rate limit for an endpoint', + description: 'Reset rate limit counter for a specific endpoint', + }) + @ApiResponse({ + status: 204, + description: 'Endpoint rate limit reset successfully', + }) + async resetEndpointRateLimit( + @Param('endpoint') endpoint: string, + ): Promise { + return this.rateLimitService.resetEndpointRateLimit(endpoint); + } + + @Delete('api-key/:apiKey/reset') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Reset rate limit for an API key', + description: 'Reset rate limit counter for a specific API key', + }) + @ApiResponse({ + status: 204, + description: 'API key rate limit reset successfully', + }) + async resetApiKeyRateLimit(@Param('apiKey') apiKey: string): Promise { + return this.rateLimitService.resetApiKeyRateLimit(apiKey); + } + + @Get('summary') + @SkipRateLimit() + @ApiOperation({ + summary: 'Get rate limiting summary', + description: + 'Retrieve information about all configured rate limits and their current status', + }) + @ApiResponse({ + status: 200, + description: 'Rate limit summary retrieved successfully', + schema: { + example: { + globalLimit: 1000, + globalWindow: '15 minutes', + userTiers: { + free: '100 req/hour', + premium: '5000 req/hour', + enterprise: 'unlimited', + }, + endpointLimits: { + 'POST /auth/login': '5 req/15 minutes', + 'POST /auth/register': '5 req/hour', + 'GET /properties': '100 req/minute', + }, + }, + }, + }) + async getRateLimitSummary(): Promise { + return { + globalLimit: 1000, + globalWindow: '15 minutes', + globalWindowMs: 15 * 60 * 1000, + userTiers: { + free: { + hourlyLimit: 100, + monthlyLimit: 10000, + }, + premium: { + hourlyLimit: 5000, + monthlyLimit: 500000, + }, + enterprise: { + hourlyLimit: 50000, + monthlyLimit: 'unlimited', + }, + apiKey: { + hourlyLimit: 10000, + monthlyLimit: 1000000, + }, + }, + strictEndpoints: { + 'POST /auth/register': '5 requests per 1 hour', + 'POST /auth/login': '5 requests per 15 minutes', + 'POST /auth/request-password-reset': '3 requests per 1 hour', + }, + moderateEndpoints: { + 'POST /users': '10 requests per hour', + 'POST /properties': '20 requests per hour', + 'GET /users': '100 requests per minute', + 'GET /properties': '100 requests per minute', + }, + description: + 'Authentication and sensitive endpoints have strict limits. Standard endpoints have moderate limits. Unauthenticated requests are limited by IP.', + }; + } +} diff --git a/src/auth/decorators/rate-limit.decorator.ts b/src/auth/decorators/rate-limit.decorator.ts new file mode 100644 index 00000000..2ccb3eff --- /dev/null +++ b/src/auth/decorators/rate-limit.decorator.ts @@ -0,0 +1,73 @@ +import { applyDecorators, UseGuards } from '@nestjs/common'; +import { RateLimitGuard, SkipRateLimit, CustomRateLimit } from '../guards/rate-limit.guard'; + +/** + * Decorator to apply rate limiting to a route + * Usage: @RateLimited() on controller methods + */ +export function RateLimited(options?: { + windowMs?: number; + max?: number; + by?: 'user' | 'ip' | 'apiKey'; +}) { + if (options) { + return applyDecorators( + UseGuards(RateLimitGuard), + CustomRateLimit(options), + ); + } + return UseGuards(RateLimitGuard); +} + +/** + * Decorator to disable rate limiting for a route + * Usage: @NoRateLimit() on controller methods + */ +export function NoRateLimit() { + return SkipRateLimit(); +} + +/** + * Decorator to enable strict rate limiting + * Usage: @StrictRateLimit() on controller methods (auth endpoints) + */ +export function StrictRateLimit() { + return applyDecorators( + UseGuards(RateLimitGuard), + CustomRateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, // 5 requests + by: 'user', + }), + ); +} + +/** + * Decorator to enable moderate rate limiting + * Usage: @ModerateRateLimit() on controller methods + */ +export function ModerateRateLimit() { + return applyDecorators( + UseGuards(RateLimitGuard), + CustomRateLimit({ + windowMs: 60 * 1000, // 1 minute + max: 100, // 100 requests + by: 'user', + }), + ); +} + +/** + * Decorator to enable loose rate limiting + * Usage: @LooseRateLimit() on controller methods + */ +export function LooseRateLimit() { + return applyDecorators( + UseGuards(RateLimitGuard), + CustomRateLimit({ + windowMs: 60 * 1000, // 1 minute + max: 300, // 300 requests + by: 'user', + }), + ); +} diff --git a/src/auth/examples/rate-limit.examples.ts b/src/auth/examples/rate-limit.examples.ts new file mode 100644 index 00000000..7286a5e1 --- /dev/null +++ b/src/auth/examples/rate-limit.examples.ts @@ -0,0 +1,211 @@ +/** + * Rate Limiting Usage Examples + * + * This file demonstrates how to use the rate limiting features + * in the PropChain API + */ + +import { + Controller, + Get, + Post, + Body, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { + RateLimited, + StrictRateLimit, + ModerateRateLimit, + LooseRateLimit, + NoRateLimit, +} from '../decorators/rate-limit.decorator'; + +@ApiTags('Examples - Rate Limiting') +@Controller('examples') +export class RateLimitExamplesController { + /** + * Example 1: Strict rate limiting (authentication) + * - 5 requests per 15 minutes per user + * - Perfect for login, register, password reset + */ + @Post('strict-auth') + @StrictRateLimit() + @ApiOperation({ + summary: 'Example: Strict Rate Limited Endpoint', + description: '5 requests per 15 minutes', + }) + exampleStrictRateLimit() { + return { + message: 'This endpoint has strict rate limiting (5 req / 15 min)', + example: 'POST /auth/login', + }; + } + + /** + * Example 2: Moderate rate limiting (standard operations) + * - 100 requests per 1 minute per user + * - Perfect for standard CRUD operations + */ + @Get('moderate-crud') + @ModerateRateLimit() + @ApiOperation({ + summary: 'Example: Moderate Rate Limited Endpoint', + description: '100 requests per minute', + }) + exampleModerateRateLimit() { + return { + message: 'This endpoint has moderate rate limiting (100 req / min)', + example: 'GET /properties, POST /users', + }; + } + + /** + * Example 3: Loose rate limiting (bulk operations) + * - 300 requests per 1 minute per user + * - Perfect for high-volume operations + */ + @Get('loose-bulk') + @LooseRateLimit() + @ApiOperation({ + summary: 'Example: Loose Rate Limited Endpoint', + description: '300 requests per minute', + }) + exampleLooseRateLimit() { + return { + message: 'This endpoint has loose rate limiting (300 req / min)', + example: 'Bulk operations, data exports', + }; + } + + /** + * Example 4: Default rate limiting (global) + * - Applied automatically to all routes + * - User-based or IP-based depending on authentication + */ + @Get('default-limit') + @RateLimited() + @ApiOperation({ + summary: 'Example: Default Rate Limited Endpoint', + description: 'Default global rate limiting applied', + }) + exampleDefaultRateLimit() { + return { + message: 'This endpoint uses default rate limiting', + limit: '1000 requests per 15 minutes (global)', + }; + } + + /** + * Example 5: Custom rate limiting + * - Define specific window and max requests + */ + @Get('custom-limit') + @RateLimited({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 50, // 50 requests + by: 'user', + }) + @ApiOperation({ + summary: 'Example: Custom Rate Limited Endpoint', + description: '50 requests per hour', + }) + exampleCustomRateLimit() { + return { + message: 'This endpoint has custom rate limiting', + limit: '50 requests per 1 hour', + }; + } + + /** + * Example 6: No rate limiting (admin operations) + * - Exempt from rate limiting + * - Use with caution, only for admin operations + */ + @Get('no-limit') + @NoRateLimit() + @ApiOperation({ + summary: 'Example: No Rate Limiting', + description: 'This endpoint is exempt from rate limiting', + }) + exampleNoRateLimit() { + return { + message: 'This endpoint has no rate limiting', + warning: 'Use @NoRateLimit() only for admin operations', + }; + } + + /** + * Example 7: Rate limiting with JWT guard + * - Combine with authentication guards + */ + @Post('protected-limited') + @UseGuards(JwtAuthGuard) + @ModerateRateLimit() + @ApiOperation({ + summary: 'Example: Protected and Rate Limited Endpoint', + description: 'Requires JWT token, 100 requests per minute', + }) + exampleProtectedAndRateLimited() { + return { + message: 'This endpoint requires JWT and has rate limiting', + }; + } +} + +/** + * RATE LIMIT HEADERS IN RESPONSES + * + * All rate-limited endpoints include these headers: + * + * X-RateLimit-Limit: 100 // Max requests allowed in window + * X-RateLimit-Remaining: 99 // Requests remaining in window + * X-RateLimit-Reset: 1703088000 // Unix timestamp when limit resets + * + * On rate limit exceeded (429 response): + * Retry-After: 45 // Seconds to wait before retrying + */ + +/** + * RATE LIMIT STATUSES + * + * 1. User-based rate limiting (authenticated requests) + * - Free tier: 100 req/hour, 10k req/month + * - Premium tier: 5000 req/hour, 500k req/month + * - Enterprise tier: 50k req/hour, unlimited/month + * - API Key: 10k req/hour, 1M req/month + * + * 2. IP-based rate limiting (unauthenticated requests) + * - 1000 requests per 15 minutes per IP + * + * 3. Endpoint-specific rate limiting + * - Authentication: 5 req/15 min + * - User creation: 10 req/hour + * - Property creation: 20 req/hour + * - Data retrieval: 100 req/minute + */ + +/** + * ERROR RESPONSE (429 Too Many Requests) + * + * { + * "statusCode": 429, + * "message": "Rate limit exceeded. Max 100 requests per 15 minutes.", + * "retryAfter": 45, + * "timestamp": "2024-12-21T12:00:00Z", + * "path": "/api/v2/users" + * } + */ + +/** + * ADMIN ENDPOINTS FOR RATE LIMIT MANAGEMENT + * + * GET /admin/rate-limits/user/:userId - Get user rate limit status + * GET /admin/rate-limits/endpoint/:endpoint - Get endpoint rate limit status + * GET /admin/rate-limits/summary - Get all rate limits summary + * DELETE /admin/rate-limits/user/:userId/reset + * DELETE /admin/rate-limits/ip/:ip/reset + * DELETE /admin/rate-limits/endpoint/:endpoint/reset + * DELETE /admin/rate-limits/api-key/:apiKey/reset + */ diff --git a/src/auth/guards/rate-limit.guard.ts b/src/auth/guards/rate-limit.guard.ts new file mode 100644 index 00000000..5e0501b9 --- /dev/null +++ b/src/auth/guards/rate-limit.guard.ts @@ -0,0 +1,159 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + HttpException, + HttpStatus, + Inject, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { RateLimitService } from '../rate-limit.service'; +import { RATE_LIMIT_HEADERS } from '../rate-limit.config'; + +export const RATE_LIMIT_SKIP_KEY = 'rate-limit-skip'; +export const RATE_LIMIT_CUSTOM_KEY = 'rate-limit-custom'; + +/** + * Decorator to skip rate limiting for a route + */ +export const SkipRateLimit = () => + Reflect.metadata(RATE_LIMIT_SKIP_KEY, true); + +/** + * Decorator to apply custom rate limiting + */ +export const CustomRateLimit = (options: { + windowMs?: number; + max?: number; + by?: 'user' | 'ip' | 'apiKey'; +}) => Reflect.metadata(RATE_LIMIT_CUSTOM_KEY, options); + +@Injectable() +export class RateLimitGuard implements CanActivate { + constructor( + private reflector: Reflector, + @Inject(RateLimitService) private rateLimitService: RateLimitService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + // Check if rate limiting is skipped for this route + const skip = this.reflector.getAllAndOverride( + RATE_LIMIT_SKIP_KEY, + [context.getHandler(), context.getClass()], + ); + + if (skip) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); + const endpoint = `${request.method} ${request.route?.path || request.url}`; + + try { + // Check by user if authenticated + if (request.user?.id) { + const userTier = request.user.tier || 'free'; + const userStatus = await this.rateLimitService.checkUserRateLimit( + request.user.id, + userTier, + ); + + // Apply rate limit headers + Object.entries(this.rateLimitService.getHeaders(userStatus)).forEach( + ([key, value]) => { + response.setHeader(key, value); + }, + ); + + if (userStatus.isExceeded) { + throw new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + message: `Rate limit exceeded. Max ${userStatus.limit} requests per 15 minutes.`, + retryAfter: userStatus.retryAfter, + }, + HttpStatus.TOO_MANY_REQUESTS, + { + cause: 'user_rate_limit_exceeded', + }, + ); + } + } else { + // Check by IP for unauthenticated requests + const ip = this.getClientIp(request); + const ipStatus = await this.rateLimitService.checkIpRateLimit(ip); + + // Apply rate limit headers + Object.entries(this.rateLimitService.getHeaders(ipStatus)).forEach( + ([key, value]) => { + response.setHeader(key, value); + }, + ); + + if (ipStatus.isExceeded) { + throw new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + message: 'Too many requests from your IP. Please try again later.', + retryAfter: ipStatus.retryAfter, + }, + HttpStatus.TOO_MANY_REQUESTS, + { + cause: 'ip_rate_limit_exceeded', + }, + ); + } + } + + // Check endpoint-specific limits + const endpointStatus = await this.rateLimitService.checkEndpointRateLimit( + endpoint, + ); + + if (endpointStatus.limit > 0) { + Object.entries(this.rateLimitService.getHeaders(endpointStatus)).forEach( + ([key, value]) => { + response.setHeader(key, value); + }, + ); + + if (endpointStatus.isExceeded) { + throw new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + message: `Too many requests to this endpoint. Please try again later.`, + retryAfter: endpointStatus.retryAfter, + }, + HttpStatus.TOO_MANY_REQUESTS, + { + cause: 'endpoint_rate_limit_exceeded', + }, + ); + } + } + + return true; + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + // If rate limit check fails, allow the request + console.error('Rate limit check error:', error); + return true; + } + } + + /** + * Extract client IP from request + */ + private getClientIp(request: any): string { + return ( + request.headers['x-forwarded-for']?.split(',')[0].trim() || + request.connection?.remoteAddress || + request.socket?.remoteAddress || + request.ip || + 'unknown' + ); + } +} diff --git a/src/auth/interceptors/rate-limit-headers.interceptor.ts b/src/auth/interceptors/rate-limit-headers.interceptor.ts new file mode 100644 index 00000000..37ab3ca1 --- /dev/null +++ b/src/auth/interceptors/rate-limit-headers.interceptor.ts @@ -0,0 +1,38 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { RATE_LIMIT_HEADERS } from '../rate-limit.config'; + +/** + * Interceptor to add rate limit headers to response + * Provides visibility into rate limit status + */ +@Injectable() +export class RateLimitHeadersInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); + + return next.handle().pipe( + tap(() => { + // Headers should already be set by the guard + // This interceptor ensures they persist through the response + const existingLimit = response.getHeader(RATE_LIMIT_HEADERS.LIMIT); + if (!existingLimit) { + // If no limit header was set, set defaults + response.setHeader(RATE_LIMIT_HEADERS.LIMIT, '1000'); + response.setHeader(RATE_LIMIT_HEADERS.REMAINING, '999'); + response.setHeader( + RATE_LIMIT_HEADERS.RESET, + Math.floor((Date.now() + 15 * 60 * 1000) / 1000), + ); + } + }), + ); + } +} diff --git a/src/auth/modules/rate-limit.module.ts b/src/auth/modules/rate-limit.module.ts new file mode 100644 index 00000000..c054eaf4 --- /dev/null +++ b/src/auth/modules/rate-limit.module.ts @@ -0,0 +1,17 @@ +import { Global, Module } from '@nestjs/common'; +import { RateLimitService } from '../rate-limit.service'; +import { RateLimitGuard } from '../guards/rate-limit.guard'; +import { RateLimitAdminController } from '../controllers/rate-limit-admin.controller'; +import { RateLimitHeadersInterceptor } from '../interceptors/rate-limit-headers.interceptor'; + +@Global() +@Module({ + providers: [RateLimitService, RateLimitGuard, RateLimitHeadersInterceptor], + controllers: [RateLimitAdminController], + exports: [ + RateLimitService, + RateLimitGuard, + RateLimitHeadersInterceptor, + ], +}) +export class RateLimitModule {} diff --git a/src/auth/rate-limit.config.ts b/src/auth/rate-limit.config.ts new file mode 100644 index 00000000..1483dc3a --- /dev/null +++ b/src/auth/rate-limit.config.ts @@ -0,0 +1,177 @@ +/** + * Rate Limiting Configuration + * Defines rate limiting strategies and constants + */ + +export interface RateLimitConfig { + windowMs: number; // Time window in milliseconds + max: number; // Maximum requests per window + message?: string; + statusCode?: number; +} + +export interface RateLimitOptions { + windowMs: number; + max: number; + keyGenerator?: (req: any) => string; + skip?: (req: any) => boolean; + handler?: (req: any, res: any) => void; +} + +/** + * Global rate limiting configuration + */ +export const RATE_LIMIT_CONFIG: RateLimitConfig = { + windowMs: 15 * 60 * 1000, // 15 minutes + max: 1000, // 1000 requests per 15 minutes + statusCode: 429, + message: 'Too many requests from this IP, please try again later.', +}; + +/** + * Per-endpoint rate limiting configurations + */ +export const ENDPOINT_RATE_LIMITS: Record = { + // Authentication endpoints (strict) + 'POST /auth/register': { + windowMs: 60 * 60 * 1000, // 1 hour + max: 5, // 5 requests per hour + }, + 'POST /auth/login': { + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, // 5 attempts per 15 minutes + }, + 'POST /auth/refresh': { + windowMs: 60 * 60 * 1000, // 1 hour + max: 20, // 20 refreshes per hour + }, + 'POST /auth/request-password-reset': { + windowMs: 60 * 60 * 1000, // 1 hour + max: 3, // 3 requests per hour + }, + + // Email verification (moderate) + 'POST /email-verification/send': { + windowMs: 60 * 60 * 1000, // 1 hour + max: 5, // 5 verification emails per hour + }, + 'POST /email-verification/verify': { + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, // 10 verification attempts + }, + + // User endpoints (moderate) + 'GET /users': { + windowMs: 60 * 1000, // 1 minute + max: 100, // 100 requests per minute + }, + 'POST /users': { + windowMs: 60 * 60 * 1000, // 1 hour + max: 10, // 10 user creations per hour + }, + + // Property endpoints (moderate) + 'GET /properties': { + windowMs: 60 * 1000, // 1 minute + max: 100, // 100 requests per minute + }, + 'POST /properties': { + windowMs: 60 * 60 * 1000, // 1 hour + max: 20, // 20 property creations per hour + }, + + // Dashboard (loose) + 'GET /dashboard': { + windowMs: 60 * 1000, // 1 minute + max: 100, // 100 requests per minute + }, + + // API Key endpoints + 'POST /auth/api-keys': { + windowMs: 60 * 60 * 1000, // 1 hour + max: 10, // 10 key creations per hour + }, +}; + +/** + * Per-user rate limiting configurations + */ +export const USER_TIER_RATE_LIMITS = { + // Free tier + free: { + windowMs: 60 * 60 * 1000, // 1 hour + max: 100, // 100 requests per hour + monthlyLimit: 10000, // 10k requests per month + }, + + // Premium tier + premium: { + windowMs: 60 * 60 * 1000, // 1 hour + max: 5000, // 5000 requests per hour + monthlyLimit: 500000, // 500k requests per month + }, + + // Enterprise tier + enterprise: { + windowMs: 60 * 60 * 1000, // 1 hour + max: 50000, // 50000 requests per hour + monthlyLimit: Infinity, // Unlimited + }, + + // API Key special tier + apiKey: { + windowMs: 60 * 60 * 1000, // 1 hour + max: 10000, // 10000 requests per hour + monthlyLimit: 1000000, // 1M requests per month + }, +}; + +/** + * Rate limit header names + */ +export const RATE_LIMIT_HEADERS = { + LIMIT: 'X-RateLimit-Limit', + REMAINING: 'X-RateLimit-Remaining', + RESET: 'X-RateLimit-Reset', + RETRY_AFTER: 'Retry-After', +}; + +/** + * Rate limit key prefixes for Redis + */ +export const RATE_LIMIT_KEYS = { + GLOBAL: 'rate-limit:global', + ENDPOINT: (endpoint: string) => `rate-limit:endpoint:${endpoint}`, + USER: (userId: string) => `rate-limit:user:${userId}`, + IP: (ip: string) => `rate-limit:ip:${ip}`, + API_KEY: (apiKey: string) => `rate-limit:api-key:${apiKey}`, +}; + +/** + * Get rate limit config for user tier + */ +export function getUserTierRateLimit( + tier: 'free' | 'premium' | 'enterprise' | 'apiKey' = 'free', +): RateLimitConfig { + const tierConfig = USER_TIER_RATE_LIMITS[tier]; + return { + windowMs: tierConfig.windowMs, + max: tierConfig.max, + statusCode: 429, + message: `Rate limit exceeded for ${tier} tier. Max ${tierConfig.max} requests per ${tierConfig.windowMs / 1000} seconds.`, + }; +} + +/** + * Get rate limit config for endpoint + */ +export function getEndpointRateLimit(endpoint: string): RateLimitConfig | null { + const config = ENDPOINT_RATE_LIMITS[endpoint]; + if (!config) return null; + + return { + ...config, + statusCode: 429, + message: `Too many requests to ${endpoint}. Please try again later.`, + }; +} diff --git a/src/auth/rate-limit.service.ts b/src/auth/rate-limit.service.ts new file mode 100644 index 00000000..f99993c8 --- /dev/null +++ b/src/auth/rate-limit.service.ts @@ -0,0 +1,209 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Cache } from 'cache-manager'; +import { + RATE_LIMIT_KEYS, + RATE_LIMIT_HEADERS, + getEndpointRateLimit, + getUserTierRateLimit, +} from './rate-limit.config'; + +export interface RateLimitStatus { + limit: number; + remaining: number; + reset: number; + retryAfter?: number; + isExceeded: boolean; +} + +export interface RateLimitRecord { + count: number; + resetAt: number; + windowMs: number; +} + +@Injectable() +export class RateLimitService { + constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {} + + /** + * Check rate limit for a user + */ + async checkUserRateLimit( + userId: string, + userTier: 'free' | 'premium' | 'enterprise' | 'apiKey' = 'free', + ): Promise { + const key = RATE_LIMIT_KEYS.USER(userId); + const config = getUserTierRateLimit(userTier); + + return this.checkRateLimit(key, config.max, config.windowMs); + } + + /** + * Check rate limit for an IP + */ + async checkIpRateLimit(ip: string): Promise { + const key = RATE_LIMIT_KEYS.IP(ip); + const limit = 1000; // Global IP limit + const windowMs = 15 * 60 * 1000; // 15 minutes + + return this.checkRateLimit(key, limit, windowMs); + } + + /** + * Check rate limit for an endpoint + */ + async checkEndpointRateLimit(endpoint: string): Promise { + const config = getEndpointRateLimit(endpoint); + if (!config) { + // No specific endpoint limit + return { + limit: 0, + remaining: 0, + reset: 0, + isExceeded: false, + }; + } + + const key = RATE_LIMIT_KEYS.ENDPOINT(endpoint); + return this.checkRateLimit(key, config.max, config.windowMs); + } + + /** + * Check rate limit for an API key + */ + async checkApiKeyRateLimit(apiKey: string): Promise { + const key = RATE_LIMIT_KEYS.API_KEY(apiKey); + const config = getUserTierRateLimit('apiKey'); + + return this.checkRateLimit(key, config.max, config.windowMs); + } + + /** + * Generic rate limit check + */ + private async checkRateLimit( + key: string, + limit: number, + windowMs: number, + ): Promise { + try { + const record = await this.cacheManager.get(key); + const now = Date.now(); + + let count = 1; + let resetAt = now + windowMs; + + if (record && record.resetAt > now) { + // Window still active + count = record.count + 1; + resetAt = record.resetAt; + } else { + // New window + resetAt = now + windowMs; + } + + const isExceeded = count > limit; + + // Store the updated record + if (!isExceeded) { + await this.cacheManager.set( + key, + { count, resetAt, windowMs }, + resetAt - now, + ); + } else { + // Still store the count for tracking + await this.cacheManager.set( + key, + { count, resetAt, windowMs }, + resetAt - now, + ); + } + + const remaining = Math.max(0, limit - count); + const retryAfter = isExceeded ? Math.ceil((resetAt - now) / 1000) : undefined; + + return { + limit, + remaining, + reset: Math.floor(resetAt / 1000), // Unix timestamp in seconds + retryAfter, + isExceeded, + }; + } catch (error) { + // If cache is unavailable, allow the request + console.error('Rate limit check failed:', error); + return { + limit, + remaining: limit - 1, + reset: Math.floor((Date.now() + windowMs) / 1000), + isExceeded: false, + }; + } + } + + /** + * Reset rate limit for a user + */ + async resetUserRateLimit(userId: string): Promise { + const key = RATE_LIMIT_KEYS.USER(userId); + await this.cacheManager.del(key); + } + + /** + * Reset rate limit for an IP + */ + async resetIpRateLimit(ip: string): Promise { + const key = RATE_LIMIT_KEYS.IP(ip); + await this.cacheManager.del(key); + } + + /** + * Reset rate limit for an endpoint + */ + async resetEndpointRateLimit(endpoint: string): Promise { + const key = RATE_LIMIT_KEYS.ENDPOINT(endpoint); + await this.cacheManager.del(key); + } + + /** + * Reset rate limit for an API key + */ + async resetApiKeyRateLimit(apiKey: string): Promise { + const key = RATE_LIMIT_KEYS.API_KEY(apiKey); + await this.cacheManager.del(key); + } + + /** + * Get rate limit status with headers + */ + getHeaders(status: RateLimitStatus): Record { + const headers: Record = { + [RATE_LIMIT_HEADERS.LIMIT]: status.limit.toString(), + [RATE_LIMIT_HEADERS.REMAINING]: status.remaining.toString(), + [RATE_LIMIT_HEADERS.RESET]: status.reset.toString(), + }; + + if (status.retryAfter) { + headers[RATE_LIMIT_HEADERS.RETRY_AFTER] = status.retryAfter.toString(); + } + + return headers; + } + + /** + * Get all rate limit stats for a user + */ + async getUserRateLimitStats(userId: string): Promise<{ + ip?: RateLimitStatus; + user?: RateLimitStatus; + reset?: Date; + }> { + const userLimit = await this.checkUserRateLimit(userId); + return { + user: userLimit, + reset: new Date(userLimit.reset * 1000), + }; + } +} diff --git a/src/main.ts b/src/main.ts index 7ea1f59f..caade3fd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,14 @@ import { NestFactory } from '@nestjs/core'; import { Logger, ValidationPipe } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; import { AppModule } from './app.module'; import { VersionHeaderInterceptor } from './versioning/version-header.interceptor'; import { DeprecationWarningInterceptor } from './versioning/deprecation-warning.interceptor'; import { CacheMetricsInterceptor } from './cache/cache-metrics.interceptor'; import { CacheMonitoringService } from './cache/cache-monitoring.service'; +import { RateLimitGuard } from './auth/guards/rate-limit.guard'; +import { RateLimitService } from './auth/rate-limit.service'; +import { RateLimitHeadersInterceptor } from './auth/interceptors/rate-limit-headers.interceptor'; import { setupSwagger } from './config/swagger.config'; async function bootstrap() { @@ -26,11 +30,21 @@ async function bootstrap() { // Global prefix app.setGlobalPrefix('api'); + // Get services for guard initialization + const reflector = app.get(Reflector); + const rateLimitService = app.get(RateLimitService); + + // Apply global guards + app.useGlobalGuards(new RateLimitGuard(reflector, rateLimitService)); + // Apply version header interceptor globally app.useGlobalInterceptors(new VersionHeaderInterceptor()); // Apply deprecation warning interceptor - app.useGlobalInterceptors(new DeprecationWarningInterceptor(app.get('Reflector'))); + app.useGlobalInterceptors(new DeprecationWarningInterceptor(reflector)); + + // Apply rate limit headers interceptor + app.useGlobalInterceptors(new RateLimitHeadersInterceptor()); // Apply cache metrics interceptor const cacheMonitoringService = app.get(CacheMonitoringService); @@ -46,5 +60,7 @@ async function bootstrap() { logger.log(`📚 Swagger UI available at http://localhost:${port}/api/docs`); logger.log(`📋 OpenAPI spec available at http://localhost:${port}/api/openapi.json`); logger.log(`💾 Redis Caching enabled`); + logger.log(`🛡️ Rate Limiting enabled (per-user, per-endpoint, IP-based)`); } + bootstrap();