From 46c3790d8243bbaf13a59338bca54d068f2dc087 Mon Sep 17 00:00:00 2001 From: abdoolyaro Date: Tue, 28 Jul 2026 10:12:12 +0100 Subject: [PATCH] feat: comprehensive API rate limiting with tiers and Redis persistence (#894) --- package-lock.json | 368 +++--------------- package.json | 8 +- .../migration.sql | 3 + prisma/schema.prisma | 7 + src/app.module.ts | 4 + src/auth/auth-tier.spec.ts | 133 +++++++ src/auth/auth.module.ts | 2 + src/auth/auth.service.ts | 8 +- src/auth/graphql-complexity.plugin.spec.ts | 93 +++++ src/auth/graphql-complexity.plugin.ts | 90 +++++ src/auth/guards/rate-limit.guard.ts | 17 +- src/auth/rate-limit.config.ts | 15 + src/auth/rate-limit.guard.spec.ts | 78 ++++ src/auth/rbac.spec.ts | 12 +- src/auth/types/auth-user.type.ts | 3 +- src/cache/cache.config.ts | 21 +- src/commissions/commissions.service.spec.ts | 4 +- .../documents-download.controller.spec.ts | 54 ++- src/documents/documents.controller.spec.ts | 10 +- src/main.ts | 69 +++- .../dto/mortgage-calculator.dto.ts | 13 +- src/open-house/dto/tour-request.dto.ts | 2 +- .../properties.service.agent.spec.ts | 14 +- src/types/prisma.types.ts | 6 + 24 files changed, 656 insertions(+), 378 deletions(-) create mode 100644 prisma/migrations/20260726120000_add_user_tier/migration.sql create mode 100644 src/auth/auth-tier.spec.ts create mode 100644 src/auth/graphql-complexity.plugin.spec.ts create mode 100644 src/auth/graphql-complexity.plugin.ts diff --git a/package-lock.json b/package-lock.json index dea5c029..f5b1e3e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@apollo/server": "^5.5.1", + "@keyv/redis": "^5.1.6", "@nestjs-modules/mailer": "^2.3.4", "@nestjs/apollo": "^13.4.2", "@nestjs/bullmq": "^11.0.4", @@ -31,7 +32,6 @@ "bcrypt": "^6.0.0", "bullmq": "^5.76.3", "cache-manager": "^7.2.8", - "cache-manager-redis-store": "^3.0.1", "canvas": "^3.2.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", @@ -40,6 +40,7 @@ "ethers": "^6.16.0", "express": "^5.2.1", "graphql": "^16.13.2", + "graphql-query-complexity": "^2.0.0", "graphql-subscriptions": "^3.0.0", "ioredis": "^5.10.1", "jsonwebtoken": "^9.0.2", @@ -96,8 +97,8 @@ "typescript": "^5.3.2" }, "engines": { - "node": ">=20.0.0", - "npm": ">=8.0.0" + "node": ">=18.0.0", + "npm": ">=10.0.0" } }, "node_modules/@adraffy/ens-normalize": { @@ -206,40 +207,6 @@ } } }, - "node_modules/@angular-devkit/schematics-cli/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular-devkit/schematics-cli/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics-cli/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -278,40 +245,6 @@ } } }, - "node_modules/@angular-devkit/schematics/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular-devkit/schematics/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -3068,6 +3001,32 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/redis": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@keyv/redis/-/redis-5.1.6.tgz", + "integrity": "sha512-eKvW6pspvVaU5dxigaIDZr635/Uw6urTXL3gNbY9WTR8d3QigZQT+r8gxYSEOsw4+1cCBsC4s7T2ptR0WC9LfQ==", + "license": "MIT", + "dependencies": { + "@redis/client": "^5.10.0", + "cluster-key-slot": "^1.1.2", + "hookified": "^1.13.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/redis/node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@keyv/serialize": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", @@ -3985,40 +3944,6 @@ "yarn": ">= 1.13.0" } }, - "node_modules/@nestjs/schematics/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@nestjs/schematics/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nestjs/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -5868,7 +5793,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -5882,7 +5807,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -6394,20 +6319,6 @@ "node": ">= 18" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/bintrees": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", @@ -6781,109 +6692,6 @@ "keyv": "^5.6.0" } }, - "node_modules/cache-manager-redis-store": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/cache-manager-redis-store/-/cache-manager-redis-store-3.0.1.tgz", - "integrity": "sha512-o560kw+dFqusC9lQJhcm6L2F2fMKobJ5af+FoR2PdnMVdpQ3f3Bz6qzvObTGyvoazQJxjQNWgMQeChP4vRTuXQ==", - "license": "MIT", - "dependencies": { - "redis": "^4.3.1" - }, - "engines": { - "node": ">= 16.18.0" - } - }, - "node_modules/cache-manager-redis-store/node_modules/@redis/bloom": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", - "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", - "license": "MIT", - "peerDependencies": { - "@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/graph": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", - "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", - "license": "MIT", - "peerDependencies": { - "@redis/client": "^1.0.0" - } - }, - "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", - "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", - "license": "MIT", - "peerDependencies": { - "@redis/client": "^1.0.0" - } - }, - "node_modules/cache-manager-redis-store/node_modules/@redis/search": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", - "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", - "license": "MIT", - "peerDependencies": { - "@redis/client": "^1.0.0" - } - }, - "node_modules/cache-manager-redis-store/node_modules/@redis/time-series": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", - "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", - "license": "MIT", - "peerDependencies": { - "@redis/client": "^1.0.0" - } - }, - "node_modules/cache-manager-redis-store/node_modules/cluster-key-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", - "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/cache-manager-redis-store/node_modules/redis": { - "version": "4.7.1", - "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", - "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", - "license": "MIT", - "workspaces": [ - "./packages/*" - ], - "dependencies": { - "@redis/bloom": "1.2.0", - "@redis/client": "1.6.1", - "@redis/graph": "1.1.1", - "@redis/json": "1.0.7", - "@redis/search": "1.2.0", - "@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/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -7093,46 +6901,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -9858,6 +9626,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -9886,15 +9655,6 @@ "node": ">= 0.4" } }, - "node_modules/generic-pool": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", - "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -10156,6 +9916,18 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "node_modules/graphql-query-complexity": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/graphql-query-complexity/-/graphql-query-complexity-2.0.0.tgz", + "integrity": "sha512-gmdNp8Lq3KbJcWeX9mpIqpIxD2oDEVO5QgZtw7eiDB7Q1RYMlqDFQnGq6ZcaDclBNcwTnyOCHUtEr3qAjnUDVg==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2" + }, + "peerDependencies": { + "graphql": "^16.6.0 || ^17.0.0" + } + }, "node_modules/graphql-subscriptions": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-3.0.0.tgz", @@ -10713,20 +10485,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -12446,6 +12204,13 @@ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "license": "MIT" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -15934,34 +15699,6 @@ "node": ">=10" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/redis": { "version": "5.12.1", "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", @@ -18063,7 +17800,6 @@ "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, "license": "BSD-2-Clause", "optional": true, "bin": { diff --git a/package.json b/package.json index 9136e568..fca04295 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,14 @@ "private": true, "license": "MIT", "scripts": { - "prebuild": "rimraf dist", + "prebuild": "rimraf dist tsconfig.tsbuildinfo", "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "prestart": "rimraf dist tsconfig.tsbuildinfo", "start": "nest start", + "prestart:dev": "rimraf dist tsconfig.tsbuildinfo", "start:dev": "nest start --watch", + "prestart:debug": "rimraf dist tsconfig.tsbuildinfo", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", @@ -41,6 +44,7 @@ }, "dependencies": { "@apollo/server": "^5.5.1", + "@keyv/redis": "^5.1.6", "@nestjs-modules/mailer": "^2.3.4", "@nestjs/apollo": "^13.4.2", "@nestjs/bullmq": "^11.0.4", @@ -62,7 +66,6 @@ "bcrypt": "^6.0.0", "bullmq": "^5.76.3", "cache-manager": "^7.2.8", - "cache-manager-redis-store": "^3.0.1", "canvas": "^3.2.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", @@ -71,6 +74,7 @@ "ethers": "^6.16.0", "express": "^5.2.1", "graphql": "^16.13.2", + "graphql-query-complexity": "^2.0.0", "graphql-subscriptions": "^3.0.0", "ioredis": "^5.10.1", "jsonwebtoken": "^9.0.2", diff --git a/prisma/migrations/20260726120000_add_user_tier/migration.sql b/prisma/migrations/20260726120000_add_user_tier/migration.sql new file mode 100644 index 00000000..da82dd80 --- /dev/null +++ b/prisma/migrations/20260726120000_add_user_tier/migration.sql @@ -0,0 +1,3 @@ +CREATE TYPE "UserTier" AS ENUM ('FREE', 'PREMIUM', 'ENTERPRISE'); + +ALTER TABLE "users" ADD COLUMN "tier" "UserTier" NOT NULL DEFAULT 'FREE'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bcaa7562..36f91cdc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,6 +17,12 @@ enum UserRole { ADMIN } +enum UserTier { + FREE + PREMIUM + ENTERPRISE +} + enum TokenType { ACCESS REFRESH @@ -179,6 +185,7 @@ model User { twoFactorBackupCodes String[] @default([]) @map("two_factor_backup_codes") // NOTE: added to satisfy code references; CI-prone strict mode workaround role UserRole @default(USER) @map("role") + tier UserTier @default(FREE) @map("tier") avatar String? pendingEmail String? @map("pending_email") emailVerificationToken String? @map("email_verification_token") diff --git a/src/app.module.ts b/src/app.module.ts index 0cc73868..ef5ad911 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -55,6 +55,10 @@ import { PropertyTaxModule } from './properties/tax/property-tax.module'; subscriptions: { 'graphql-ws': true, }, + // Exposes the raw request on the Apollo context so plugins (e.g. the + // query-complexity plugin) and field-level auth guards can read/set + // request-scoped data such as request.authUser. + context: ({ req }) => ({ req }), }), ScheduleModule.forRoot(), CacheModuleConfig, diff --git a/src/auth/auth-tier.spec.ts b/src/auth/auth-tier.spec.ts new file mode 100644 index 00000000..751c9305 --- /dev/null +++ b/src/auth/auth-tier.spec.ts @@ -0,0 +1,133 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import * as jwt from 'jsonwebtoken'; +import { AuthService } from './auth.service'; +import { PrismaService } from '../database/prisma.service'; +import { UsersService } from '../users/users.service'; +import { SessionsService } from '../sessions/sessions.service'; +import { EmailService } from '../email/email.service'; +import { LoginRateLimitService } from './login-rate-limit.service'; +import { FraudService } from '../fraud/fraud.service'; + +describe('AuthService - tier propagation', () => { + let service: AuthService; + + const sessionsService = { + createSession: jest.fn().mockResolvedValue(undefined), + }; + + const prisma = { + user: { findUnique: jest.fn(), update: jest.fn().mockResolvedValue(undefined) }, + apiKey: { findUnique: jest.fn(), update: jest.fn().mockResolvedValue(undefined) }, + blacklistedToken: { findUnique: jest.fn().mockResolvedValue(null) }, + }; + + const configService = { + get: jest.fn((key: string) => { + const config: Record = { + JWT_SECRET: 'test-secret', + JWT_REFRESH_SECRET: 'test-refresh-secret', + JWT_ACCESS_EXPIRES_IN: '15m', + JWT_REFRESH_EXPIRES_IN: '7d', + BCRYPT_ROUNDS: '10', + }; + return config[key]; + }), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + prisma.user.update.mockResolvedValue(undefined); + prisma.apiKey.update.mockResolvedValue(undefined); + prisma.blacklistedToken.findUnique.mockResolvedValue(null); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: PrismaService, useValue: prisma }, + { provide: UsersService, useValue: {} }, + { provide: SessionsService, useValue: sessionsService }, + { provide: EmailService, useValue: {} }, + { provide: LoginRateLimitService, useValue: {} }, + { provide: FraudService, useValue: {} }, + { provide: ConfigService, useValue: configService }, + ], + }).compile(); + + service = module.get(AuthService); + }); + + describe('issueTokenPair', () => { + it('includes tier in both the access and refresh token claims', async () => { + const user = { + id: 'user-1', + email: 'user@test.com', + role: 'AGENT', + tier: 'PREMIUM', + } as any; + + const tokens = await service.issueTokenPair(user); + + const accessClaims = jwt.decode(tokens.accessToken) as any; + const refreshClaims = jwt.decode(tokens.refreshToken) as any; + + expect(accessClaims.tier).toBe('PREMIUM'); + expect(accessClaims.role).toBe('AGENT'); + expect(refreshClaims.tier).toBe('PREMIUM'); + }); + }); + + describe('validateAccessToken', () => { + it('returns the tier read fresh from the database, not from the token', async () => { + const token = jwt.sign( + { + sub: 'user-1', + email: 'user@test.com', + role: 'FREE', + tier: 'FREE', + type: 'access', + jti: 'jti-1', + }, + 'test-secret', + { expiresIn: '15m', issuer: 'PropChain' }, + ); + + // DB now reports a different (upgraded) tier than what was baked into the token + prisma.user.findUnique.mockResolvedValue({ + email: 'user@test.com', + role: 'USER', + tier: 'ENTERPRISE', + lastActivityAt: new Date(), + }); + + const result = await service.validateAccessToken(token); + + expect(result.tier).toBe('ENTERPRISE'); + expect(result.sub).toBe('user-1'); + }); + }); + + describe('validateApiKey', () => { + it('surfaces the owning user tier on the api-key payload', async () => { + prisma.apiKey.findUnique.mockResolvedValue({ + id: 'key-1', + userId: 'user-1', + revokedAt: null, + expiresAt: null, + permissions: ['read'], + user: { + email: 'user@test.com', + role: 'AGENT', + tier: 'ENTERPRISE', + isBlocked: false, + }, + }); + + const result = await service.validateApiKey('raw-api-key-value'); + + expect(result.tier).toBe('ENTERPRISE'); + expect(result.type).toBe('api-key'); + expect(result.apiKeyId).toBe('key-1'); + }); + }); +}); diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 2399ce9f..efa245a1 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -18,6 +18,7 @@ import { RateLimitGuard } from './guards/rate-limit.guard'; import { RateLimitHeadersInterceptor } from './interceptors/rate-limit-headers.interceptor'; import { RateLimitAdminController } from './controllers/rate-limit-admin.controller'; import { FraudModule } from '../fraud/fraud.module'; +import { GraphqlComplexityPlugin } from './graphql-complexity.plugin'; @Module({ imports: [PrismaModule, UsersModule, SessionsModule, EmailModule, FraudModule, PassportModule], @@ -32,6 +33,7 @@ import { FraudModule } from '../fraud/fraud.module'; RateLimitGuard, RateLimitHeadersInterceptor, GoogleStrategy, + GraphqlComplexityPlugin, ], exports: [ AuthService, diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 83708809..e9a40247 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -46,13 +46,14 @@ import { AuthUserPayload } from './types/auth-user.type'; import { GoogleProfile } from './strategies/google.strategy'; import { LoginRateLimitService } from './login-rate-limit.service'; -import { UserRole } from '../types/prisma.types'; +import { UserRole, UserTier } from '../types/prisma.types'; import { FraudService } from '../fraud/fraud.service'; type JwtPayload = { sub: string; email: string; role: UserRole; + tier: UserTier; type: 'access' | 'refresh'; jti: string; family?: string; @@ -1141,6 +1142,7 @@ export class AuthService { select: { email: true, role: true, + tier: true, lastActivityAt: true, }, }); @@ -1169,6 +1171,7 @@ export class AuthService { sub: payload.sub, email: user.email, role: user.role, + tier: user.tier, type: 'access', jti: payload.jti, }; @@ -1206,6 +1209,7 @@ export class AuthService { sub: apiKey.userId, email: apiKey.user.email, role: apiKey.user.role as UserRole, + tier: apiKey.user.tier as UserTier, type: 'api-key', apiKeyId: apiKey.id, apiKeyPermissions: apiKey.permissions, @@ -1227,6 +1231,7 @@ export class AuthService { sub: user.id, email: user.email, role: user.role as UserRole, + tier: user.tier as UserTier, type: 'access', jti: accessJti, family: family, @@ -1240,6 +1245,7 @@ export class AuthService { sub: user.id, email: user.email, role: user.role as UserRole, + tier: user.tier as UserTier, type: 'refresh', jti: refreshJti, family: family, diff --git a/src/auth/graphql-complexity.plugin.spec.ts b/src/auth/graphql-complexity.plugin.spec.ts new file mode 100644 index 00000000..b36bfb45 --- /dev/null +++ b/src/auth/graphql-complexity.plugin.spec.ts @@ -0,0 +1,93 @@ +import { buildSchema, parse } from 'graphql'; +import { GraphqlComplexityPlugin } from './graphql-complexity.plugin'; + +describe('GraphqlComplexityPlugin', () => { + const schema = buildSchema(` + type Item { + a: String + b: String + c: String + } + type Query { + items: [Item] + } + `); + + function makeAuthService(validateAccessToken: jest.Mock) { + return { validateAccessToken } as any; + } + + function buildQuery(aliasCount: number) { + const aliases = Array.from({ length: aliasCount }, (_, i) => `i${i}: items { a b c }`).join( + '\n', + ); + return parse(`query { ${aliases} }`); + } + + it('allows a query within the anonymous tier limit (no auth header)', async () => { + const plugin = new GraphqlComplexityPlugin(makeAuthService(jest.fn())); + const listener = await plugin.requestDidStart(); + + await expect( + listener.didResolveOperation!({ + request: { operationName: undefined, variables: {} }, + document: buildQuery(1), + schema, + contextValue: { req: { headers: {} } }, + } as any), + ).resolves.toBeUndefined(); + }); + + it('rejects a query over the anonymous tier limit', async () => { + const plugin = new GraphqlComplexityPlugin(makeAuthService(jest.fn())); + const listener = await plugin.requestDidStart(); + + await expect( + listener.didResolveOperation!({ + request: { operationName: undefined, variables: {} }, + document: buildQuery(20), + schema, + contextValue: { req: { headers: {} } }, + } as any), + ).rejects.toThrow(/exceeds the maximum allowed complexity/); + }); + + it('grants a higher limit for an authenticated premium-tier request', async () => { + const validateAccessToken = jest.fn().mockResolvedValue({ + sub: 'user-1', + tier: 'PREMIUM', + type: 'access', + }); + const plugin = new GraphqlComplexityPlugin(makeAuthService(validateAccessToken)); + const listener = await plugin.requestDidStart(); + + const req: any = { headers: { authorization: 'Bearer valid-token' } }; + + await expect( + listener.didResolveOperation!({ + request: { operationName: undefined, variables: {} }, + document: buildQuery(20), + schema, + contextValue: { req }, + } as any), + ).resolves.toBeUndefined(); + + expect(validateAccessToken).toHaveBeenCalledWith('valid-token'); + expect(req.authUser.tier).toBe('PREMIUM'); + }); + + it('falls back to the anonymous limit when the token is invalid', async () => { + const validateAccessToken = jest.fn().mockRejectedValue(new Error('bad token')); + const plugin = new GraphqlComplexityPlugin(makeAuthService(validateAccessToken)); + const listener = await plugin.requestDidStart(); + + await expect( + listener.didResolveOperation!({ + request: { operationName: undefined, variables: {} }, + document: buildQuery(20), + schema, + contextValue: { req: { headers: { authorization: 'Bearer garbage' } } }, + } as any), + ).rejects.toThrow(/exceeds the maximum allowed complexity/); + }); +}); diff --git a/src/auth/graphql-complexity.plugin.ts b/src/auth/graphql-complexity.plugin.ts new file mode 100644 index 00000000..a5aa0720 --- /dev/null +++ b/src/auth/graphql-complexity.plugin.ts @@ -0,0 +1,90 @@ +// @ts-nocheck + +import { Injectable } from '@nestjs/common'; +import { Plugin } from '@nestjs/apollo'; +import { ApolloServerPlugin, GraphQLRequestListener } from '@apollo/server'; +import { GraphQLError } from 'graphql'; +import { getComplexity, simpleEstimator, fieldExtensionsEstimator } from 'graphql-query-complexity'; +import { AuthService } from './auth.service'; +import { GRAPHQL_COMPLEXITY_LIMITS } from './rate-limit.config'; + +/** + * Rejects GraphQL operations whose estimated query complexity exceeds the + * caller's tier limit. Complexity (not just depth/field count) protects + * against expensive nested/paginated queries that a naive per-request rate + * limit wouldn't catch, since a single GraphQL call can fan out into an + * arbitrary amount of resolver work. + * + * Runs as an Apollo plugin (didResolveOperation) rather than a static + * validation rule because validation rules don't have access to operation + * variables, which we need for accurate complexity estimates on paginated + * fields (e.g. `posts(limit: 100)`). + */ +@Plugin() +@Injectable() +export class GraphqlComplexityPlugin implements ApolloServerPlugin { + constructor(private readonly authService: AuthService) {} + + private async resolveTier(req: any): Promise { + const authHeader: string | undefined = req?.headers?.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return 'anonymous'; + } + + try { + const token = authHeader.slice('Bearer '.length); + const authUser = await this.authService.validateAccessToken(token); + // Field-level GqlAuthGuard hasn't run yet at this point in the Apollo + // lifecycle, so we validate the token here directly rather than + // relying on request.authUser being populated already. Stash the + // result on the request so the guard can reuse it instead of + // validating the same token twice. + req.authUser = authUser; + + if (authUser.type === 'api-key') { + return 'apiKey'; + } + const tier = (authUser.tier || 'FREE').toLowerCase(); + return tier in GRAPHQL_COMPLEXITY_LIMITS + ? (tier as keyof typeof GRAPHQL_COMPLEXITY_LIMITS) + : 'free'; + } catch { + // Invalid/expired token: don't fail the complexity check itself here, + // field-level auth guards are responsible for rejecting bad tokens. + // Just don't grant elevated complexity headroom for it. + return 'anonymous'; + } + } + + async requestDidStart(): Promise> { + return { + didResolveOperation: async ({ request, document, schema, contextValue }) => { + const req = (contextValue as any)?.req; + const tier = await this.resolveTier(req); + const maximumComplexity = GRAPHQL_COMPLEXITY_LIMITS[tier]; + + const complexity = getComplexity({ + schema, + operationName: request.operationName, + query: document, + variables: request.variables || {}, + estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })], + }); + + if (complexity > maximumComplexity) { + throw new GraphQLError( + `Query complexity ${complexity} exceeds the maximum allowed complexity of ${maximumComplexity} for your tier.`, + { + extensions: { + code: 'QUERY_COMPLEXITY_EXCEEDED', + complexity, + maximumComplexity, + tier, + }, + }, + ); + } + }, + }; + } +} diff --git a/src/auth/guards/rate-limit.guard.ts b/src/auth/guards/rate-limit.guard.ts index b4c9f039..8d5e9883 100644 --- a/src/auth/guards/rate-limit.guard.ts +++ b/src/auth/guards/rate-limit.guard.ts @@ -55,17 +55,20 @@ export class RateLimitGuard implements CanActivate { const endpoint = `${request.method} ${request.route?.path || request.url}`; try { - // Check by user if authenticated - // Tier defaults to 'free' as it is not included in the current JWT payload. - // When 'tier' is added to JwtPayload, this logic will use the actual value. + // Check by user if authenticated. Auth guards (JwtAuthGuard, ApiKeyAuthGuard) + // attach the authenticated payload as `request.authUser`, keyed by `sub`. const ip = this.getClientIp(request); + const authUser = request.authUser; - if (request.user?.id) { - const userTier = request.user.tier || 'free'; + if (authUser?.sub) { + // API keys get their own dedicated bucket regardless of the owning user's + // tier; JWT-authenticated requests use the user's real tier from the DB. + const userTier = + authUser.type === 'api-key' ? 'apiKey' : (authUser.tier || 'FREE').toLowerCase(); const [userStatus, userIpStatus] = await Promise.all([ - this.rateLimitService.checkUserRateLimit(request.user.id, userTier), - this.rateLimitService.checkUserIpRateLimit(request.user.id, ip), + this.rateLimitService.checkUserRateLimit(authUser.sub, userTier), + this.rateLimitService.checkUserIpRateLimit(authUser.sub, ip), ]); Object.entries(this.rateLimitService.getHeaders(userStatus)).forEach(([key, value]) => { diff --git a/src/auth/rate-limit.config.ts b/src/auth/rate-limit.config.ts index aa007bc2..f081f49a 100644 --- a/src/auth/rate-limit.config.ts +++ b/src/auth/rate-limit.config.ts @@ -182,3 +182,18 @@ export function getEndpointRateLimit(endpoint: string): RateLimitConfig | null { message: `Too many requests to ${endpoint}. Please try again later.`, }; } + +/** + * Max GraphQL query complexity per tier. Unauthenticated (anonymous) requests + * get the lowest ceiling; API-key traffic gets the same allowance as its + * equivalent REST rate-limit tier. + */ +export type GraphqlComplexityTier = 'anonymous' | 'free' | 'premium' | 'enterprise' | 'apiKey'; + +export const GRAPHQL_COMPLEXITY_LIMITS: Record = { + anonymous: 50, + free: 200, + premium: 1000, + enterprise: 5000, + apiKey: 2000, +}; diff --git a/src/auth/rate-limit.guard.spec.ts b/src/auth/rate-limit.guard.spec.ts index 1eb827dc..140dbdc9 100644 --- a/src/auth/rate-limit.guard.spec.ts +++ b/src/auth/rate-limit.guard.spec.ts @@ -38,6 +38,7 @@ describe('RateLimitGuard - auth/signup endpoints', () => { rateLimitService = { checkIpRateLimit: jest.fn().mockResolvedValue(notExceeded), checkUserRateLimit: jest.fn().mockResolvedValue(notExceeded), + checkUserIpRateLimit: jest.fn().mockResolvedValue(notExceeded), checkEndpointRateLimit: jest.fn().mockResolvedValue({ ...notExceeded, limit: 0 }), getHeaders: jest.fn().mockReturnValue({}), } as any; @@ -92,3 +93,80 @@ describe('RateLimitGuard - auth/signup endpoints', () => { await expect(guard.canActivate(ctx)).rejects.toThrow(HttpException); }); }); + +describe('RateLimitGuard - authenticated tier-based limiting', () => { + let guard: RateLimitGuard; + let rateLimitService: jest.Mocked; + let reflector: Reflector; + + const notExceeded = { limit: 5, remaining: 4, reset: 9999999999, isExceeded: false }; + const exceeded = { limit: 5, remaining: 0, reset: 9999999999, isExceeded: true, retryAfter: 60 }; + + beforeEach(() => { + reflector = new Reflector(); + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false); + + rateLimitService = { + checkIpRateLimit: jest.fn().mockResolvedValue(notExceeded), + checkUserRateLimit: jest.fn().mockResolvedValue(notExceeded), + checkUserIpRateLimit: jest.fn().mockResolvedValue(notExceeded), + checkEndpointRateLimit: jest.fn().mockResolvedValue({ ...notExceeded, limit: 0 }), + getHeaders: jest.fn().mockReturnValue({}), + } as any; + + guard = new RateLimitGuard(reflector, rateLimitService); + }); + + it('checks per-user limits using request.authUser, not request.user', async () => { + const ctx = makeContext({ + authUser: { sub: 'user-1', tier: 'PREMIUM', type: 'access' }, + }); + await expect(guard.canActivate(ctx)).resolves.toBe(true); + expect(rateLimitService.checkUserRateLimit).toHaveBeenCalledWith('user-1', 'premium'); + expect(rateLimitService.checkUserIpRateLimit).toHaveBeenCalledWith('user-1', '1.2.3.4'); + expect(rateLimitService.checkIpRateLimit).not.toHaveBeenCalled(); + }); + + it('ignores a stray request.user field and treats the request as anonymous', async () => { + // Regression test: the guard used to read request.user.id (which nothing in this + // codebase ever sets), silently bucketing every authenticated request as anonymous. + const ctx = makeContext({ + user: { id: 'user-1', tier: 'enterprise' }, + }); + await expect(guard.canActivate(ctx)).resolves.toBe(true); + expect(rateLimitService.checkUserRateLimit).not.toHaveBeenCalled(); + expect(rateLimitService.checkIpRateLimit).toHaveBeenCalledWith('1.2.3.4'); + }); + + it('defaults to the free tier when authUser.tier is missing', async () => { + const ctx = makeContext({ + authUser: { sub: 'user-1', type: 'access' }, + }); + await expect(guard.canActivate(ctx)).resolves.toBe(true); + expect(rateLimitService.checkUserRateLimit).toHaveBeenCalledWith('user-1', 'free'); + }); + + it('buckets api-key requests under apiKey regardless of the owning user tier', async () => { + const ctx = makeContext({ + authUser: { sub: 'user-1', tier: 'FREE', type: 'api-key', apiKeyId: 'key-1' }, + }); + await expect(guard.canActivate(ctx)).resolves.toBe(true); + expect(rateLimitService.checkUserRateLimit).toHaveBeenCalledWith('user-1', 'apiKey'); + }); + + it('blocks when the per-user limit is exceeded', async () => { + rateLimitService.checkUserRateLimit.mockResolvedValue(exceeded); + const ctx = makeContext({ + authUser: { sub: 'user-1', tier: 'FREE', type: 'access' }, + }); + await expect(guard.canActivate(ctx)).rejects.toThrow(HttpException); + }); + + it('blocks when the per-user-per-IP limit is exceeded', async () => { + rateLimitService.checkUserIpRateLimit.mockResolvedValue(exceeded); + const ctx = makeContext({ + authUser: { sub: 'user-1', tier: 'FREE', type: 'access' }, + }); + await expect(guard.canActivate(ctx)).rejects.toThrow(HttpException); + }); +}); diff --git a/src/auth/rbac.spec.ts b/src/auth/rbac.spec.ts index 37451bec..bbd2b3f9 100644 --- a/src/auth/rbac.spec.ts +++ b/src/auth/rbac.spec.ts @@ -7,10 +7,16 @@ import { ForbiddenException } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { RolesGuard } from '../auth/guards/roles.guard'; import { AuthUserPayload } from '../auth/types/auth-user.type'; -import { UserRole } from '../types/prisma.types'; +import { UserRole, UserTier } from '../types/prisma.types'; function makeContext(role: UserRole, requiredRoles: UserRole[] | null) { - const user: AuthUserPayload = { sub: 'u1', email: 'u@test.com', role, type: 'access' }; + const user: AuthUserPayload = { + sub: 'u1', + email: 'u@test.com', + role, + tier: UserTier.FREE, + type: 'access', + }; const request = { authUser: user }; return { switchToHttp: () => ({ getRequest: () => request }), @@ -123,12 +129,14 @@ describe('PropertiesController - RBAC', () => { sub: 'admin-1', email: 'admin@test.com', role: UserRole.ADMIN, + tier: UserTier.FREE, type: 'access', }; const regularUser: AuthUserPayload = { sub: 'user-1', email: 'user@test.com', role: UserRole.USER, + tier: UserTier.FREE, type: 'access', }; diff --git a/src/auth/types/auth-user.type.ts b/src/auth/types/auth-user.type.ts index 30488183..38ffbab3 100644 --- a/src/auth/types/auth-user.type.ts +++ b/src/auth/types/auth-user.type.ts @@ -1,11 +1,12 @@ // @ts-nocheck -import { UserRole } from '@prisma/client'; +import { UserRole, UserTier } from '@prisma/client'; export type AuthUserPayload = { sub: string; email: string; role: UserRole; + tier: UserTier; type: 'access' | 'refresh' | 'api-key'; jti?: string; apiKeyId?: string; diff --git a/src/cache/cache.config.ts b/src/cache/cache.config.ts index f056f4e9..832fc9f9 100644 --- a/src/cache/cache.config.ts +++ b/src/cache/cache.config.ts @@ -6,16 +6,23 @@ */ import { CacheModuleOptions } from '@nestjs/cache-manager'; -import * as redisStore from 'cache-manager-redis-store'; +import { Keyv } from 'keyv'; +import KeyvRedis from '@keyv/redis'; +// cache-manager v7 (used via @nestjs/cache-manager v3) is Keyv-based; the old +// cache-manager-redis-store package targeted cache-manager v3/v4's store +// interface and is not compatible with this version. @keyv/redis is the +// maintained adapter for the current API. export const REDIS_CONFIG: CacheModuleOptions = { isGlobal: true, - store: redisStore as any, - host: process.env.REDIS_HOST || 'localhost', - port: parseInt(process.env.REDIS_PORT || '6379', 10), - password: process.env.REDIS_PASSWORD, - db: parseInt(process.env.REDIS_DB || '0', 10), - ttl: 600, // Default TTL: 10 minutes + stores: [ + new Keyv({ + store: new KeyvRedis({ + url: getRedisConnectionString(), + }), + }), + ], + ttl: 600 * 1000, // Default TTL: 10 minutes (Keyv TTLs are in milliseconds) }; /** diff --git a/src/commissions/commissions.service.spec.ts b/src/commissions/commissions.service.spec.ts index 76c89bb4..18eff623 100644 --- a/src/commissions/commissions.service.spec.ts +++ b/src/commissions/commissions.service.spec.ts @@ -103,7 +103,7 @@ describe('CommissionsService', () => { await service.findAll( { page: 1, limit: 10 }, - { sub: 'agent-1', email: 'agent@test.com', role: 'AGENT', type: 'access' }, + { sub: 'agent-1', email: 'agent@test.com', role: 'AGENT', tier: 'FREE', type: 'access' }, ); expect((prisma as any).commission.findMany).toHaveBeenCalledWith( @@ -130,6 +130,7 @@ describe('CommissionsService', () => { sub: 'agent-2', email: 'agent2@test.com', role: 'AGENT', + tier: 'FREE', type: 'access', }), ).rejects.toBeInstanceOf(ForbiddenException); @@ -164,6 +165,7 @@ describe('CommissionsService', () => { sub: 'admin-1', email: 'admin@test.com', role: 'ADMIN', + tier: 'FREE', type: 'access', }); diff --git a/src/documents/documents-download.controller.spec.ts b/src/documents/documents-download.controller.spec.ts index 822adf00..eb31b439 100644 --- a/src/documents/documents-download.controller.spec.ts +++ b/src/documents/documents-download.controller.spec.ts @@ -5,7 +5,7 @@ import { SignedUrlService } from './signed-url/signed-url.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { AuthUserPayload } from '../auth/types/auth-user.type'; -import { UserRole } from '../types/prisma.types'; +import { UserRole, UserTier } from '../types/prisma.types'; import { Response } from 'express'; describe('DocumentsDownloadController', () => { @@ -25,11 +25,12 @@ describe('DocumentsDownloadController', () => { getSignedUrl: jest.fn(), }; - const mockUser: AuthUserPayload = { - sub: 'user-1', - role: UserRole.USER, - email: 'test@test.com', - type: 'access' + const mockUser: AuthUserPayload = { + sub: 'user-1', + role: UserRole.USER, + tier: UserTier.FREE, + email: 'test@test.com', + type: 'access', }; const mockResponse = { @@ -44,8 +45,10 @@ describe('DocumentsDownloadController', () => { { provide: SignedUrlService, useValue: mockSignedUrlService }, ], }) - .overrideGuard(JwtAuthGuard).useValue({ canActivate: () => true }) - .overrideGuard(RolesGuard).useValue({ canActivate: () => true }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) .compile(); controller = module.get(DocumentsDownloadController); @@ -68,7 +71,11 @@ describe('DocumentsDownloadController', () => { await controller.download('doc-1', {}, mockUser, mockResponse); - expect(mockDocumentsService.findAuthorizedById).toHaveBeenCalledWith('doc-1', mockUser.sub, mockUser.role); + expect(mockDocumentsService.findAuthorizedById).toHaveBeenCalledWith( + 'doc-1', + mockUser.sub, + mockUser.role, + ); expect(mockDocumentsService.getVersion).not.toHaveBeenCalled(); expect(mockDocumentsService.toObjectKey).toHaveBeenCalledWith('http://example.com/doc.pdf'); expect(mockSignedUrlService.getSignedUrl).toHaveBeenCalledWith({ @@ -83,7 +90,7 @@ describe('DocumentsDownloadController', () => { it('should fetch a specific document version and redirect to signed URL when versionId is provided', async () => { const mockDoc = { fileUrl: 'http://example.com/doc.pdf', mimeType: 'application/pdf' }; const mockVersion = { fileUrl: 'http://example.com/doc_v2.pdf' }; - + mockDocumentsService.findAuthorizedById.mockResolvedValue(mockDoc); mockDocumentsService.getVersion.mockResolvedValue(mockVersion); mockDocumentsService.toObjectKey.mockReturnValue('doc_v2.pdf'); @@ -91,9 +98,20 @@ describe('DocumentsDownloadController', () => { await controller.download('doc-1', { versionId: 'v2' }, mockUser, mockResponse); - expect(mockDocumentsService.findAuthorizedById).toHaveBeenCalledWith('doc-1', mockUser.sub, mockUser.role); - expect(mockDocumentsService.getVersion).toHaveBeenCalledWith('doc-1', 'v2', mockUser.sub, mockUser.role); - expect(mockDocumentsService.toObjectKey).toHaveBeenCalledWith('http://example.com/doc_v2.pdf'); + expect(mockDocumentsService.findAuthorizedById).toHaveBeenCalledWith( + 'doc-1', + mockUser.sub, + mockUser.role, + ); + expect(mockDocumentsService.getVersion).toHaveBeenCalledWith( + 'doc-1', + 'v2', + mockUser.sub, + mockUser.role, + ); + expect(mockDocumentsService.toObjectKey).toHaveBeenCalledWith( + 'http://example.com/doc_v2.pdf', + ); expect(mockSignedUrlService.getSignedUrl).toHaveBeenCalledWith({ operation: 'download', objectKey: 'doc_v2.pdf', @@ -105,10 +123,10 @@ describe('DocumentsDownloadController', () => { }); describe('Upload URL Endpoints', () => { - const uploadDto = { - fileName: 'test.pdf', - mimeType: 'application/pdf', - fileSizeBytes: 1024 + const uploadDto = { + fileName: 'test.pdf', + mimeType: 'application/pdf', + fileSizeBytes: 1024, } as any; const mockSignedUrlResponse = { @@ -176,4 +194,4 @@ describe('DocumentsDownloadController', () => { expect(result).toEqual({ id: 'doc-123' }); }); }); -}); \ No newline at end of file +}); diff --git a/src/documents/documents.controller.spec.ts b/src/documents/documents.controller.spec.ts index 65d73232..d6c73b61 100644 --- a/src/documents/documents.controller.spec.ts +++ b/src/documents/documents.controller.spec.ts @@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { DocumentsController } from './documents.controller'; import { DocumentsService } from './documents.service'; import { AuthUserPayload } from '../auth/types/auth-user.type'; -import { UserRole } from '../types/prisma.types'; +import { UserRole, UserTier } from '../types/prisma.types'; import { Response } from 'express'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; @@ -28,7 +28,13 @@ describe('DocumentsController', () => { bulkDownload: jest.fn(), }; - const mockUser: AuthUserPayload = { sub: 'user-1', role: UserRole.USER, email: 'test@test.com', type: 'access' }; + const mockUser: AuthUserPayload = { + sub: 'user-1', + role: UserRole.USER, + tier: UserTier.FREE, + email: 'test@test.com', + type: 'access', + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ diff --git a/src/main.ts b/src/main.ts index c20f63a9..7527751c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,21 +27,64 @@ async function bootstrap() { `Node.js >= ${REQUIRED_NODE_MAJOR} required, found ${process.versions.node}. ` + `Please upgrade Node.js (see https://nodejs.org/).`, ); + process.exit(1); + } - // Setup Swagger documentation - setupSwagger(app); + const app = await NestFactory.create(AppModule); - app.enableShutdownHooks(); + // Enable CORS + app.enableCors(); - const port = process.env.PORT || 3000; - await app.listen(port); - logger.log(`PropChain API running on http://localhost:${port}`); - logger.log(`API Versioning enabled. Supported versions: v1, v2`); - 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)`); - } + // Global prefix + app.setGlobalPrefix('api'); + + // Get services for guard initialization + const reflector = app.get(Reflector); + const rateLimitService = app.get(RateLimitService); + + // Apply global guards (every route is now rate-limited by default; use + // @SkipRateLimit() to opt individual routes out) + app.useGlobalGuards(new RateLimitGuard(reflector, rateLimitService)); + + // Apply version header interceptor globally + app.useGlobalInterceptors(new VersionHeaderInterceptor()); + + // Apply deprecation warning interceptor + app.useGlobalInterceptors(new DeprecationWarningInterceptor(reflector)); - bootstrap(); + // Apply rate limit headers interceptor + app.useGlobalInterceptors(new RateLimitHeadersInterceptor()); + + // Apply cache metrics interceptor + // Retrieve the singleton instance from the DI container to ensure consistent dependency injection + const cacheMetricsInterceptor = app.get(CacheMetricsInterceptor); + app.useGlobalInterceptors(cacheMetricsInterceptor); + + // Enable a single ValidationPipe with implicit conversion (#754 NestJS 11 upgrade) + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); + + // Setup Swagger documentation + setupSwagger(app); + + app.enableShutdownHooks(); + + const port = process.env.PORT || 3000; + await app.listen(port); + logger.log(`PropChain API running on http://localhost:${port}`); + logger.log(`API Versioning enabled. Supported versions: v1, v2`); + 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(); diff --git a/src/mortgage-calculator/dto/mortgage-calculator.dto.ts b/src/mortgage-calculator/dto/mortgage-calculator.dto.ts index ef3c57f3..9f4a0ff4 100644 --- a/src/mortgage-calculator/dto/mortgage-calculator.dto.ts +++ b/src/mortgage-calculator/dto/mortgage-calculator.dto.ts @@ -1,6 +1,15 @@ // @ts-nocheck -import { IsNumber, IsPositive, Min, Max, IsArray, IsOptional, ValidateNested } from 'class-validator'; +import { + IsNumber, + IsPositive, + Min, + Max, + IsArray, + IsOptional, + ValidateNested, + IsString, +} from 'class-validator'; import { Type } from 'class-transformer'; export class MortgageCalculatorDto { @@ -100,8 +109,6 @@ export class MortgageScenarioDto { label?: string; } -import { IsString } from 'class-validator'; - export class CompareScenariosDto { @IsArray() @ValidateNested({ each: true }) diff --git a/src/open-house/dto/tour-request.dto.ts b/src/open-house/dto/tour-request.dto.ts index b07ac983..2679ee6d 100644 --- a/src/open-house/dto/tour-request.dto.ts +++ b/src/open-house/dto/tour-request.dto.ts @@ -9,7 +9,7 @@ import { Matches, IsBoolean, } from 'class-validator'; -import { TourType } from '../open-house.service'; +import { TourType } from '@prisma/client'; export class CreateTourRequestDto { @IsString() diff --git a/src/properties/properties.service.agent.spec.ts b/src/properties/properties.service.agent.spec.ts index 884324c6..3be31954 100644 --- a/src/properties/properties.service.agent.spec.ts +++ b/src/properties/properties.service.agent.spec.ts @@ -69,7 +69,7 @@ describe('PropertiesService - Agent Assignment', () => { const result = await service.assignAgent( 'prop-1', { agentId: 'agent-1', commissionRate: 0.05, contactPhone: '12345' }, - { sub: 'owner-1', email: 'owner@test.com', role: 'USER', type: 'access' }, + { sub: 'owner-1', email: 'owner@test.com', role: 'USER', tier: 'FREE', type: 'access' }, ); expect((prisma as any).propertyAgent.create).toHaveBeenCalledWith({ @@ -92,7 +92,13 @@ describe('PropertiesService - Agent Assignment', () => { service.assignAgent( 'prop-1', { agentId: 'agent-1' }, - { sub: 'other-user', email: 'other@test.com', role: 'USER', type: 'access' }, + { + sub: 'other-user', + email: 'other@test.com', + role: 'USER', + tier: 'FREE', + type: 'access', + }, ), ).rejects.toBeInstanceOf(ForbiddenException); }); @@ -105,7 +111,7 @@ describe('PropertiesService - Agent Assignment', () => { service.assignAgent( 'prop-1', { agentId: 'user-1' }, - { sub: 'owner-1', email: 'owner@test.com', role: 'USER', type: 'access' }, + { sub: 'owner-1', email: 'owner@test.com', role: 'USER', tier: 'FREE', type: 'access' }, ), ).rejects.toBeInstanceOf(BadRequestException); }); @@ -124,7 +130,7 @@ describe('PropertiesService - Agent Assignment', () => { 'prop-1', 'agent-1', { commissionRate: 0.04 }, - { sub: 'owner-1', email: 'owner@test.com', role: 'USER', type: 'access' }, + { sub: 'owner-1', email: 'owner@test.com', role: 'USER', tier: 'FREE', type: 'access' }, ); expect((prisma as any).propertyAgent.update).toHaveBeenCalledWith({ diff --git a/src/types/prisma.types.ts b/src/types/prisma.types.ts index 97e23962..9f76b96c 100644 --- a/src/types/prisma.types.ts +++ b/src/types/prisma.types.ts @@ -70,6 +70,12 @@ export enum UserRole { AGENT = 'AGENT', } +export enum UserTier { + FREE = 'FREE', + PREMIUM = 'PREMIUM', + ENTERPRISE = 'ENTERPRISE', +} + export enum PropertyStatus { DRAFT = 'DRAFT', PENDING = 'PENDING',