From e2cc3971497c614fe344bfd1cf9efd1f844f35db Mon Sep 17 00:00:00 2001 From: mallison Date: Thu, 25 Jun 2026 11:53:08 +0100 Subject: [PATCH] feat: build feature flags system with deterministic percentage rollouts --- package-lock.json | 622 +++++++++++++++++- package.json | 3 + src/app.module.ts | 4 +- src/common/guards/feature-flag.guard.spec.ts | 61 ++ src/common/guards/feature-flag.guard.ts | 31 + src/dao/controllers/proposal.controller.ts | 13 +- .../1782382000000-CreateFeatureFlagsTable.ts | 70 ++ .../flags/entities/feature-flag.entity.ts | 40 ++ src/modules/flags/flags.controller.spec.ts | 60 ++ src/modules/flags/flags.controller.ts | 71 ++ src/modules/flags/flags.module.ts | 31 + src/modules/flags/flags.service.spec.ts | 166 +++++ src/modules/flags/flags.service.ts | 118 ++++ 13 files changed, 1252 insertions(+), 38 deletions(-) create mode 100644 src/common/guards/feature-flag.guard.spec.ts create mode 100644 src/common/guards/feature-flag.guard.ts create mode 100644 src/database/migrations/1782382000000-CreateFeatureFlagsTable.ts create mode 100644 src/modules/flags/entities/feature-flag.entity.ts create mode 100644 src/modules/flags/flags.controller.spec.ts create mode 100644 src/modules/flags/flags.controller.ts create mode 100644 src/modules/flags/flags.module.ts create mode 100644 src/modules/flags/flags.service.spec.ts create mode 100644 src/modules/flags/flags.service.ts diff --git a/package-lock.json b/package-lock.json index 94165125..f082b8bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@as-integrations/express5": "^1.1.2", "@nestjs/apollo": "^13.3.0", "@nestjs/axios": "^4.0.0", + "@nestjs/cache-manager": "^3.1.3", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.0", "@nestjs/core": "^11.0.1", @@ -31,6 +32,8 @@ "archiver": "^7.0.1", "axios": "^1.7.9", "bcrypt": "^5.1.1", + "cache-manager": "^7.2.8", + "cache-manager-redis-yet": "^5.1.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "date-fns": "^3.3.1", @@ -1078,6 +1081,16 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@cacheable/utils": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.1.tgz", + "integrity": "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==", + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -1092,7 +1105,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, + "devOptional": true, "dependencies": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -1104,7 +1117,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, + "devOptional": true, "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -2546,7 +2559,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "engines": { "node": ">=6.0.0" } @@ -2565,7 +2578,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true + "devOptional": true }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", @@ -2587,6 +2600,12 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "license": "MIT" + }, "node_modules/@lukeed/csprng": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", @@ -2969,6 +2988,19 @@ "rxjs": "^7.0.0" } }, + "node_modules/@nestjs/cache-manager": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-3.1.3.tgz", + "integrity": "sha512-HMtiOfHz75NZX7mJn1VnZGLSachVI04TnUc5wvEogIaKwk5BDQHgtP5htxreizjv7oxKalJbuTyxtiF6bE+bgQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0", + "cache-manager": ">=6", + "keyv": ">=5", + "rxjs": "^7.8.1" + } + }, "node_modules/@nestjs/cli": { "version": "11.0.21", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.21.tgz", @@ -3895,6 +3927,65 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==" }, + "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/@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/@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/@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/@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/@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/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -4500,25 +4591,25 @@ "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true + "devOptional": true }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true + "devOptional": true }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true + "devOptional": true }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true + "devOptional": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5805,7 +5896,7 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, + "devOptional": true, "bin": { "acorn": "bin/acorn" }, @@ -5838,7 +5929,7 @@ "version": "8.3.5", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, + "devOptional": true, "dependencies": { "acorn": "^8.11.0" }, @@ -6161,7 +6252,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true + "devOptional": true }, "node_modules/argparse": { "version": "2.0.1", @@ -6918,6 +7009,63 @@ "node": ">= 0.8" } }, + "node_modules/cache-manager": { + "version": "7.2.8", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.8.tgz", + "integrity": "sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==", + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.3.3", + "keyv": "^5.5.5" + } + }, + "node_modules/cache-manager-redis-yet": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/cache-manager-redis-yet/-/cache-manager-redis-yet-5.1.5.tgz", + "integrity": "sha512-NYDxrWBoLXxxVPw4JuBriJW0f45+BVOAsgLiozRo4GoJQyoKPbueQWYStWqmO73/AeHJeWrV7Hzvk6vhCGHlqA==", + "deprecated": "With cache-manager v6 we now are using Keyv", + "license": "MIT", + "dependencies": { + "@redis/bloom": "^1.2.0", + "@redis/client": "^1.6.0", + "@redis/graph": "^1.1.1", + "@redis/json": "^1.0.7", + "@redis/search": "^1.2.0", + "@redis/time-series": "^1.1.0", + "cache-manager": "^5.7.6", + "redis": "^4.7.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/cache-manager-redis-yet/node_modules/cache-manager": { + "version": "5.7.6", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.7.6.tgz", + "integrity": "sha512-wBxnBHjDxF1RXpHCBD6HGvKER003Ts7IIm0CHpggliHzN1RZditb7rXoduE1rplc2DEFYKxhLKgFuchXMJje9w==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^10.2.2", + "promise-coalesce": "^1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/cache-manager-redis-yet/node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/cache-manager-redis-yet/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/cacheable-lookup": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", @@ -6945,6 +7093,16 @@ "node": ">=14.16" } }, + "node_modules/cacheable-request/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/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -7241,6 +7399,15 @@ "node": ">=0.8" } }, + "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/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -7526,7 +7693,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true + "devOptional": true }, "node_modules/cron": { "version": "4.4.0", @@ -7825,7 +7992,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.3.1" } @@ -8084,10 +8251,11 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", - "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", + "version": "5.24.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.1.tgz", + "integrity": "sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" @@ -9189,6 +9357,16 @@ "node": ">=16" } }, + "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/flatted": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", @@ -9645,6 +9823,15 @@ "url": "https://opencollective.com/node-fetch" } }, + "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", @@ -10143,6 +10330,18 @@ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -10165,6 +10364,12 @@ "url": "https://github.com/sponsors/EvanHahn" } }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "license": "MIT" + }, "node_modules/html-entities": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", @@ -11560,7 +11765,8 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -11709,12 +11915,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/kind-of": { @@ -12150,7 +12356,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true + "devOptional": true }, "node_modules/makeerror": { "version": "1.0.12", @@ -12310,6 +12516,181 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/minimizer-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -13249,6 +13630,15 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "node_modules/promise-coalesce": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/promise-coalesce/-/promise-coalesce-1.5.0.tgz", + "integrity": "sha512-cTJ30U+ur1LD7pMPyQxiKIwxjtAjLsyU7ivRhVWZrX9BNIXtf78pc37vSMc8Vikx7DVzEKNk2SEJ5KWUpSG2ig==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -13619,6 +14009,23 @@ "url": "https://paulmillr.com/funding/" } }, + "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/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -15379,7 +15786,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, + "devOptional": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -15759,7 +16166,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16005,7 +16412,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true + "devOptional": true }, "node_modules/v8-to-istanbul": { "version": "9.3.0", @@ -16047,12 +16454,12 @@ } }, "node_modules/watchpack": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", "dev": true, + "license": "MIT", "dependencies": { - "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" }, "engines": { @@ -16102,6 +16509,53 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, + "node_modules/webpack": { + "version": "5.108.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.108.0.tgz", + "integrity": "sha512-Ln1JuYGPRTXcHECapSFSvACtHmWEN5sQqFJeLLGQ0057S7qzT2eXUz0MZUedtmIrNy3nJgnITSubIYKGED9jSQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.22.2", + "es-module-lexer": "^2.1.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.2", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.6.1", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "watchpack": "^2.5.2", + "webpack-sources": "^3.5.0" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, "node_modules/webpack-node-externals": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", @@ -16120,6 +16574,112 @@ "node": ">=10.13.0" } }, + "node_modules/webpack/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", @@ -16453,7 +17013,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, + "devOptional": true, "engines": { "node": ">=6" } diff --git a/package.json b/package.json index b95d81f5..1f102fe4 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@as-integrations/express5": "^1.1.2", "@nestjs/apollo": "^13.3.0", "@nestjs/axios": "^4.0.0", + "@nestjs/cache-manager": "^3.1.3", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.0", "@nestjs/core": "^11.0.1", @@ -46,6 +47,8 @@ "archiver": "^7.0.1", "axios": "^1.7.9", "bcrypt": "^5.1.1", + "cache-manager": "^7.2.8", + "cache-manager-redis-yet": "^5.1.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "date-fns": "^3.3.1", diff --git a/src/app.module.ts b/src/app.module.ts index 0f857453..d95ba1ac 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TerminusModule } from '@nestjs/terminus'; +import { ThrottlerModule } from '@nestjs/throttler'; import { TypeOrmModule } from '@nestjs/typeorm'; import { envValidationSchema } from './config/env.validation'; import { AppController } from './app.controller'; @@ -11,7 +12,6 @@ import { CurrenciesModule } from './currencies/currencies.module'; import { ExchangeRatesModule } from './exchange-rates/exchange-rates.module'; import { CommonModule } from './common/common.module'; import { PlanThrottlerGuard } from './common/guards/plan-throttler.guard'; -import { HealthModule } from './health/health.module'; import { AuditLogsModule } from './audit-logs/audit-logs.module'; import { NotificationsModule } from './notifications/notifications.module'; import { TransactionsModule } from './transactions/transaction.module'; @@ -36,6 +36,7 @@ import { LedgerModule } from './ledger/ledger.module'; import { UsersModule } from './users/users.module'; import { StellarModule } from './modules/stellar/stellar.module'; import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard'; +import { FlagsModule } from './modules/flags/flags.module'; @Module({ imports: [ @@ -81,6 +82,7 @@ import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard'; ExchangeRatesModule, GatewaysModule, HealthModule, + FlagsModule, ], controllers: [AppController], providers: [], diff --git a/src/common/guards/feature-flag.guard.spec.ts b/src/common/guards/feature-flag.guard.spec.ts new file mode 100644 index 00000000..6e2f16bb --- /dev/null +++ b/src/common/guards/feature-flag.guard.spec.ts @@ -0,0 +1,61 @@ +import { FeatureFlagGuard } from './feature-flag.guard'; +import { NotFoundException } from '@nestjs/common'; + +describe('FeatureFlagGuard', () => { + let GuardClass: any; + let guard: any; + let flagsServiceMock: any; + + beforeEach(() => { + flagsServiceMock = { + isEnabled: jest.fn(), + }; + GuardClass = FeatureFlagGuard('dao_voting'); + guard = new GuardClass(flagsServiceMock); + }); + + it('should return true if flag is enabled', async () => { + flagsServiceMock.isEnabled.mockResolvedValue(true); + const mockContext = { + switchToHttp: () => ({ + getRequest: () => ({ user: { userId: 'user1' } }), + }), + }; + + const result = await guard.canActivate(mockContext as any); + expect(result).toBe(true); + expect(flagsServiceMock.isEnabled).toHaveBeenCalledWith( + 'dao_voting', + 'user1', + ); + }); + + it('should return true if flag is enabled and user is undefined', async () => { + flagsServiceMock.isEnabled.mockResolvedValue(true); + const mockContext = { + switchToHttp: () => ({ + getRequest: () => ({}), // No user + }), + }; + + const result = await guard.canActivate(mockContext as any); + expect(result).toBe(true); + expect(flagsServiceMock.isEnabled).toHaveBeenCalledWith( + 'dao_voting', + undefined, + ); + }); + + it('should throw NotFoundException if flag is disabled', async () => { + flagsServiceMock.isEnabled.mockResolvedValue(false); + const mockContext = { + switchToHttp: () => ({ + getRequest: () => ({ user: { userId: 'user1' } }), + }), + }; + + await expect(guard.canActivate(mockContext as any)).rejects.toThrow( + NotFoundException, + ); + }); +}); diff --git a/src/common/guards/feature-flag.guard.ts b/src/common/guards/feature-flag.guard.ts new file mode 100644 index 00000000..691332a3 --- /dev/null +++ b/src/common/guards/feature-flag.guard.ts @@ -0,0 +1,31 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + mixin, + NotFoundException, +} from '@nestjs/common'; +import { FlagsService } from '../../modules/flags/flags.service'; + +export function FeatureFlagGuard(flagKey: string) { + @Injectable() + class MixinFeatureFlagGuard implements CanActivate { + constructor(private readonly flagsService: FlagsService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + // Assume JwtAuthGuard has run first to populate request.user + const userId = request.user?.userId; + + const isEnabled = await this.flagsService.isEnabled(flagKey, userId); + + if (!isEnabled) { + throw new NotFoundException(); // 404 not 403 to prevent feature discovery + } + + return true; + } + } + + return mixin(MixinFeatureFlagGuard); +} diff --git a/src/dao/controllers/proposal.controller.ts b/src/dao/controllers/proposal.controller.ts index e911c5f7..61a0bdab 100644 --- a/src/dao/controllers/proposal.controller.ts +++ b/src/dao/controllers/proposal.controller.ts @@ -21,6 +21,7 @@ import { ProposalService } from '../services/proposal.service'; import { CreateProposalDto } from '../dto/create-proposal.dto'; import { CastVoteDto } from '../dto/cast-vote.dto'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { FeatureFlagGuard } from '../../common/guards/feature-flag.guard'; import { RolesGuard } from '../../auth/guards/roles.guard'; import { Roles } from '../../auth/decorators/roles.decorator'; import { UserRole } from '../../users/user.entity'; @@ -33,7 +34,7 @@ export class ProposalController { constructor(private readonly proposalService: ProposalService) {} @Post() - @UseGuards(JwtAuthGuard, RolesGuard) + @UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard('dao_voting')) @Roles(UserRole.ADMIN) @HttpCode(201) @ApiOperation({ @@ -76,7 +77,7 @@ export class ProposalController { } @Post(':id/vote') - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuard, FeatureFlagGuard('dao_voting')) @HttpCode(201) @ApiOperation({ summary: 'Cast a vote on a proposal', @@ -124,7 +125,7 @@ export class ProposalController { } @Get(':id') - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuard, FeatureFlagGuard('dao_voting')) @ApiOperation({ summary: 'Get proposal detail with current vote counts', }) @@ -167,7 +168,7 @@ export class ProposalController { } @Get(':id/results') - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuard, FeatureFlagGuard('dao_voting')) @ApiOperation({ summary: 'Get voting results for a proposal', }) @@ -206,7 +207,7 @@ export class ProposalController { } @Get() - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuard, FeatureFlagGuard('dao_voting')) @ApiOperation({ summary: 'List all proposals with pagination', }) @@ -263,7 +264,7 @@ export class ProposalController { } @Delete(':id') - @UseGuards(JwtAuthGuard, RolesGuard) + @UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard('dao_voting')) @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Cancel a proposal (ADMIN only)', diff --git a/src/database/migrations/1782382000000-CreateFeatureFlagsTable.ts b/src/database/migrations/1782382000000-CreateFeatureFlagsTable.ts new file mode 100644 index 00000000..58949335 --- /dev/null +++ b/src/database/migrations/1782382000000-CreateFeatureFlagsTable.ts @@ -0,0 +1,70 @@ +import { MigrationInterface, QueryRunner, Table } from 'typeorm'; + +export class CreateFeatureFlagsTable1782382000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'feature_flags', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'uuid_generate_v4()', + }, + { + name: 'key', + type: 'varchar', + isUnique: true, + }, + { + name: 'name', + type: 'varchar', + }, + { + name: 'description', + type: 'text', + isNullable: true, + }, + { + name: 'isEnabled', + type: 'boolean', + default: false, + }, + { + name: 'rolloutPercent', + type: 'int', + default: 0, + }, + { + name: 'targetUserIds', + type: 'uuid', + isArray: true, + isNullable: true, + }, + { + name: 'environments', + type: 'varchar', + isArray: true, + default: "'{}'", + }, + { + name: 'createdAt', + type: 'timestamp with time zone', + default: 'now()', + }, + { + name: 'updatedAt', + type: 'timestamp with time zone', + default: 'now()', + }, + ], + }), + true, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('feature_flags'); + } +} diff --git a/src/modules/flags/entities/feature-flag.entity.ts b/src/modules/flags/entities/feature-flag.entity.ts new file mode 100644 index 00000000..7fed95f8 --- /dev/null +++ b/src/modules/flags/entities/feature-flag.entity.ts @@ -0,0 +1,40 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('feature_flags') +export class FeatureFlag { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', unique: true }) + key: string; + + @Column({ type: 'varchar' }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'boolean', default: false }) + isEnabled: boolean; + + @Column({ type: 'int', default: 0 }) + rolloutPercent: number; + + @Column({ type: 'uuid', array: true, nullable: true }) + targetUserIds: string[]; + + @Column({ type: 'varchar', array: true, default: [] }) + environments: string[]; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updatedAt: Date; +} diff --git a/src/modules/flags/flags.controller.spec.ts b/src/modules/flags/flags.controller.spec.ts new file mode 100644 index 00000000..b9e8448c --- /dev/null +++ b/src/modules/flags/flags.controller.spec.ts @@ -0,0 +1,60 @@ +import { FlagsController } from './flags.controller'; + +describe('FlagsController', () => { + let controller: FlagsController; + let service: any; + + beforeEach(() => { + service = { + getFlagsForUser: jest.fn(), + listFlags: jest.fn(), + createFlag: jest.fn(), + updateFlag: jest.fn(), + deleteFlag: jest.fn(), + isEnabled: jest.fn(), + }; + controller = new FlagsController(service); + }); + + it('should call getFlagsForUser', async () => { + service.getFlagsForUser.mockResolvedValue({ flag1: true }); + expect(await controller.getUserFlags({ user: { userId: '1' } })).toEqual({ + flag1: true, + }); + }); + + it('should call listFlags', async () => { + service.listFlags.mockResolvedValue([]); + expect(await controller.listFlags()).toEqual([]); + }); + + it('should call createFlag', async () => { + service.createFlag.mockResolvedValue({}); + expect(await controller.createFlag({})).toEqual({}); + }); + + it('should call updateFlag', async () => { + service.updateFlag.mockResolvedValue({}); + expect(await controller.updateFlag('1', {})).toEqual({}); + }); + + it('should call deleteFlag', async () => { + service.deleteFlag.mockResolvedValue(undefined); + expect(await controller.deleteFlag('1')).toEqual({ success: true }); + }); + + it('should return enabled state from checkFlagForUser', async () => { + service.listFlags.mockResolvedValue([{ id: '1', key: 'flag1' }]); + service.isEnabled.mockResolvedValue(true); + expect(await controller.checkFlagForUser('1', 'user1')).toEqual({ + enabled: true, + }); + }); + + it('should return enabled false if flag not found in checkFlagForUser', async () => { + service.listFlags.mockResolvedValue([]); + expect(await controller.checkFlagForUser('1', 'user1')).toEqual({ + enabled: false, + }); + }); +}); diff --git a/src/modules/flags/flags.controller.ts b/src/modules/flags/flags.controller.ts new file mode 100644 index 00000000..94b2c2a9 --- /dev/null +++ b/src/modules/flags/flags.controller.ts @@ -0,0 +1,71 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, + Request, +} from '@nestjs/common'; +import { FlagsService } from './flags.service'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { UserRole } from '../../users/user.entity'; + +@Controller() +export class FlagsController { + constructor(private readonly flagsService: FlagsService) {} + + @Get('flags') + @UseGuards(JwtAuthGuard) + async getUserFlags(@Request() req: any) { + return this.flagsService.getFlagsForUser(req.user.userId); + } + + @Get('admin/flags') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + async listFlags() { + return this.flagsService.listFlags(); + } + + @Post('admin/flags') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + async createFlag(@Body() body: any) { + return this.flagsService.createFlag(body); + } + + @Patch('admin/flags/:id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + async updateFlag(@Param('id') id: string, @Body() body: any) { + return this.flagsService.updateFlag(id, body); + } + + @Delete('admin/flags/:id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + async deleteFlag(@Param('id') id: string) { + await this.flagsService.deleteFlag(id); + return { success: true }; + } + + @Get('admin/flags/:id/check') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + async checkFlagForUser( + @Param('id') id: string, + @Query('userId') targetUserId: string, + ) { + const flags = await this.flagsService.listFlags(); + const flag = flags.find((f) => f.id === id); + if (!flag) return { enabled: false }; + const enabled = await this.flagsService.isEnabled(flag.key, targetUserId); + return { enabled }; + } +} diff --git a/src/modules/flags/flags.module.ts b/src/modules/flags/flags.module.ts new file mode 100644 index 00000000..679f5e9e --- /dev/null +++ b/src/modules/flags/flags.module.ts @@ -0,0 +1,31 @@ +import { Module, Global } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { CacheModule } from '@nestjs/cache-manager'; +import { redisStore } from 'cache-manager-redis-yet'; +import { FeatureFlag } from './entities/feature-flag.entity'; +import { FlagsService } from './flags.service'; +import { FlagsController } from './flags.controller'; + +@Global() +@Module({ + imports: [ + TypeOrmModule.forFeature([FeatureFlag]), + CacheModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async (configService: ConfigService) => { + const redisUrl = configService.get('REDIS_URL'); + if (redisUrl) { + const store = await redisStore({ url: redisUrl }); + return { store }; + } + return {}; // fallback to in-memory if REDIS_URL not configured locally + }, + }), + ], + controllers: [FlagsController], + providers: [FlagsService], + exports: [FlagsService], +}) +export class FlagsModule {} diff --git a/src/modules/flags/flags.service.spec.ts b/src/modules/flags/flags.service.spec.ts new file mode 100644 index 00000000..40c2189b --- /dev/null +++ b/src/modules/flags/flags.service.spec.ts @@ -0,0 +1,166 @@ +import { FlagsService } from './flags.service'; +import { FeatureFlag } from './entities/feature-flag.entity'; + +describe('FlagsService', () => { + let service: FlagsService; + let repoMock: any; + let cacheMock: any; + let configMock: any; + + beforeEach(() => { + repoMock = { + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + findOneOrFail: jest.fn(), + }; + cacheMock = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + }; + configMock = { + get: jest.fn().mockReturnValue('production'), + }; + service = new FlagsService(repoMock, cacheMock, configMock); + }); + + const baseFlag = { + id: '1', + key: 'test_flag', + isEnabled: true, + name: 'Test flag', + description: '', + environments: ['production'], + rolloutPercent: 0, + targetUserIds: [], + createdAt: new Date(), + updatedAt: new Date(), + } as FeatureFlag; + + it('should return true if rollout is 100%', async () => { + cacheMock.get.mockResolvedValue({ ...baseFlag, rolloutPercent: 100 }); + expect(await service.isEnabled('test_flag')).toBe(true); + }); + + it('should return false if rollout is 0%', async () => { + cacheMock.get.mockResolvedValue({ ...baseFlag, rolloutPercent: 0 }); + expect(await service.isEnabled('test_flag', 'user1')).toBe(false); + }); + + it('should return true for targeted users even if 0% rollout', async () => { + cacheMock.get.mockResolvedValue({ + ...baseFlag, + rolloutPercent: 0, + targetUserIds: ['user1'], + }); + expect(await service.isEnabled('test_flag', 'user1')).toBe(true); + }); + + it('should be consistent based on deterministic hash for 50% rollout', async () => { + cacheMock.get.mockResolvedValue({ ...baseFlag, rolloutPercent: 50 }); + + const result1 = await service.isEnabled('test_flag', 'user1'); + const result2 = await service.isEnabled('test_flag', 'user1'); + expect(result1).toBe(result2); + }); + + it('should return false if disabled', async () => { + cacheMock.get.mockResolvedValue({ + ...baseFlag, + isEnabled: false, + rolloutPercent: 100, + }); + expect(await service.isEnabled('test_flag', 'user1')).toBe(false); + }); + + it('should return false if environment does not match', async () => { + cacheMock.get.mockResolvedValue({ + ...baseFlag, + environments: ['staging'], + rolloutPercent: 100, + }); + expect(await service.isEnabled('test_flag', 'user1')).toBe(false); + }); + + it('should return false for missing user if partial rollout', async () => { + cacheMock.get.mockResolvedValue({ ...baseFlag, rolloutPercent: 50 }); + expect(await service.isEnabled('test_flag')).toBe(false); + }); + + it('should cache DB result if not found in cache', async () => { + cacheMock.get.mockResolvedValue(null); + repoMock.findOne.mockResolvedValue(baseFlag); + + await service.isEnabled('test_flag'); + expect(repoMock.findOne).toHaveBeenCalledWith({ + where: { key: 'test_flag' }, + }); + expect(cacheMock.set).toHaveBeenCalledWith( + 'flag_test_flag', + baseFlag, + 60000, + ); + }); + + it('should return false if DB flag not found', async () => { + cacheMock.get.mockResolvedValue(null); + repoMock.findOne.mockResolvedValue(null); + expect(await service.isEnabled('test_flag')).toBe(false); + }); + + it('should return false if environments array is null', async () => { + cacheMock.get.mockResolvedValue({ + ...baseFlag, + environments: null, + rolloutPercent: 100, + }); + expect(await service.isEnabled('test_flag', 'user1')).toBe(false); + }); + + it('should return true if environment defaults to development and matches', async () => { + configMock.get.mockReturnValue(undefined); // Default to development + cacheMock.get.mockResolvedValue({ + ...baseFlag, + environments: ['development'], + rolloutPercent: 100, + }); + expect(await service.isEnabled('test_flag')).toBe(true); + }); + + it('should list flags', async () => { + repoMock.find.mockResolvedValue([baseFlag]); + expect(await service.listFlags()).toEqual([baseFlag]); + }); + + it('should create flag', async () => { + repoMock.create.mockReturnValue(baseFlag); + repoMock.save.mockResolvedValue(baseFlag); + expect(await service.createFlag({})).toEqual(baseFlag); + }); + + it('should update flag and delete cache', async () => { + repoMock.findOneOrFail.mockResolvedValue(baseFlag); + repoMock.save.mockResolvedValue({ ...baseFlag, name: 'new' }); + await service.updateFlag('1', { name: 'new' }); + expect(cacheMock.del).toHaveBeenCalledWith('flag_test_flag'); + }); + + it('should delete flag and delete cache', async () => { + const flagToDel = { ...baseFlag }; + repoMock.findOneOrFail.mockResolvedValue(flagToDel); + repoMock.save.mockResolvedValue(flagToDel); + await service.deleteFlag('1'); + expect(flagToDel.isEnabled).toBe(false); + expect(flagToDel.key).toMatch(/^archived_/); + expect(cacheMock.del).toHaveBeenCalledWith('flag_test_flag'); + }); + + it('should get flags for user', async () => { + repoMock.find.mockResolvedValue([{ ...baseFlag, rolloutPercent: 100 }]); + cacheMock.get.mockResolvedValue({ ...baseFlag, rolloutPercent: 100 }); + const res = await service.getFlagsForUser('user1'); + expect(res).toEqual({ test_flag: true }); + }); +}); diff --git a/src/modules/flags/flags.service.ts b/src/modules/flags/flags.service.ts new file mode 100644 index 00000000..297fe7c9 --- /dev/null +++ b/src/modules/flags/flags.service.ts @@ -0,0 +1,118 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Cache } from 'cache-manager'; +import { ConfigService } from '@nestjs/config'; +import * as crypto from 'crypto'; +import { FeatureFlag } from './entities/feature-flag.entity'; + +@Injectable() +export class FlagsService { + constructor( + @InjectRepository(FeatureFlag) + private readonly flagsRepository: Repository, + @Inject(CACHE_MANAGER) + private readonly cacheManager: Cache, + private readonly configService: ConfigService, + ) {} + + /** + * Internal method to retrieve a flag. + * Hits the cache first; if missing, falls back to DB and caches for 60s. + */ + private async getFlag(key: string): Promise { + const cacheKey = `flag_${key}`; + const cachedFlag = await this.cacheManager.get(cacheKey); + + if (cachedFlag) { + return cachedFlag; + } + + const flag = await this.flagsRepository.findOne({ where: { key } }); + if (flag) { + // TTL is in milliseconds for cache-manager v5 + await this.cacheManager.set(cacheKey, flag, 60000); + } + return flag; + } + + /** + * Determine if a feature flag is enabled for a given context. + */ + async isEnabled(key: string, userId?: string): Promise { + const flag = await this.getFlag(key); + + if (!flag || !flag.isEnabled) { + return false; + } + + const currentEnv = + this.configService.get('NODE_ENV') || 'development'; + if (!flag.environments || !flag.environments.includes(currentEnv)) { + return false; + } + + if (userId && flag.targetUserIds && flag.targetUserIds.includes(userId)) { + return true; + } + + if (flag.rolloutPercent === 100) return true; + if (flag.rolloutPercent === 0) return false; + + if (!userId) { + // If a feature is partially rolled out, anonymous users do not get it. + return false; + } + + // Deterministic hash of userId + key for stable rollout result + const hashHex = crypto + .createHash('sha256') + .update(`${userId}-${key}`) + .digest('hex'); + + // Parse first 8 hex chars as an integer and get 0-99 + const hashInt = parseInt(hashHex.substring(0, 8), 16); + const hashPercent = hashInt % 100; + + return hashPercent < flag.rolloutPercent; + } + + async listFlags(): Promise { + return this.flagsRepository.find(); + } + + async createFlag(data: Partial): Promise { + const flag = this.flagsRepository.create(data); + return this.flagsRepository.save(flag); + } + + async updateFlag( + id: string, + data: Partial, + ): Promise { + const flag = await this.flagsRepository.findOneOrFail({ where: { id } }); + Object.assign(flag, data); + const saved = await this.flagsRepository.save(flag); + await this.cacheManager.del(`flag_${saved.key}`); + return saved; + } + + async deleteFlag(id: string): Promise { + const flag = await this.flagsRepository.findOneOrFail({ where: { id } }); + const originalKey = flag.key; + flag.isEnabled = false; + flag.key = `archived_${Date.now()}_${flag.key}`; // avoid unique constraint if re-created + await this.flagsRepository.save(flag); + await this.cacheManager.del(`flag_${originalKey}`); + } + + async getFlagsForUser(userId: string): Promise> { + const allFlags = await this.flagsRepository.find(); + const result: Record = {}; + for (const flag of allFlags) { + result[flag.key] = await this.isEnabled(flag.key, userId); + } + return result; + } +}