diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 401a764..34acf8d 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -91,6 +91,16 @@ jobs: working-directory: ./backend run: npm run build + # Dependency vulnerability scanning with npm audit. + # Fails the build if high or critical vulnerabilities are found in the dependency tree. + # The --audit-level=high threshold balances security with practicality — low/moderate + # findings are logged but don't block CI, giving teams time to evaluate and update + # dependencies on a reasonable schedule rather than forcing immediate action on every + # minor advisory. + - name: Dependency vulnerability scan + working-directory: ./backend + run: npm audit --audit-level=high + - name: Upload coverage uses: codecov/codecov-action@v4 with: diff --git a/backend/.eslintrc.js b/backend/.eslintrc.js index 1b058fa..26f6b92 100644 --- a/backend/.eslintrc.js +++ b/backend/.eslintrc.js @@ -20,7 +20,7 @@ module.exports = { "@typescript-eslint/interface-name-prefix": "off", "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-module-boundary-types": "off", - "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-explicit-any": ["warn", { ignoreRestArgs: false }], "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], }, }; diff --git a/backend/package-lock.json b/backend/package-lock.json index 2a35166..0049525 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -43,7 +43,9 @@ "eslint": "^8.42.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", + "husky": "^9.1.7", "jest": "^29.5.0", + "lint-staged": "^15.2.10", "prettier": "^3.0.0", "supertest": "^6.3.0", "ts-jest": "^29.1.0", @@ -3893,6 +3895,93 @@ "validator": "^13.15.22" } }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3953,6 +4042,13 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3965,6 +4061,16 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -4385,6 +4491,19 @@ "node": ">=10.0.0" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -4711,6 +4830,13 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/eventsource": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", @@ -5185,6 +5311,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -5499,6 +5638,22 @@ "node": ">=10.17.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -6640,6 +6795,19 @@ "integrity": "sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==", "license": "MIT" }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -6647,6 +6815,294 @@ "dev": true, "license": "MIT" }, + "node_modules/lint-staged": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.2.tgz", + "integrity": "sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^13.1.0", + "debug": "^4.4.0", + "execa": "^8.0.1", + "lilconfig": "^3.1.3", + "listr2": "^8.2.5", + "micromatch": "^4.0.8", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.7.0" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/lint-staged/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/lint-staged/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", + "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -6725,6 +7181,160 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6878,6 +7488,19 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", @@ -7399,6 +8022,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidtree": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.1.tgz", + "integrity": "sha512-e0F9AOF1JMrCfBsyJOwU9lNvQ0WtXTq0j/4jk0BQ5JSI9VAybPXmDpPRw/2FQ3e5d3ZFN1mLh7jW99m/jjaptw==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -7887,6 +8523,52 @@ "node": ">=10" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -7898,6 +8580,13 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -8208,6 +8897,49 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/socket.io": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", @@ -8351,6 +9083,16 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -9302,6 +10044,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/backend/src/admin/admin.guard.ts b/backend/src/admin/admin.guard.ts index 7c59698..39e5e50 100644 --- a/backend/src/admin/admin.guard.ts +++ b/backend/src/admin/admin.guard.ts @@ -5,6 +5,7 @@ import { Injectable, Logger, } from '@nestjs/common'; +import { config } from '../config/env.config'; /** * Restricts access to addresses listed in `ADMIN_ADDRESSES` (comma-separated Stellar public @@ -22,9 +23,9 @@ export class AdminGuard implements CanActivate { constructor() { this.adminAddresses = new Set( - (process.env.ADMIN_ADDRESSES ?? '') + (config.ADMIN_ADDRESSES ?? '') .split(',') - .map(address => address.trim()) + .map((address: string) => address.trim()) .filter(Boolean), ); diff --git a/backend/src/auth/auth.controller.spec.ts b/backend/src/auth/auth.controller.spec.ts index d085dd4..a25f8be 100644 --- a/backend/src/auth/auth.controller.spec.ts +++ b/backend/src/auth/auth.controller.spec.ts @@ -4,6 +4,7 @@ import { AuthService } from './auth.service'; describe('AuthController', () => { let controller: AuthController; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockAuthService: any; const TEST_ADDRESS = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOP'; diff --git a/backend/src/auth/auth.guard.ts b/backend/src/auth/auth.guard.ts index fba681d..7642f82 100644 --- a/backend/src/auth/auth.guard.ts +++ b/backend/src/auth/auth.guard.ts @@ -7,7 +7,11 @@ export class JwtAuthGuard extends AuthGuard('jwt') { return super.canActivate(context); } - handleRequest(err: any, user: any, info: any) { + handleRequest( + err: Error | null, + user: TUser | false, + _info: Error | string | null, + ): TUser { if (err || !user) { throw err || new UnauthorizedException('Invalid or expired token'); } diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index b5800a1..ecc0130 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -5,12 +5,13 @@ import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; import { JwtStrategy } from './jwt.strategy'; import { NonceStoreService } from './nonce-store.service'; +import { config } from '../config/env.config'; @Module({ imports: [ PassportModule, JwtModule.register({ - secret: process.env.JWT_SECRET || 'dev-secret-change-in-production', + secret: config.JWT_SECRET, signOptions: { expiresIn: '24h' }, }), ], diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index 27400f7..c94ccdd 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -6,7 +6,9 @@ import { NonceStoreService } from './nonce-store.service'; describe('AuthService', () => { let service: AuthService; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockNonceStore: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockJwtService: any; const TEST_ADDRESS = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOP'; diff --git a/backend/src/auth/jwt.strategy.ts b/backend/src/auth/jwt.strategy.ts index 60de3e2..4b26823 100644 --- a/backend/src/auth/jwt.strategy.ts +++ b/backend/src/auth/jwt.strategy.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; +import { config } from '../config/env.config'; export interface JwtPayload { address: string; @@ -15,7 +16,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: process.env.JWT_SECRET || 'dev-secret-change-in-production', + secretOrKey: config.JWT_SECRET, }); } diff --git a/backend/src/auth/nonce-store.redis-integration.spec.ts b/backend/src/auth/nonce-store.redis-integration.spec.ts new file mode 100644 index 0000000..d8aa4b3 --- /dev/null +++ b/backend/src/auth/nonce-store.redis-integration.spec.ts @@ -0,0 +1,120 @@ +import { Redis } from 'ioredis'; +import { NonceStoreService } from './nonce-store.service'; + +/** + * Exercises NonceStoreService's Lua GETDEL script against a real Redis server instead of the + * mocked ioredis client the rest of nonce-store.service.spec.ts uses. This guards against + * behavior the mock can't faithfully reproduce: real Lua script execution, real GET+DEL + * atomicity, and real TTL expiry semantics. + * + * Requires REDIS_URL — CI provides a `redis:7-alpine` service container (see + * .github/workflows/backend-ci.yml). Skipped locally when REDIS_URL isn't set rather than + * failing, so `npm test` still works without a local Redis. + */ +const describeIfRedis = process.env.REDIS_URL ? describe : describe.skip; + +describeIfRedis('NonceStoreService (Redis integration)', () => { + let redis: Redis; + let service: NonceStoreService; + + const TEST_ADDRESS = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOP'; + const TEST_NONCE = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'; + const TEST_CHALLENGE = `Sign this message to authenticate with TrustFlow: ${TEST_NONCE}`; + + beforeAll(() => { + redis = new Redis(process.env.REDIS_URL!); + }); + + afterAll(async () => { + await redis.quit(); + }); + + beforeEach(async () => { + // Isolate each test from prior runs/tests without touching unrelated keys another + // suite/process might be using on the same Redis instance. + const keys = await redis.keys('auth:nonce*'); + if (keys.length > 0) await redis.del(...keys); + + service = new NonceStoreService(redis); + }); + + it('stores and consumes a challenge using the real GETDEL Lua script', async () => { + await service.store(TEST_ADDRESS, TEST_CHALLENGE, TEST_NONCE); + + // Verify stored + const rawChallenge = await redis.get(`auth:nonce:${TEST_ADDRESS}`); + expect(rawChallenge).toBe(TEST_CHALLENGE); + + // Consume should use the GETDEL_LUA script to atomically get and delete + const consumed = await service.consume(TEST_ADDRESS); + expect(consumed).toBe(TEST_CHALLENGE); + + // Second consume should return null (already deleted) + const second = await service.consume(TEST_ADDRESS); + expect(second).toBeNull(); + + // Key should be gone + const afterConsume = await redis.get(`auth:nonce:${TEST_ADDRESS}`); + expect(afterConsume).toBeNull(); + }); + + it('marks nonce as used with TTL and detects replay', async () => { + await service.markNonceUsed(TEST_NONCE); + + // Verify key exists + const exists = await redis.exists(`auth:nonce:used:${TEST_NONCE}`); + expect(exists).toBe(1); + + // Verify TTL is set (should be ~300 seconds) + const ttl = await redis.ttl(`auth:nonce:used:${TEST_NONCE}`); + expect(ttl).toBeGreaterThan(290); + expect(ttl).toBeLessThanOrEqual(300); + + // Service should detect replay + const isReplay = await service.isNonceReplay(TEST_NONCE); + expect(isReplay).toBe(true); + }); + + it('detects when a challenge has expired via TTL', async () => { + await service.store(TEST_ADDRESS, TEST_CHALLENGE, TEST_NONCE); + + // Force expire by setting TTL to 0 + await redis.expire(`auth:nonce:${TEST_ADDRESS}`, 0); + + // Wait a moment for expiry to take effect + await new Promise(resolve => setTimeout(resolve, 100)); + + // Consume should return null (expired) + const consumed = await service.consume(TEST_ADDRESS); + expect(consumed).toBeNull(); + }); + + it('allows only one active challenge per address (NX semantics)', async () => { + const firstChallenge = 'First challenge'; + const secondChallenge = 'Second challenge'; + + await service.store(TEST_ADDRESS, firstChallenge, TEST_NONCE); + + // Attempt to store a second challenge — NonceStoreService replaces it + await service.store(TEST_ADDRESS, secondChallenge, `${TEST_NONCE}-2`); + + // Should get the second one (replaced) + const consumed = await service.consume(TEST_ADDRESS); + expect(consumed).toBe(secondChallenge); + }); + + it('isolates challenges by address', async () => { + const address1 = 'GADDR1XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; + const address2 = 'GADDR2XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; + + await service.store(address1, 'Challenge 1', 'nonce1'); + await service.store(address2, 'Challenge 2', 'nonce2'); + + expect(await service.consume(address1)).toBe('Challenge 1'); + expect(await service.consume(address2)).toBe('Challenge 2'); + + // Both should be consumed independently + expect(await service.consume(address1)).toBeNull(); + expect(await service.consume(address2)).toBeNull(); + }); +}); diff --git a/backend/src/auth/nonce-store.service.spec.ts b/backend/src/auth/nonce-store.service.spec.ts index f8846cd..2bb0934 100644 --- a/backend/src/auth/nonce-store.service.spec.ts +++ b/backend/src/auth/nonce-store.service.spec.ts @@ -4,6 +4,7 @@ import { REDIS_CLIENT } from '../common/redis/redis.module'; describe('NonceStoreService', () => { let service: NonceStoreService; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockRedis: any; const TEST_ADDRESS = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOP'; diff --git a/backend/src/common/rate-limit/rate-limit.guard.spec.ts b/backend/src/common/rate-limit/rate-limit.guard.spec.ts index a988275..529ab03 100644 --- a/backend/src/common/rate-limit/rate-limit.guard.spec.ts +++ b/backend/src/common/rate-limit/rate-limit.guard.spec.ts @@ -24,6 +24,7 @@ function mockContext(overrides?: { const handler = () => {}; const cls = class Mock {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any const context: any = { getHandler: () => handler, getClass: () => cls, diff --git a/backend/src/common/rate-limit/rate-limit.guard.ts b/backend/src/common/rate-limit/rate-limit.guard.ts index a53099e..06b8e9f 100644 --- a/backend/src/common/rate-limit/rate-limit.guard.ts +++ b/backend/src/common/rate-limit/rate-limit.guard.ts @@ -12,6 +12,27 @@ import { Redis } from 'ioredis'; import { randomUUID } from 'crypto'; import { REDIS_CLIENT } from '../redis/redis.module'; import { SKIP_RATE_LIMIT, RATE_LIMIT_POINTS, RATE_LIMIT_DURATION } from './rate-limit.decorator'; +import { config } from '../../config/env.config'; + +const DEFAULT_POINTS = 100; +const DEFAULT_DURATION = 60; +const DEFAULT_ABUSE_WINDOW = 300; +const DEFAULT_ABUSE_THRESHOLD = 5; +const DEFAULT_LOCKOUT_DURATION = 900; + +/** Minimal shape of the HTTP request object that rate-limiting reads from. */ +interface RateLimitRequest { + ip?: string; + method?: string; + url?: string; + route?: { path?: string }; + headers?: Record; + connection?: { remoteAddress?: string }; + user?: { address?: string; sub?: string }; + body?: Record; + query?: Record; + params?: Record; +} const DEFAULT_POINTS = 100; const DEFAULT_DURATION = 60; @@ -180,18 +201,9 @@ export class RateLimitGuard implements CanActivate { route: string, retryAfter: number, ): Promise { - const abuseWindow = this.getPositiveInteger( - 'RATE_LIMIT_ABUSE_WINDOW_SECONDS', - DEFAULT_ABUSE_WINDOW, - ); - const abuseThreshold = this.getPositiveInteger( - 'RATE_LIMIT_ABUSE_THRESHOLD', - DEFAULT_ABUSE_THRESHOLD, - ); - const lockoutDuration = this.getPositiveInteger( - 'RATE_LIMIT_LOCKOUT_SECONDS', - DEFAULT_LOCKOUT_DURATION, - ); + const abuseWindow = config.RATE_LIMIT_ABUSE_WINDOW_SECONDS; + const abuseThreshold = config.RATE_LIMIT_ABUSE_THRESHOLD; + const lockoutDuration = config.RATE_LIMIT_LOCKOUT_SECONDS; const now = Date.now(); const abuseKey = this.buildKey('abuse', identity.scope, identity.value, route); const lockoutKey = this.buildKey('lockout', identity.scope, identity.value, route); @@ -211,12 +223,11 @@ export class RateLimitGuard implements CanActivate { return Number(lockoutApplied) > 0 ? Number(lockoutApplied) : retryAfter; } - private getIdentities(request: any): RateLimitIdentity[] { + private getIdentities(request: RateLimitRequest): RateLimitIdentity[] { + const xForwarded = request.headers?.['x-forwarded-for']; + const forwarded = Array.isArray(xForwarded) ? xForwarded[0] : xForwarded?.split(',')[0]?.trim(); const ip = this.normalizeIdentity( - request.ip || - request.headers?.['x-forwarded-for']?.split(',')[0]?.trim() || - request.connection?.remoteAddress || - 'unknown', + request.ip || forwarded || request.connection?.remoteAddress || 'unknown', ); const wallet = this.extractWallet(request); const identities: RateLimitIdentity[] = [{ scope: 'ip', value: ip }]; @@ -228,7 +239,7 @@ export class RateLimitGuard implements CanActivate { return identities; } - private extractWallet(request: any): string | undefined { + private extractWallet(request: RateLimitRequest): string | undefined { return ( request.user?.address || request.user?.sub || @@ -241,7 +252,7 @@ export class RateLimitGuard implements CanActivate { ); } - private getRoute(request: any): string { + private getRoute(request: RateLimitRequest): string { const method = request.method || 'GET'; const route = request.route?.path || request.url || '/'; return this.normalizeIdentity(`${method}:${route}`); @@ -256,9 +267,4 @@ export class RateLimitGuard implements CanActivate { .toLowerCase() .replace(/[^a-z0-9:._-]/g, '_'); } - - private getPositiveInteger(name: string, fallback: number): number { - const value = Number(process.env[name]); - return Number.isInteger(value) && value > 0 ? value : fallback; - } } diff --git a/backend/src/common/rate-limit/rate-limit.redis-integration.spec.ts b/backend/src/common/rate-limit/rate-limit.redis-integration.spec.ts new file mode 100644 index 0000000..f6bf326 --- /dev/null +++ b/backend/src/common/rate-limit/rate-limit.redis-integration.spec.ts @@ -0,0 +1,272 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Reflector } from '@nestjs/core'; +import { Redis } from 'ioredis'; +import { RateLimitGuard } from './rate-limit.guard'; +import { REDIS_CLIENT } from '../redis/redis.module'; +import { HttpException, HttpStatus } from '@nestjs/common'; + +/** + * Exercises RateLimitGuard's Lua scripts (TOKEN_BUCKET_SCRIPT, ABUSE_LOCKOUT_SCRIPT) against + * a real Redis server instead of the mocked ioredis client the rest of rate-limit.guard.spec.ts + * uses. This guards against behavior the mock can't faithfully reproduce: real Lua script + * execution, real HMSET/HMGET/ZADD/ZCARD/ZREMRANGEBYSCORE operations, and real TTL/EXPIRE + * semantics. + * + * Requires REDIS_URL — CI provides a `redis:7-alpine` service container (see + * .github/workflows/backend-ci.yml). Skipped locally when REDIS_URL isn't set rather than + * failing, so `npm test` still works without a local Redis. + */ +const describeIfRedis = process.env.REDIS_URL ? describe : describe.skip; + +function mockContext(overrides?: { + ip?: string; + method?: string; + url?: string; + routePath?: string; + user?: Record; +}) { + const ip = overrides?.ip ?? '127.0.0.1'; + const url = overrides?.url ?? '/test'; + const routePath = overrides?.routePath ?? '/test'; + + const handler = () => {}; + const cls = class Mock {}; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const context: any = { + getHandler: () => handler, + getClass: () => cls, + switchToHttp: () => ({ + getRequest: () => ({ + ip, + method: overrides?.method ?? 'GET', + url, + route: { path: routePath }, + headers: {}, + connection: { remoteAddress: '::1' }, + user: overrides?.user, + }), + }), + }; + + return { context }; +} + +function createReflector(overrides?: { skip?: boolean; points?: number; duration?: number }) { + return { + getAllAndOverride: jest.fn((key: string) => { + if (key === 'SKIP_RATE_LIMIT') return overrides?.skip; + if (key === 'RATE_LIMIT_POINTS') return overrides?.points; + if (key === 'RATE_LIMIT_DURATION') return overrides?.duration; + return undefined; + }), + } as unknown as Reflector; +} + +describeIfRedis('RateLimitGuard (Redis integration)', () => { + let redis: Redis; + let guard: RateLimitGuard; + + beforeAll(() => { + redis = new Redis(process.env.REDIS_URL!); + }); + + afterAll(async () => { + await redis.quit(); + }); + + beforeEach(async () => { + // Isolate each test from prior runs/tests + const keys = await redis.keys('ratelimit:*'); + if (keys.length > 0) await redis.del(...keys); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RateLimitGuard, + { provide: REDIS_CLIENT, useValue: redis }, + { provide: Reflector, useValue: createReflector({ points: 5, duration: 10 }) }, + ], + }).compile(); + + guard = module.get(RateLimitGuard); + }); + + it('allows requests while the token bucket has capacity', async () => { + const { context } = mockContext({ ip: '10.0.0.1' }); + + // First 5 requests should succeed (capacity = 5) + for (let i = 0; i < 5; i++) { + await expect(guard.canActivate(context)).resolves.toBe(true); + } + + // 6th request should be rejected (bucket exhausted) + await expect(guard.canActivate(context)).rejects.toThrow(HttpException); + }); + + it('refills the bucket over time using real TOKEN_BUCKET_SCRIPT', async () => { + const { context } = mockContext({ ip: '10.0.0.2' }); + + // Exhaust the bucket (5 requests) + for (let i = 0; i < 5; i++) { + await guard.canActivate(context); + } + + // Next request should fail + await expect(guard.canActivate(context)).rejects.toThrow(HttpException); + + // Wait for ~2 seconds (20% of the 10-second refill duration = 1 token) + await new Promise(resolve => setTimeout(resolve, 2100)); + + // Should have refilled ~1 token + await expect(guard.canActivate(context)).resolves.toBe(true); + + // But not more than 1 + await expect(guard.canActivate(context)).rejects.toThrow(HttpException); + }); + + it('records abuse events in a sorted set and triggers lockout', async () => { + // Lower the abuse threshold for faster testing + process.env.RATE_LIMIT_ABUSE_THRESHOLD = '3'; + process.env.RATE_LIMIT_LOCKOUT_SECONDS = '5'; + + const { context } = mockContext({ ip: '10.0.0.3' }); + + // Exhaust the bucket + for (let i = 0; i < 5; i++) { + await guard.canActivate(context); + } + + // Trigger 3 abuse events (bucket rejections) + for (let i = 0; i < 3; i++) { + try { + await guard.canActivate(context); + } catch (e) { + // Expected rejection + } + } + + // Verify lockout key exists with TTL + const lockoutKey = 'ratelimit:lockout:ip:10.0.0.3:get:_test'; + const lockoutTtl = await redis.ttl(lockoutKey); + expect(lockoutTtl).toBeGreaterThan(0); + expect(lockoutTtl).toBeLessThanOrEqual(5); + + // Verify abuse sorted set exists + const abuseKey = 'ratelimit:abuse:ip:10.0.0.3:get:_test'; + const abuseCount = await redis.zcard(abuseKey); + expect(abuseCount).toBeGreaterThanOrEqual(3); + + // New requests should be immediately rejected during lockout + await expect(guard.canActivate(context)).rejects.toThrow( + expect.objectContaining({ + message: expect.objectContaining({ + message: 'Too many requests - rate limit exceeded', + }), + }), + ); + + // Wait for lockout to expire + await new Promise(resolve => setTimeout(resolve, 5500)); + + // Should be able to make requests again (bucket refilled during lockout) + await expect(guard.canActivate(context)).resolves.toBe(true); + + // Clean up env vars + delete process.env.RATE_LIMIT_ABUSE_THRESHOLD; + delete process.env.RATE_LIMIT_LOCKOUT_SECONDS; + }); + + it('enforces both IP and wallet buckets independently', async () => { + const { context: ipOnlyContext } = mockContext({ ip: '10.0.0.4' }); + const { context: walletContext } = mockContext({ + ip: '10.0.0.4', + user: { address: 'GWALLET1' }, + }); + + // Exhaust IP bucket without wallet + for (let i = 0; i < 5; i++) { + await guard.canActivate(ipOnlyContext); + } + + // IP bucket exhausted + await expect(guard.canActivate(ipOnlyContext)).rejects.toThrow(HttpException); + + // But wallet-authenticated requests should still check the wallet bucket (fresh) + // The IP bucket is still exhausted, so this will fail on IP + await expect(guard.canActivate(walletContext)).rejects.toThrow(HttpException); + + // Wait for IP bucket to refill slightly + await new Promise(resolve => setTimeout(resolve, 2100)); + + // Now wallet request should succeed (both IP and wallet buckets allow it) + await expect(guard.canActivate(walletContext)).resolves.toBe(true); + }); + + it('cleans up old abuse entries via ZREMRANGEBYSCORE', async () => { + process.env.RATE_LIMIT_ABUSE_WINDOW_SECONDS = '2'; // 2-second abuse window + process.env.RATE_LIMIT_ABUSE_THRESHOLD = '10'; // High threshold so we don't lock out + + const { context } = mockContext({ ip: '10.0.0.5' }); + + // Exhaust bucket + for (let i = 0; i < 5; i++) { + await guard.canActivate(context); + } + + // Trigger 2 abuse events + for (let i = 0; i < 2; i++) { + try { + await guard.canActivate(context); + } catch (e) { + // Expected + } + } + + const abuseKey = 'ratelimit:abuse:ip:10.0.0.5:get:_test'; + let abuseCount = await redis.zcard(abuseKey); + expect(abuseCount).toBe(2); + + // Wait for abuse window to pass + await new Promise(resolve => setTimeout(resolve, 2500)); + + // Trigger another abuse event — the script should clean up old entries + try { + await guard.canActivate(context); + } catch (e) { + // Expected + } + + // Old entries should be cleaned (ZREMRANGEBYSCORE in ABUSE_LOCKOUT_SCRIPT) + abuseCount = await redis.zcard(abuseKey); + expect(abuseCount).toBe(1); // Only the most recent event + + delete process.env.RATE_LIMIT_ABUSE_WINDOW_SECONDS; + delete process.env.RATE_LIMIT_ABUSE_THRESHOLD; + }); + + it('persists bucket state across guard instances (real Redis storage)', async () => { + const { context } = mockContext({ ip: '10.0.0.6' }); + + // Use 3 tokens with first guard instance + for (let i = 0; i < 3; i++) { + await guard.canActivate(context); + } + + // Create a new guard instance (simulates app restart) + const module2: TestingModule = await Test.createTestingModule({ + providers: [ + RateLimitGuard, + { provide: REDIS_CLIENT, useValue: redis }, + { provide: Reflector, useValue: createReflector({ points: 5, duration: 10 }) }, + ], + }).compile(); + const guard2 = module2.get(RateLimitGuard); + + // Should still have 2 tokens available (5 - 3 = 2) + await expect(guard2.canActivate(context)).resolves.toBe(true); + await expect(guard2.canActivate(context)).resolves.toBe(true); + + // Now bucket should be exhausted + await expect(guard2.canActivate(context)).rejects.toThrow(HttpException); + }); +}); diff --git a/backend/src/common/redis/redis.module.ts b/backend/src/common/redis/redis.module.ts index f032c44..86fd802 100644 --- a/backend/src/common/redis/redis.module.ts +++ b/backend/src/common/redis/redis.module.ts @@ -1,6 +1,7 @@ import { Module, Global, Logger } from '@nestjs/common'; import { Redis } from 'ioredis'; import { DistributedLockService } from './distributed-lock.service'; +import { config } from '../config/env.config'; export const REDIS_CLIENT = 'REDIS_CLIENT'; @@ -20,7 +21,7 @@ export const REDIS_CLIENT = 'REDIS_CLIENT'; * features degrade until it recovers. */ export function createRedisClient(logger: Logger = new Logger('RedisModule')): Redis | null { - const url = process.env.REDIS_URL; + const url = config.REDIS_URL; if (!url) { logger.warn( 'REDIS_URL not set — Redis-backed features (rate limiting, outbox relay, caches) are disabled', @@ -34,17 +35,17 @@ export function createRedisClient(logger: Logger = new Logger('RedisModule')): R lazyConnect: true, }); - client.on('error', err => - logger.error(`Redis client error: ${err.message}`, err.stack), - ); + client.on('error', err => logger.error(`Redis client error: ${err.message}`, err.stack)); client.on('connect', () => logger.log('Redis connected')); client.on('reconnecting', () => logger.warn('Redis reconnecting…')); - client.connect().catch((err: Error) => - logger.error( - `Initial Redis connection failed (will keep retrying per retryStrategy): ${err.message}`, - ), - ); + client + .connect() + .catch((err: Error) => + logger.error( + `Initial Redis connection failed (will keep retrying per retryStrategy): ${err.message}`, + ), + ); return client; } diff --git a/backend/src/config/env.config.spec.ts b/backend/src/config/env.config.spec.ts new file mode 100644 index 0000000..cfde68f --- /dev/null +++ b/backend/src/config/env.config.spec.ts @@ -0,0 +1,151 @@ +import { validateEnv } from './env.config'; + +describe('Environment Configuration', () => { + const originalEnv = process.env; + + beforeEach(() => { + // Reset process.env before each test + process.env = { ...originalEnv }; + // Clear the cached config + jest.resetModules(); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should validate and return config with all defaults when only JWT_SECRET is set', () => { + process.env = { + JWT_SECRET: 'test-secret-at-least-16-chars', + }; + + const { validateEnv: freshValidateEnv } = jest.requireActual('./env.config'); + const config = freshValidateEnv(); + + expect(config.JWT_SECRET).toBe('test-secret-at-least-16-chars'); + expect(config.NODE_ENV).toBe('development'); + expect(config.PORT).toBe(3001); + expect(config.STELLAR_NETWORK).toBe('TESTNET'); + expect(config.STELLAR_HORIZON_URL).toBe('https://horizon-testnet.stellar.org'); + }); + + it('should throw when JWT_SECRET is missing', () => { + process.env = {}; + + const { validateEnv: freshValidateEnv } = jest.requireActual('./env.config'); + + expect(() => freshValidateEnv()).toThrow('Environment variable validation failed'); + expect(() => freshValidateEnv()).toThrow('JWT_SECRET'); + }); + + it('should throw when JWT_SECRET is too short', () => { + process.env = { + JWT_SECRET: 'short', + }; + + const { validateEnv: freshValidateEnv } = jest.requireActual('./env.config'); + + expect(() => freshValidateEnv()).toThrow('JWT_SECRET must be at least 16 characters'); + }); + + it('should throw when PORT is not a number', () => { + process.env = { + JWT_SECRET: 'test-secret-at-least-16-chars', + PORT: 'not-a-number', + }; + + const { validateEnv: freshValidateEnv } = jest.requireActual('./env.config'); + + expect(() => freshValidateEnv()).toThrow('Environment variable validation failed'); + }); + + it('should throw when NODE_ENV is invalid', () => { + process.env = { + JWT_SECRET: 'test-secret-at-least-16-chars', + NODE_ENV: 'invalid-env', + }; + + const { validateEnv: freshValidateEnv } = jest.requireActual('./env.config'); + + expect(() => freshValidateEnv()).toThrow('Environment variable validation failed'); + }); + + it('should accept valid production config', () => { + process.env = { + NODE_ENV: 'production', + JWT_SECRET: 'production-secret-at-least-16-chars', + PORT: '8080', + CORS_ORIGIN: 'https://trustflow.xyz', + API_URL: 'https://api.trustflow.xyz', + STELLAR_NETWORK: 'PUBLIC', + STELLAR_HORIZON_URL: 'https://horizon.stellar.org', + SOROBAN_RPC_URL: 'https://soroban-rpc.stellar.org', + TRUSTFLOW_CONTRACT_ID: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4', + REDIS_URL: 'redis://localhost:6379', + SENTRY_DSN: 'https://example@sentry.io/123456', + }; + + const { validateEnv: freshValidateEnv } = jest.requireActual('./env.config'); + const config = freshValidateEnv(); + + expect(config.NODE_ENV).toBe('production'); + expect(config.PORT).toBe(8080); + expect(config.STELLAR_NETWORK).toBe('PUBLIC'); + expect(config.REDIS_URL).toBe('redis://localhost:6379'); + }); + + it('should throw when TRUSTFLOW_CONTRACT_ID has invalid format', () => { + process.env = { + JWT_SECRET: 'test-secret-at-least-16-chars', + TRUSTFLOW_CONTRACT_ID: 'invalid-contract-id', + }; + + const { validateEnv: freshValidateEnv } = jest.requireActual('./env.config'); + + expect(() => freshValidateEnv()).toThrow( + 'TRUSTFLOW_CONTRACT_ID must be a valid Stellar contract address', + ); + }); + + it('should throw when URL fields are malformed', () => { + process.env = { + JWT_SECRET: 'test-secret-at-least-16-chars', + STELLAR_HORIZON_URL: 'not-a-url', + }; + + const { validateEnv: freshValidateEnv } = jest.requireActual('./env.config'); + + expect(() => freshValidateEnv()).toThrow('Environment variable validation failed'); + }); + + it('should coerce numeric environment variables from strings', () => { + process.env = { + JWT_SECRET: 'test-secret-at-least-16-chars', + PORT: '4000', + BODY_LIMIT_MB: '20', + RATE_LIMIT_ABUSE_THRESHOLD: '10', + EVENT_PROCESSING_CONCURRENCY: '16', + }; + + const { validateEnv: freshValidateEnv } = jest.requireActual('./env.config'); + const config = freshValidateEnv(); + + expect(config.PORT).toBe(4000); + expect(config.BODY_LIMIT_MB).toBe(20); + expect(config.RATE_LIMIT_ABUSE_THRESHOLD).toBe(10); + expect(config.EVENT_PROCESSING_CONCURRENCY).toBe(16); + }); + + it('should accept MAINNET as legacy alias for PUBLIC', () => { + process.env = { + JWT_SECRET: 'test-secret-at-least-16-chars', + STELLAR_NETWORK: 'MAINNET', + }; + + const { validateEnv: freshValidateEnv } = jest.requireActual('./env.config'); + const config = freshValidateEnv(); + + // The validation should accept MAINNET, but stellar.config.ts normalizes it to PUBLIC + expect(config.STELLAR_NETWORK).toBe('MAINNET'); + }); +}); diff --git a/backend/src/config/env.config.ts b/backend/src/config/env.config.ts new file mode 100644 index 0000000..b084651 --- /dev/null +++ b/backend/src/config/env.config.ts @@ -0,0 +1,153 @@ +import { z } from 'zod'; + +/** + * Centralized environment variable validation using Zod. + * + * Every environment variable read by the application is declared here with its type, + * format constraints, and required-vs-optional-with-default semantics. Validation + * runs once at startup (see validateEnv() in main.ts) and fails fast with a readable + * error listing every invalid/missing variable, rather than silently falling through + * to defaults or unusable values scattered across the codebase. + * + * After validation, typed config values are exported via the `config` object for use + * throughout the application, replacing inline `process.env.X || fallback` reads. + */ + +const EnvSchema = z.object({ + // Node environment + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + + // Server configuration + PORT: z.coerce.number().int().positive().default(3001), + CORS_ORIGIN: z.string().optional(), + API_URL: z.string().url().optional().default('http://localhost:3001'), + BODY_LIMIT_MB: z.coerce.number().int().positive().default(15), + + // Authentication & Security + JWT_SECRET: z.string().min(16, 'JWT_SECRET must be at least 16 characters for security'), + ADMIN_ADDRESSES: z + .string() + .optional() + .describe('Comma-separated list of Stellar addresses with admin access'), + + // Stellar Network Configuration + STELLAR_NETWORK: z.enum(['TESTNET', 'PUBLIC', 'MAINNET']).default('TESTNET'), + STELLAR_HORIZON_URL: z.string().url().default('https://horizon-testnet.stellar.org'), + SOROBAN_RPC_URL: z.string().url().default('https://soroban-testnet.stellar.org'), + TRUSTFLOW_CONTRACT_ID: z + .string() + .regex(/^C[A-Z2-7]{55}$/, 'TRUSTFLOW_CONTRACT_ID must be a valid Stellar contract address') + .optional() + .describe('Required for on-chain operations; optional for off-chain-only deployments'), + + // Stellar failover endpoints (comma-separated URLs) + STELLAR_HORIZON_ENDPOINTS: z.string().optional(), + SOROBAN_RPC_ENDPOINTS: z.string().optional(), + + // Redis Configuration + REDIS_URL: z + .string() + .url() + .optional() + .describe('Required for rate limiting, outbox relay, and distributed caches'), + + // Database Configuration (PostgreSQL) + DATABASE_URL: z + .string() + .url() + .optional() + .describe('PostgreSQL connection string; currently optional infrastructure'), + + // Monitoring & Observability + SENTRY_DSN: z + .string() + .url() + .optional() + .describe('Sentry error tracking DSN; errors are logged but not reported when unset'), + + // Discord Integration + DISCORD_WEBHOOK_URL: z + .string() + .url() + .optional() + .describe('Discord webhook for dispute notifications'), + + // Rate Limiting Configuration + RATE_LIMIT_ABUSE_WINDOW_SECONDS: z.coerce.number().int().positive().default(300), + RATE_LIMIT_ABUSE_THRESHOLD: z.coerce.number().int().positive().default(5), + RATE_LIMIT_LOCKOUT_SECONDS: z.coerce.number().int().positive().default(900), + + // Event Processing Configuration + EVENT_PROCESSING_CONCURRENCY: z.coerce.number().int().positive().default(8), + + // IPFS Pinning Configuration + IPFS_PINATA_JWT: z.string().optional().describe('Pinata API JWT token'), + IPFS_WEB3_STORAGE_TOKEN: z.string().optional().describe('Web3.Storage API token'), + IPFS_INFURA_PROJECT_ID: z.string().optional().describe('Infura IPFS project ID'), + IPFS_INFURA_PROJECT_SECRET: z.string().optional().describe('Infura IPFS project secret'), + + // Reputation System Configuration + REPUTATION_DECAY_HALF_LIFE_MS: z.coerce.number().int().positive().optional(), +}); + +export type EnvConfig = z.infer; + +let validatedConfig: EnvConfig | null = null; + +/** + * Validate environment variables against the schema and cache the result. + * + * This function is called once at startup (in main.ts). If validation fails, + * it throws a ZodError with a readable message listing all invalid/missing + * variables. The error includes paths and reasons for each failed field. + * + * @throws {z.ZodError} when required variables are missing or malformed + */ +export function validateEnv(): EnvConfig { + if (validatedConfig) { + return validatedConfig; + } + + try { + validatedConfig = EnvSchema.parse(process.env); + return validatedConfig; + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessage = error.errors + .map(err => ` - ${err.path.join('.')}: ${err.message}`) + .join('\n'); + + throw new Error( + `Environment variable validation failed:\n${errorMessage}\n\n` + + `Fix the above issues in your .env file or environment configuration.`, + ); + } + throw error; + } +} + +/** + * Get the validated config object. Must call validateEnv() first (typically in main.ts). + * + * @throws {Error} if validateEnv() hasn't been called yet + */ +export function getConfig(): EnvConfig { + if (!validatedConfig) { + throw new Error('Config not initialized. Call validateEnv() first.'); + } + return validatedConfig; +} + +/** + * Exported config object for convenient access throughout the application. + * Replaces scattered `process.env.X || fallback` reads with typed, validated values. + * + * Usage: + * import { config } from './config/env.config'; + * const port = config.PORT; // typed as number, guaranteed to be valid + */ +export const config = new Proxy({} as EnvConfig, { + get(_target, prop: string) { + return getConfig()[prop as keyof EnvConfig]; + }, +}); diff --git a/backend/src/event-ingestion/event-ingestion.service.ts b/backend/src/event-ingestion/event-ingestion.service.ts index adc788f..8fcf99e 100644 --- a/backend/src/event-ingestion/event-ingestion.service.ts +++ b/backend/src/event-ingestion/event-ingestion.service.ts @@ -1,12 +1,13 @@ import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; -import { rpc as SorobanRpc } from '@stellar/stellar-sdk'; +import { rpc as SorobanRpc, xdr } from '@stellar/stellar-sdk'; import { LedgerCursorService, LedgerCheckpoint } from './ledger-cursor.service'; import { EventProcessorService, SorobanEvent, ProcessedEvent } from './event-processor.service'; import { STELLAR_CONFIG } from '../stellar/stellar.config'; import { mapWithConcurrency } from '../common/concurrency'; +import { config } from '../config/env.config'; /** How many independent escrows to process in parallel per ingestion batch (#238). */ -const EVENT_PROCESSING_CONCURRENCY = Number(process.env.EVENT_PROCESSING_CONCURRENCY) || 8; +const EVENT_PROCESSING_CONCURRENCY = config.EVENT_PROCESSING_CONCURRENCY; @Injectable() export class EventIngestionService implements OnModuleInit, OnModuleDestroy { @@ -188,9 +189,9 @@ export class EventIngestionService implements OnModuleInit, OnModuleDestroy { while (currentStart <= endLedger) { const batchEnd = Math.min(currentStart + 99, endLedger); - + // Build request parameters — when using cursor, omit startLedger and endLedger - const getEventsParams: any = { + const getEventsParams: SorobanRpc.Server.GetEventsRequest = { filters: [ { type: 'contract', @@ -242,25 +243,25 @@ export class EventIngestionService implements OnModuleInit, OnModuleDestroy { contractId: event.contractId?.toString() || '', eventType: topics[0] || 'unknown', topic: topics, - value: parsedValue, - xdr: event.value.toXDR().toString(), + value: typeof parsedValue === 'string' ? { raw: parsedValue } : parsedValue, + xdr: event.value.toXDR().toString('base64'), createdAt: new Date(), }; } - private parseEventValue(value: any): any { + private parseEventValue(value: xdr.ScVal): Record | string { try { if (value.switch().name === 'SCV_BYTES') { const bytes = value.bytes(); - return JSON.parse(Buffer.from(bytes).toString()); + return JSON.parse(Buffer.from(bytes).toString()) as Record; } - return value.toXDR(); + return value.toXDR().toString(); } catch { - return value.toXDR(); + return value.toXDR().toString(); } } - private parseTopic(topic: any): string { + private parseTopic(topic: xdr.ScVal): string { try { if (topic.switch().name === 'SCV_SYMBOL') { return topic.sym().toString(); @@ -268,7 +269,7 @@ export class EventIngestionService implements OnModuleInit, OnModuleDestroy { if (topic.switch().name === 'SCV_BYTES') { return Buffer.from(topic.bytes()).toString(); } - return topic.toXDR(); + return topic.toXDR().toString(); } catch { return 'unknown'; } diff --git a/backend/src/event-ingestion/event-processor.service.ts b/backend/src/event-ingestion/event-processor.service.ts index 3956d53..7bf105f 100644 --- a/backend/src/event-ingestion/event-processor.service.ts +++ b/backend/src/event-ingestion/event-processor.service.ts @@ -7,7 +7,8 @@ export interface SorobanEvent { contractId: string; eventType: string; topic: string[]; - value: any; + /** Raw decoded XDR value — type depends on event schema; callers narrow before use. */ + value: Record; xdr: string; createdAt: Date; } diff --git a/backend/src/ipfs-pinning/ipfs-pinning.e2e-spec.ts b/backend/src/ipfs-pinning/ipfs-pinning.e2e-spec.ts index b9d7392..dffa7fc 100644 --- a/backend/src/ipfs-pinning/ipfs-pinning.e2e-spec.ts +++ b/backend/src/ipfs-pinning/ipfs-pinning.e2e-spec.ts @@ -125,7 +125,7 @@ describe('IPFS Pinning (API integration)', () => { expect(res.body.cid).toBe(EXPECTED_CID); expect(res.body.status).toBe('HEALTHY'); expect(res.body.providers).toHaveLength(2); - expect(res.body.providers.every((p: any) => p.status === 'PINNED')).toBe(true); + expect(res.body.providers.every((p: { status: string }) => p.status === 'PINNED')).toBe(true); expect(providerA.pinned.has(EXPECTED_CID)).toBe(true); expect(providerB.pinned.has(EXPECTED_CID)).toBe(true); }); @@ -152,7 +152,7 @@ describe('IPFS Pinning (API integration)', () => { expect(res.body.status).toBe('DEGRADED'); const byProvider = Object.fromEntries( - res.body.providers.map((p: any) => [p.provider, p.status]), + res.body.providers.map((p: { provider: string; status: string }) => [p.provider, p.status]), ); expect(byProvider[PinProviderName.PINATA]).toBe('FAILED'); expect(byProvider[PinProviderName.WEB3_STORAGE]).toBe('PINNED'); @@ -182,7 +182,7 @@ describe('IPFS Pinning (API integration)', () => { .get('/ipfs/pins') .set('Authorization', authHeader) .expect(200); - expect(list.body.some((p: any) => p.cid === EXPECTED_CID)).toBe(true); + expect(list.body.some((p: { cid: string }) => p.cid === EXPECTED_CID)).toBe(true); const single = await request(app.getHttpServer()) .get(`/ipfs/pins/${EXPECTED_CID}`) diff --git a/backend/src/main.ts b/backend/src/main.ts index 7529d11..008c8a6 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -9,9 +9,23 @@ import { SentryExceptionFilter } from './common/filters/sentry-exception.filter' import { SorobanEventIndexerService } from './soroban-event-indexer/soroban-event-indexer.service'; import { MetricsHttpInterceptor } from './monitoring/metrics-http.interceptor'; import { CorrelationIdStore } from './common/logging/correlation-id.store'; +import { validateEnv, config } from './config/env.config'; const logger = new Logger('Bootstrap'); +// Validate environment variables at startup before anything else runs. +// This fails fast with a clear error if required variables are missing or malformed, +// rather than allowing the app to start with invalid config that only surfaces as +// runtime errors later. +try { + validateEnv(); + logger.log('✓ Environment variables validated successfully'); +} catch (error) { + logger.error('Environment variable validation failed:'); + logger.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} + // Capture unhandled promise rejections before the app is ready process.on('unhandledRejection', (reason: unknown) => { Sentry.captureException(reason); @@ -36,8 +50,7 @@ async function bootstrap() { // encodes to ~13.6 MB base64, so a 15 MB JSON limit gives adequate headroom while // still providing a deliberate, reviewed DoS control rather than relying on Express's // implicit default. Override via BODY_LIMIT_MB env var if your use case requires it. - const bodyLimitMb = parseInt(process.env.BODY_LIMIT_MB || '15', 10); - const bodyLimit = `${bodyLimitMb}mb`; + const bodyLimit = `${config.BODY_LIMIT_MB}mb`; app.use(express.json({ limit: bodyLimit })); app.use(express.urlencoded({ extended: true, limit: bodyLimit })); @@ -54,8 +67,8 @@ async function bootstrap() { app.useGlobalInterceptors(metricsInterceptor); // Enable CORS - const corsOrigin = process.env.CORS_ORIGIN; - const nodeEnv = process.env.NODE_ENV || 'development'; + const corsOrigin = config.CORS_ORIGIN; + const nodeEnv = config.NODE_ENV; // Validate CORS configuration in production if (nodeEnv === 'production' && (!corsOrigin || corsOrigin === '*')) { @@ -113,7 +126,7 @@ async function bootstrap() { .setVersion('1.0.0') .setContact('TrustFlow Protocol', 'https://trustflow.xyz', 'support@trustflow.xyz') .setLicense('MIT', 'https://opensource.org/licenses/MIT') - .addServer(process.env.API_URL || 'http://localhost:3001', 'Development') + .addServer(config.API_URL, 'Development') .addServer('https://api.trustflow.xyz', 'Production') .addBearerAuth( { @@ -176,7 +189,7 @@ async function bootstrap() { const indexer = app.get(SorobanEventIndexerService); indexer.start(); - const port = process.env.PORT || 3001; + const port = config.PORT; await app.listen(port); logger.log(`🚀 TrustFlow API running on: http://localhost:${port}`); diff --git a/backend/src/monitoring/health.controller.ts b/backend/src/monitoring/health.controller.ts index c0b514f..630b373 100644 --- a/backend/src/monitoring/health.controller.ts +++ b/backend/src/monitoring/health.controller.ts @@ -31,7 +31,7 @@ export class HealthController { }, }) @ApiResponse({ status: 503, description: 'Service is unhealthy' }) - async getHealth(): Promise { + async getHealth(): Promise { return this.health.check(); } diff --git a/backend/src/monitoring/health.service.ts b/backend/src/monitoring/health.service.ts index e4f57d8..a7e5eb8 100644 --- a/backend/src/monitoring/health.service.ts +++ b/backend/src/monitoring/health.service.ts @@ -2,6 +2,7 @@ import { Inject, Injectable, Optional } from '@nestjs/common'; import type { Redis } from 'ioredis'; import { DatabaseService } from '../common/database/database.service'; import { REDIS_CLIENT } from '../common/redis/redis.module'; +import { config } from '../config/env.config'; export interface HealthStatus { status: 'ok' | 'degraded' | 'down'; @@ -62,7 +63,7 @@ export class HealthService { private async checkStellar(): Promise { try { - const url = process.env.STELLAR_HORIZON_URL || 'https://horizon-testnet.stellar.org'; + const url = config.STELLAR_HORIZON_URL; const r = await fetch(`${url}/`); return r.ok; } catch { diff --git a/backend/src/monitoring/metrics-http.interceptor.ts b/backend/src/monitoring/metrics-http.interceptor.ts index a2c14c5..707b9e7 100644 --- a/backend/src/monitoring/metrics-http.interceptor.ts +++ b/backend/src/monitoring/metrics-http.interceptor.ts @@ -1,10 +1,4 @@ -import { - Injectable, - NestInterceptor, - ExecutionContext, - CallHandler, - Logger, -} from '@nestjs/common'; +import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap, catchError } from 'rxjs/operators'; import { Request, Response } from 'express'; @@ -24,7 +18,7 @@ export class MetricsHttpInterceptor implements NestInterceptor { constructor(private readonly metrics: MetricsService) {} - intercept(context: ExecutionContext, next: CallHandler): Observable { + intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); const response = context.switchToHttp().getResponse(); @@ -62,7 +56,7 @@ export class MetricsHttpInterceptor implements NestInterceptor { `${method} ${route} completed with status ${statusCode} in ${duration}ms`, ); }), - catchError((error) => { + catchError(error => { const duration = Date.now() - startTime; const statusCode = error.status || 500; diff --git a/backend/src/stellar/soroban.helper.ts b/backend/src/stellar/soroban.helper.ts index 8e8ec7b..35aa137 100644 --- a/backend/src/stellar/soroban.helper.ts +++ b/backend/src/stellar/soroban.helper.ts @@ -1,4 +1,5 @@ import { rpc as SorobanRpc, Transaction, Networks } from '@stellar/stellar-sdk'; +import { config } from '../config/env.config'; export async function simulateTransaction( rpcUrl: string, @@ -11,7 +12,9 @@ export async function simulateTransaction( }, rpcUrl); } -export function isSimulationError(result: any): boolean { +export function isSimulationError( + result: SorobanRpc.Api.SimulateTransactionResponse, +): result is SorobanRpc.Api.SimulateTransactionErrorResponse { return false; } @@ -26,14 +29,12 @@ export async function withSorobanFailover( const endpoints = primaryEndpoint ? [ primaryEndpoint, - ...(process.env.SOROBAN_RPC_ENDPOINTS || '') + ...(config.SOROBAN_RPC_ENDPOINTS || '') .split(',') .filter(Boolean) - .map(url => url.trim()), + .map((url: string) => url.trim()), ] - : (process.env.SOROBAN_RPC_ENDPOINTS || 'https://soroban-testnet.stellar.org') - .split(',') - .map(url => url.trim()); + : config.SOROBAN_RPC_URL.split(',').map((url: string) => url.trim()); // Remove duplicates const uniqueEndpoints = [...new Set(endpoints.filter(Boolean))]; diff --git a/backend/src/stellar/stellar.config.ts b/backend/src/stellar/stellar.config.ts index abcfb13..91ee992 100644 --- a/backend/src/stellar/stellar.config.ts +++ b/backend/src/stellar/stellar.config.ts @@ -1,3 +1,5 @@ +import { config } from '../config/env.config'; + /** * Canonical accepted values for STELLAR_NETWORK: * - "TESTNET" — Stellar Testnet (Test SDF Network ; September 2015) @@ -9,7 +11,7 @@ * An unrecognized value causes the app to throw at startup rather than silently * defaulting to testnet (which would be a dangerous misconfiguration in production). */ -const rawNetwork = process.env.STELLAR_NETWORK || 'TESTNET'; +const rawNetwork = config.STELLAR_NETWORK; // Accept "MAINNET" as an alias for "PUBLIC" (legacy compatibility) const normalizedNetwork = rawNetwork === 'MAINNET' ? 'PUBLIC' : rawNetwork; @@ -33,15 +35,15 @@ const NETWORK_PASSPHRASES: Record = { export const STELLAR_CONFIG = { network: normalizedNetwork as StellarNetworkValue, // Single endpoint fallback (for backward compatibility) - horizonUrl: process.env.STELLAR_HORIZON_URL || 'https://horizon-testnet.stellar.org', - sorobanRpcUrl: process.env.SOROBAN_RPC_URL || 'https://soroban-testnet.stellar.org', + horizonUrl: config.STELLAR_HORIZON_URL, + sorobanRpcUrl: config.SOROBAN_RPC_URL, // Multiple endpoints for failover (comma-separated) - horizonEndpoints: (process.env.STELLAR_HORIZON_ENDPOINTS || 'https://horizon-testnet.stellar.org') + horizonEndpoints: (config.STELLAR_HORIZON_ENDPOINTS || config.STELLAR_HORIZON_URL) .split(',') .map(url => url.trim()), - sorobanRpcEndpoints: (process.env.SOROBAN_RPC_ENDPOINTS || 'https://soroban-testnet.stellar.org') + sorobanRpcEndpoints: (config.SOROBAN_RPC_ENDPOINTS || config.SOROBAN_RPC_URL) .split(',') .map(url => url.trim()), - contractId: process.env.TRUSTFLOW_CONTRACT_ID || '', + contractId: config.TRUSTFLOW_CONTRACT_ID || '', networkPassphrase: NETWORK_PASSPHRASES[normalizedNetwork as StellarNetworkValue], }; diff --git a/backend/src/stellar/stellar.service.ts b/backend/src/stellar/stellar.service.ts index 74acfa0..f5b9642 100644 --- a/backend/src/stellar/stellar.service.ts +++ b/backend/src/stellar/stellar.service.ts @@ -47,7 +47,9 @@ export class StellarService { } throw error; } - const native = account.balances.find((b: any) => b.asset_type === 'native'); + const native = account.balances.find( + (b: Horizon.HorizonApi.BalanceLine) => b.asset_type === 'native', + ); return native?.balance ?? '0'; }); } diff --git a/backend/src/webhook/discord.service.ts b/backend/src/webhook/discord.service.ts index 63684a7..7b92278 100644 --- a/backend/src/webhook/discord.service.ts +++ b/backend/src/webhook/discord.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import * as https from 'https'; +import { config } from '../config/env.config'; interface DiscordEmbed { title: string; @@ -20,7 +21,7 @@ export class DiscordService { private readonly webhookUrl: string; constructor() { - this.webhookUrl = process.env.DISCORD_WEBHOOK_URL || ''; + this.webhookUrl = config.DISCORD_WEBHOOK_URL || ''; } /** @@ -64,7 +65,9 @@ export class DiscordService { await this.sendWebhook(payload); this.logger.log(`Discord notification sent for dispute: ${disputeData.escrowId}`); } catch (error) { - this.logger.error(`Failed to send Discord notification: ${error.message}`); + this.logger.error( + `Failed to send Discord notification: ${error instanceof Error ? error.message : String(error)}`, + ); } } @@ -97,9 +100,7 @@ export class DiscordService { req.on('timeout', () => { req.destroy( - new Error( - `Discord webhook timed out after ${DiscordService.WEBHOOK_TIMEOUT_MS}ms`, - ), + new Error(`Discord webhook timed out after ${DiscordService.WEBHOOK_TIMEOUT_MS}ms`), ); }); diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 62efb0d..9f29df5 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -13,7 +13,7 @@ "incremental": true, "skipLibCheck": true, "strictNullChecks": true, - "noImplicitAny": false, + "noImplicitAny": true, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false, diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1c8114b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,86 @@ +{ + "name": "trustflow-backend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "lint-staged": "^17.4.1" + } + }, + "node_modules/lint-staged": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.4.1.tgz", + "integrity": "sha512-FmJeudcalbSfg1du+JCfvi5vS6Qt08KgbfLWiHinbef+2JJwUZwAWVoaO1AcJVUTWPfk0t30PMQNwPAeCzYQ+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^4.0.7", + "string-argv": "^0.3.2", + "tinyexec": "^1.3.0" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=22.22.1" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + }, + "optionalDependencies": { + "yaml": "^2.9.0" + } + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9eab229 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "lint-staged": "^17.4.1" + } +}