diff --git a/jest-setup.ts b/jest-setup.ts new file mode 100644 index 0000000..01b67c7 --- /dev/null +++ b/jest-setup.ts @@ -0,0 +1,4 @@ +process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'; +process.env.REDIS_URL = 'redis://localhost:6379'; +process.env.JWT_SECRET = 'test-secret'; +process.env.PLATFORM_DOMAIN = 'http://localhost:3001'; diff --git a/jest.config.ts b/jest.config.ts index 8781e9a..317d69b 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -10,6 +10,7 @@ const config: Config = { collectCoverageFrom: ['**/*.ts', '!main.ts', '!index.ts', '!**/*.module.ts'], coverageDirectory: '../coverage', testEnvironment: 'node', + setupFiles: ['../jest-setup.ts'], }; export default config; diff --git a/package-lock.json b/package-lock.json index 10bb8ea..f74e459 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "drizzle-kit": "^0.30.4", + "eslint": "^8.57.1", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "ioredis-mock": "^8.9.0", @@ -842,7 +843,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==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -855,7 +856,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -1731,7 +1732,6 @@ "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -1756,7 +1756,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1774,7 +1773,6 @@ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -1792,16 +1790,14 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@eslint/js": { "version": "8.57.1", @@ -1809,7 +1805,6 @@ "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -1821,7 +1816,6 @@ "deprecated": "Use @eslint/config-array instead", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", @@ -1837,7 +1831,6 @@ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -1855,8 +1848,7 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", @@ -1864,7 +1856,6 @@ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=12.22" }, @@ -1879,8 +1870,7 @@ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "deprecated": "Use @eslint/object-schema instead", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@ioredis/as-callback": { "version": "3.0.0", @@ -2402,7 +2392,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==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2423,7 +2413,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -3235,28 +3225,28 @@ "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "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==", - "devOptional": true, + "dev": true, "license": "MIT" }, "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==", - "devOptional": true, + "dev": true, "license": "MIT" }, "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==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/babel__core": { @@ -3969,8 +3959,7 @@ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", @@ -4170,7 +4159,7 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -4185,7 +4174,6 @@ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -4194,7 +4182,7 @@ "version": "8.3.5", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -4433,7 +4421,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -5567,7 +5555,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==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -5639,8 +5627,7 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/deepmerge": { "version": "4.3.1", @@ -5759,7 +5746,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -5794,7 +5781,6 @@ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "esutils": "^2.0.2" }, @@ -6277,7 +6263,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6405,7 +6390,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6423,7 +6407,6 @@ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -6442,7 +6425,6 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -6456,7 +6438,6 @@ "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -6474,7 +6455,6 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -6485,7 +6465,6 @@ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -6503,7 +6482,6 @@ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -6516,8 +6494,7 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/eslint/node_modules/locate-path": { "version": "6.0.0", @@ -6525,7 +6502,6 @@ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -6541,8 +6517,7 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/eslint/node_modules/p-locate": { "version": "5.0.0", @@ -6550,7 +6525,6 @@ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -6567,7 +6541,6 @@ "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", @@ -6600,7 +6573,6 @@ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -6614,7 +6586,6 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -6658,7 +6629,6 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6897,8 +6867,7 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-safe-stringify": { "version": "2.1.1", @@ -6993,7 +6962,6 @@ "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "flat-cache": "^3.0.4" }, @@ -7070,7 +7038,6 @@ "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", @@ -7085,8 +7052,7 @@ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/follow-redirects": { "version": "1.16.0", @@ -7548,7 +7514,6 @@ "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "type-fest": "^0.20.2" }, @@ -8135,7 +8100,6 @@ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -9041,8 +9005,7 @@ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -9063,8 +9026,7 @@ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", @@ -9154,7 +9116,6 @@ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "json-buffer": "3.0.1" } @@ -9185,7 +9146,6 @@ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -9289,8 +9249,7 @@ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.once": { "version": "4.1.1", @@ -9366,7 +9325,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/makeerror": { @@ -9823,7 +9782,6 @@ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -10295,7 +10253,6 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8.0" } @@ -11882,8 +11839,7 @@ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/through": { "version": "2.3.8", @@ -12071,7 +12027,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -12170,7 +12126,6 @@ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -12426,7 +12381,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -12579,7 +12534,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==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { @@ -12796,7 +12751,6 @@ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12947,7 +12901,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" diff --git a/package.json b/package.json index bcd7de6..32e26d0 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "drizzle-kit": "^0.30.4", + "eslint": "^8.57.1", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "ioredis-mock": "^8.9.0", diff --git a/src/app.module.ts b/src/app.module.ts index 935d12c..bf76a69 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthModule } from './auth/auth.module'; import { MerchantsModule } from './merchants/merchants.module'; @@ -8,6 +8,8 @@ import { StellarModule } from './stellar/stellar.module'; import { WebhookModule } from './webhook/webhook.module'; import { MonitoringModule } from './monitoring/monitoring.module'; import { RedisModule } from './redis/redis.module'; +import { DynamicCorsMiddleware } from './middleware/dynamic-cors.middleware'; +import { SecurityHeadersMiddleware } from './middleware/security-headers.middleware'; @Module({ imports: [ @@ -27,4 +29,10 @@ import { RedisModule } from './redis/redis.module'; MonitoringModule, ], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer): void { + consumer.apply(SecurityHeadersMiddleware).forRoutes('*'); + + consumer.apply(DynamicCorsMiddleware).forRoutes('*'); + } +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 9781229..0186a0f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -34,6 +34,7 @@ export const merchants = pgTable('merchants', { webhookUrl: text('webhook_url'), webhookSecret: text('webhook_secret'), logoUrl: text('logo_url'), + corsOrigins: jsonb('cors_origins').default([]), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), }); diff --git a/src/main.ts b/src/main.ts index ae3dcb1..c30401a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,6 @@ import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); - app.enableCors(); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); const port = Number(process.env.PORT ?? 3001); diff --git a/src/merchants/merchants.controller.ts b/src/merchants/merchants.controller.ts index 05b6d2a..0d986e8 100644 --- a/src/merchants/merchants.controller.ts +++ b/src/merchants/merchants.controller.ts @@ -3,6 +3,7 @@ import { Post, Get, Patch, + Put, Delete, Body, Param, @@ -16,6 +17,7 @@ import { UpdateMerchantDto, SetWebhookDto, GenerateApiKeyDto, + SetCorsOriginsDto, } from './merchants.dto'; @Controller('merchants') @@ -77,4 +79,22 @@ export class MerchantsController { return this.merchants.setWebhook(m.id, dto.webhookUrl); }); } + + @UseGuards(AuthGuard('jwt')) + @Get('me/cors') + getCorsOrigins(@Request() req: any) { + return this.merchants.findByWallet(req.user.walletAddress).then((m) => { + if (!m) throw new Error('Merchant not found'); + return this.merchants.getCorsOrigins(m.id); + }); + } + + @UseGuards(AuthGuard('jwt')) + @Put('me/cors') + setCorsOrigins(@Request() req: any, @Body() dto: SetCorsOriginsDto) { + return this.merchants.findByWallet(req.user.walletAddress).then((m) => { + if (!m) throw new Error('Merchant not found'); + return this.merchants.setCorsOrigins(m.id, dto.origins); + }); + } } diff --git a/src/merchants/merchants.dto.ts b/src/merchants/merchants.dto.ts index 74dd8b5..f7d4145 100644 --- a/src/merchants/merchants.dto.ts +++ b/src/merchants/merchants.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsEmail, IsOptional, IsUrl } from 'class-validator'; +import { IsString, IsEmail, IsOptional, IsUrl, IsArray } from 'class-validator'; export class RegisterMerchantDto { @IsString() @@ -34,3 +34,9 @@ export class GenerateApiKeyDto { @IsString() environment: 'testnet' | 'mainnet'; } + +export class SetCorsOriginsDto { + @IsArray() + @IsString({ each: true }) + origins: string[]; +} diff --git a/src/merchants/merchants.module.ts b/src/merchants/merchants.module.ts index c9adde9..841a610 100644 --- a/src/merchants/merchants.module.ts +++ b/src/merchants/merchants.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { MerchantsController } from './merchants.controller'; import { MerchantsService } from './merchants.service'; +import { CorsOriginsCacheService } from '../middleware/cors-origins-cache.service'; @Module({ controllers: [MerchantsController], - providers: [MerchantsService], - exports: [MerchantsService], + providers: [MerchantsService, CorsOriginsCacheService], + exports: [MerchantsService, CorsOriginsCacheService], }) export class MerchantsModule {} diff --git a/src/merchants/merchants.service.ts b/src/merchants/merchants.service.ts index d4aa801..345feb3 100644 --- a/src/merchants/merchants.service.ts +++ b/src/merchants/merchants.service.ts @@ -3,9 +3,12 @@ import { db } from '../db/index'; import { merchants, apiKeys } from '../db/schema'; import { eq } from 'drizzle-orm'; import * as crypto from 'crypto'; +import { CorsOriginsCacheService } from '../middleware/cors-origins-cache.service'; @Injectable() export class MerchantsService { + constructor(private readonly corsCache: CorsOriginsCacheService) {} + async register(walletAddress: string, businessName: string, email: string) { const existing = await db.query.merchants.findFirst({ where: eq(merchants.walletAddress, walletAddress), @@ -91,4 +94,20 @@ export class MerchantsService { if (!key || !key.isActive) return null; return key.merchantId; } + + async getCorsOrigins(merchantId: string): Promise { + const merchant = await this.findById(merchantId); + return (merchant.corsOrigins ?? []) as string[]; + } + + async setCorsOrigins(merchantId: string, origins: string[]): Promise { + const [updated] = await db + .update(merchants) + .set({ corsOrigins: origins } as any) + .where(eq(merchants.id, merchantId)) + .returning(); + if (!updated) throw new NotFoundException('Merchant not found'); + await this.corsCache.invalidateMerchantCache(merchantId); + return (updated.corsOrigins ?? []) as string[]; + } } diff --git a/src/middleware/cors-origins-cache.service.spec.ts b/src/middleware/cors-origins-cache.service.spec.ts new file mode 100644 index 0000000..9f23c0b --- /dev/null +++ b/src/middleware/cors-origins-cache.service.spec.ts @@ -0,0 +1,63 @@ +jest.mock('../db/index', () => ({ + db: { + query: { + merchants: { + findMany: jest.fn().mockResolvedValue([]), + findFirst: jest.fn().mockResolvedValue({ corsOrigins: [] }), + }, + }, + insert: jest.fn(), + update: jest.fn(), + }, + client: {}, + schema: {}, +})); + +import { Test, TestingModule } from '@nestjs/testing'; +import { CorsOriginsCacheService } from './cors-origins-cache.service'; +import { RedisService } from '../redis/redis.service'; + +describe('CorsOriginsCacheService', () => { + let service: CorsOriginsCacheService; + let redis: { getClient: jest.Mock }; + + beforeEach(async () => { + redis = { + getClient: jest.fn().mockReturnValue({ + pipeline: jest.fn().mockReturnValue({ + set: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue([]), + }), + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue('OK'), + del: jest.fn().mockResolvedValue(1), + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [CorsOriginsCacheService, { provide: RedisService, useValue: redis }], + }).compile(); + + service = module.get(CorsOriginsCacheService); + }); + + afterEach(() => { + service.onModuleDestroy(); + }); + + it('returns empty array when no origins cached and no merchant found', async () => { + const origins = await service.getMerchantOrigins('nonexistent-id'); + expect(origins).toEqual([]); + }); + + it('caches and returns origins from Redis', async () => { + redis.getClient().get = jest.fn().mockResolvedValue(JSON.stringify(['https://shop.com'])); + const origins = await service.getMerchantOrigins('merchant-1'); + expect(origins).toEqual(['https://shop.com']); + }); + + it('returns empty array for all merchant origins when none cached', async () => { + const origins = await service.getAllMerchantOrigins(); + expect(origins).toEqual([]); + }); +}); diff --git a/src/middleware/cors-origins-cache.service.ts b/src/middleware/cors-origins-cache.service.ts new file mode 100644 index 0000000..4597f3f --- /dev/null +++ b/src/middleware/cors-origins-cache.service.ts @@ -0,0 +1,109 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { RedisService } from '../redis/redis.service'; +import { db } from '../db/index'; +import { merchants } from '../db/schema'; +import { eq } from 'drizzle-orm'; + +const CORS_CACHE_KEY = 'orbitstream:cors_origins'; +const CACHE_TTL_MS = 5 * 60 * 1000; +const ALL_MERCHANT_ORIGINS_KEY = 'orbitstream:cors_origins:all'; + +@Injectable() +export class CorsOriginsCacheService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(CorsOriginsCacheService.name); + private intervalId: ReturnType | null = null; + + constructor(private readonly redis: RedisService) {} + + async onModuleInit(): Promise { + await this.refreshCache(); + this.intervalId = setInterval(() => this.refreshCache(), CACHE_TTL_MS); + this.logger.log('CORS origins cache scheduled every 5 minutes'); + } + + onModuleDestroy(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + } + } + + async refreshCache(): Promise { + try { + const allMerchants = await db.query.merchants.findMany({ + columns: { id: true, corsOrigins: true }, + }); + + const pipe = this.redis.getClient().pipeline(); + + for (const m of allMerchants) { + const origins: string[] = (m.corsOrigins ?? []) as string[]; + pipe.set(`${CORS_CACHE_KEY}:${m.id}`, JSON.stringify(origins), 'PX', CACHE_TTL_MS); + } + + const allOrigins = new Set(); + for (const m of allMerchants) { + const origins: string[] = (m.corsOrigins ?? []) as string[]; + for (const o of origins) allOrigins.add(o); + } + pipe.set(ALL_MERCHANT_ORIGINS_KEY, JSON.stringify([...allOrigins]), 'PX', CACHE_TTL_MS); + + await pipe.exec(); + } catch (err) { + this.logger.error('Failed to refresh CORS origins cache', err); + } + } + + async getMerchantOrigins(merchantId: string): Promise { + try { + const raw = await this.redis.getClient().get(`${CORS_CACHE_KEY}:${merchantId}`); + if (raw) return JSON.parse(raw) as string[]; + } catch {} + + const merchant = await db.query.merchants.findFirst({ + where: eq(merchants.id, merchantId), + columns: { corsOrigins: true }, + }); + const origins: string[] = (merchant?.corsOrigins ?? []) as string[]; + await this.redis + .getClient() + .set(`${CORS_CACHE_KEY}:${merchantId}`, JSON.stringify(origins), 'PX', CACHE_TTL_MS); + return origins; + } + + async getAllMerchantOrigins(): Promise { + try { + const raw = await this.redis.getClient().get(ALL_MERCHANT_ORIGINS_KEY); + if (raw) return JSON.parse(raw) as string[]; + } catch {} + + const allMerchants = await db.query.merchants.findMany({ + columns: { corsOrigins: true }, + }); + const origins = new Set(); + for (const m of allMerchants) { + const o: string[] = (m.corsOrigins ?? []) as string[]; + for (const origin of o) origins.add(origin); + } + const result = [...origins]; + await this.redis + .getClient() + .set(ALL_MERCHANT_ORIGINS_KEY, JSON.stringify(result), 'PX', CACHE_TTL_MS); + return result; + } + + async invalidateMerchantCache(merchantId: string): Promise { + await this.redis.getClient().del(`${CORS_CACHE_KEY}:${merchantId}`); + const merchant = await db.query.merchants.findFirst({ + where: eq(merchants.id, merchantId), + columns: { corsOrigins: true }, + }); + const origins = (merchant?.corsOrigins ?? []) as string[]; + await this.redis + .getClient() + .set(`${CORS_CACHE_KEY}:${merchantId}`, JSON.stringify(origins), 'PX', CACHE_TTL_MS); + } + + invalidateAllCache(): void { + this.refreshCache(); + } +} diff --git a/src/middleware/dynamic-cors.middleware.spec.ts b/src/middleware/dynamic-cors.middleware.spec.ts new file mode 100644 index 0000000..5ebf140 --- /dev/null +++ b/src/middleware/dynamic-cors.middleware.spec.ts @@ -0,0 +1,162 @@ +import { DynamicCorsMiddleware } from './dynamic-cors.middleware'; +import { CorsOriginsCacheService } from './cors-origins-cache.service'; + +function mockReqRes(path: string, method: string, origin?: string) { + const headers: Record = {}; + if (origin) headers['origin'] = origin; + const req = { path, method, headers } as any; + const res = { + _headers: {} as Record, + _status: 200, + _body: null, + setHeader(k: string, v: string) { + this._headers[k] = v; + }, + status(s: number) { + this._status = s; + return this; + }, + json(b: any) { + this._body = b; + return this; + }, + end() { + return this; + }, + } as any; + const next = jest.fn(); + return { req, res, next }; +} + +describe('DynamicCorsMiddleware - route grouping', () => { + let middleware: DynamicCorsMiddleware; + let cache: jest.Mocked; + + beforeEach(() => { + process.env.PLATFORM_DOMAIN = 'http://localhost:3001'; + cache = { + getAllMerchantOrigins: jest.fn().mockResolvedValue(['https://myshop.com']), + getMerchantOrigins: jest.fn().mockResolvedValue([] as string[]), + invalidateMerchantCache: jest.fn(), + refreshCache: jest.fn(), + invalidateAllCache: jest.fn(), + } as any; + middleware = new DynamicCorsMiddleware(cache); + }); + + it('allows all origins on public GET /v1/checkout/sessions/:id', async () => { + const { req, res, next } = mockReqRes( + '/v1/checkout/sessions/abc-123', + 'GET', + 'https://evil.com', + ); + await middleware.use(req, res, next); + expect(res._headers['Access-Control-Allow-Origin']).toBe('https://evil.com'); + expect(next).toHaveBeenCalled(); + }); + + it('allows all origins on POST /merchants/register', async () => { + const { req, res, next } = mockReqRes('/merchants/register', 'POST', 'https://evil.com'); + await middleware.use(req, res, next); + expect(res._headers['Access-Control-Allow-Origin']).toBe('https://evil.com'); + expect(next).toHaveBeenCalled(); + }); + + it('allows all origins on /health', async () => { + const { req, res, next } = mockReqRes('/health', 'GET', 'https://evil.com'); + await middleware.use(req, res, next); + expect(res._headers['Access-Control-Allow-Origin']).toBe('https://evil.com'); + expect(next).toHaveBeenCalled(); + }); + + it('blocks unknown origins on merchant API routes', async () => { + cache.getAllMerchantOrigins.mockResolvedValue(['https://myshop.com']); + const { req, res, next } = mockReqRes('/v1/checkout/sessions', 'POST', 'https://evil.com'); + await middleware.use(req, res, next); + expect(res._status).toBe(403); + expect(res._body).toEqual({ error: 'CORS_ORIGIN_DENIED', message: 'Origin not allowed' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('allows configured merchant origins on merchant API routes', async () => { + cache.getAllMerchantOrigins.mockResolvedValue(['https://myshop.com']); + const { req, res, next } = mockReqRes('/v1/checkout/sessions', 'POST', 'https://myshop.com'); + await middleware.use(req, res, next); + expect(res._headers['Access-Control-Allow-Origin']).toBe('https://myshop.com'); + expect(next).toHaveBeenCalled(); + }); + + it('allows platform domain on merchant API routes', async () => { + cache.getAllMerchantOrigins.mockResolvedValue([]); + const { req, res, next } = mockReqRes('/v1/checkout/sessions', 'POST', 'http://localhost:3001'); + await middleware.use(req, res, next); + expect(res._headers['Access-Control-Allow-Origin']).toBe('http://localhost:3001'); + expect(next).toHaveBeenCalled(); + }); + + it('blocks unknown origins on dashboard routes', async () => { + const { req, res, next } = mockReqRes('/merchants/me', 'GET', 'https://evil.com'); + await middleware.use(req, res, next); + expect(res._status).toBe(403); + expect(res._body).toEqual({ error: 'CORS_ORIGIN_DENIED', message: 'Origin not allowed' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('allows platform domain on dashboard routes', async () => { + const { req, res, next } = mockReqRes('/merchants/me', 'GET', 'http://localhost:3001'); + await middleware.use(req, res, next); + expect(res._headers['Access-Control-Allow-Origin']).toBe('http://localhost:3001'); + expect(next).toHaveBeenCalled(); + }); + + it('allows platform domain on /auth/ routes', async () => { + const { req, res, next } = mockReqRes('/auth/login', 'POST', 'http://localhost:3001'); + await middleware.use(req, res, next); + expect(res._headers['Access-Control-Allow-Origin']).toBe('http://localhost:3001'); + expect(next).toHaveBeenCalled(); + }); + + it('blocks unknown origin on /auth/ routes', async () => { + const { req, res, next } = mockReqRes('/auth/login', 'POST', 'https://evil.com'); + await middleware.use(req, res, next); + expect(res._status).toBe(403); + expect(next).not.toHaveBeenCalled(); + }); + + it('handles OPTIONS preflight on allowed merchant route', async () => { + cache.getAllMerchantOrigins.mockResolvedValue(['https://myshop.com']); + const { req, res, next } = mockReqRes('/v1/checkout/sessions', 'OPTIONS', 'https://myshop.com'); + await middleware.use(req, res, next); + expect(res._headers['Access-Control-Allow-Origin']).toBe('https://myshop.com'); + expect(res._headers['Access-Control-Max-Age']).toBe('86400'); + expect(res._status).toBe(204); + expect(next).not.toHaveBeenCalled(); + }); + + it('blocks OPTIONS preflight on disallowed merchant route', async () => { + cache.getAllMerchantOrigins.mockResolvedValue(['https://myshop.com']); + const { req, res, next } = mockReqRes('/v1/checkout/sessions', 'OPTIONS', 'https://evil.com'); + await middleware.use(req, res, next); + expect(res._status).toBe(403); + expect(next).not.toHaveBeenCalled(); + }); + + it('does not block requests without origin header', async () => { + const { req, res, next } = mockReqRes('/v1/checkout/sessions', 'POST'); + await middleware.use(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + it('sets correct CORS headers on successful responses', async () => { + const { req, res, next } = mockReqRes('/v1/checkout/sessions', 'POST', 'https://myshop.com'); + cache.getAllMerchantOrigins.mockResolvedValue(['https://myshop.com']); + await middleware.use(req, res, next); + expect(res._headers['Access-Control-Allow-Methods']).toBe( + 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', + ); + expect(res._headers['Access-Control-Allow-Headers']).toBe( + 'Content-Type,Authorization,X-Requested-With', + ); + expect(res._headers['Access-Control-Allow-Credentials']).toBe('true'); + }); +}); diff --git a/src/middleware/dynamic-cors.middleware.ts b/src/middleware/dynamic-cors.middleware.ts new file mode 100644 index 0000000..5fa2511 --- /dev/null +++ b/src/middleware/dynamic-cors.middleware.ts @@ -0,0 +1,106 @@ +import { Injectable, NestMiddleware, Logger } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { CorsOriginsCacheService } from './cors-origins-cache.service'; + +type RouteGroup = 'public' | 'merchant_api' | 'dashboard'; + +function getRouteGroup(path: string, method: string): RouteGroup { + if (path.startsWith('/health') || path.startsWith('/metrics')) return 'public'; + + if (method === 'GET' && /^\/v1\/checkout\/sessions\/[^\/]+$/.test(path)) return 'public'; + if (method === 'POST' && path === '/merchants/register') return 'public'; + + if (path.startsWith('/v1/checkout/')) return 'merchant_api'; + if (path.startsWith('/v1/webhooks/')) return 'merchant_api'; + + if (path.startsWith('/merchants/')) return 'dashboard'; + if (path.startsWith('/auth/')) return 'dashboard'; + + return 'public'; +} + +function originMatches(origin: string, allowed: string[]): boolean { + return allowed.some( + (a) => a === origin || a === '*' || (a.endsWith('/*') && origin.startsWith(a.slice(0, -1))), + ); +} + +@Injectable() +export class DynamicCorsMiddleware implements NestMiddleware { + private readonly logger = new Logger(DynamicCorsMiddleware.name); + private readonly platformOrigin: string; + + constructor(private readonly corsCache: CorsOriginsCacheService) { + const domain = process.env.PLATFORM_DOMAIN ?? 'http://localhost:3001'; + this.platformOrigin = new URL(domain).origin; + } + + async use(req: Request, res: Response, next: NextFunction): Promise { + const origin = req.headers['origin'] as string | undefined; + const method = req.method; + const path = req.path; + const group = getRouteGroup(path, method); + + let allowedOrigin: string | null = null; + + switch (group) { + case 'public': { + if (origin) { + allowedOrigin = origin; + } + break; + } + case 'merchant_api': { + if (!origin) { + allowedOrigin = this.platformOrigin; + break; + } + const allowed = await this.getAllowedMerchantOrigins(); + if (originMatches(origin, [...allowed, this.platformOrigin])) { + allowedOrigin = origin; + } + break; + } + case 'dashboard': { + if (!origin) { + allowedOrigin = this.platformOrigin; + break; + } + if (origin === this.platformOrigin) { + allowedOrigin = origin; + } + break; + } + } + + if (allowedOrigin) { + res.setHeader('Access-Control-Allow-Origin', allowedOrigin); + res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,X-Requested-With'); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + + if (method === 'OPTIONS') { + res.setHeader('Access-Control-Max-Age', '86400'); + res.status(204).end(); + return; + } + } else if (origin) { + this.logger.warn(`Blocked CORS request from origin=${origin} to path=${path}`); + res.status(403).json({ + error: 'CORS_ORIGIN_DENIED', + message: 'Origin not allowed', + }); + return; + } + + next(); + } + + private async getAllowedMerchantOrigins(): Promise { + try { + return await this.corsCache.getAllMerchantOrigins(); + } catch { + return []; + } + } +} diff --git a/src/middleware/security-headers.middleware.spec.ts b/src/middleware/security-headers.middleware.spec.ts new file mode 100644 index 0000000..c020568 --- /dev/null +++ b/src/middleware/security-headers.middleware.spec.ts @@ -0,0 +1,64 @@ +import { SecurityHeadersMiddleware } from './security-headers.middleware'; + +function mockReqRes() { + const headers: Record = {}; + const req = { headers: {} } as any; + const res = { + _headers: headers, + setHeader(k: string, v: string) { + headers[k] = v; + }, + } as any; + const next = jest.fn(); + return { req, res, next }; +} + +describe('SecurityHeadersMiddleware', () => { + let middleware: SecurityHeadersMiddleware; + + beforeEach(() => { + middleware = new SecurityHeadersMiddleware(); + }); + + it('sets Strict-Transport-Security header', () => { + const { req, res, next } = mockReqRes(); + middleware.use(req, res, next); + expect(res._headers['Strict-Transport-Security']).toBe('max-age=31536000; includeSubDomains'); + }); + + it('sets X-Content-Type-Options header', () => { + const { req, res, next } = mockReqRes(); + middleware.use(req, res, next); + expect(res._headers['X-Content-Type-Options']).toBe('nosniff'); + }); + + it('sets X-Frame-Options header', () => { + const { req, res, next } = mockReqRes(); + middleware.use(req, res, next); + expect(res._headers['X-Frame-Options']).toBe('DENY'); + }); + + it('sets X-XSS-Protection header', () => { + const { req, res, next } = mockReqRes(); + middleware.use(req, res, next); + expect(res._headers['X-XSS-Protection']).toBe('1; mode=block'); + }); + + it('sets Referrer-Policy header', () => { + const { req, res, next } = mockReqRes(); + middleware.use(req, res, next); + expect(res._headers['Referrer-Policy']).toBe('strict-origin-when-cross-origin'); + }); + + it('sets Content-Security-Policy header', () => { + const { req, res, next } = mockReqRes(); + middleware.use(req, res, next); + expect(res._headers['Content-Security-Policy']).toBeDefined(); + }); + + it('calls next()', () => { + const { req, res, next } = mockReqRes(); + middleware.use(req, res, next); + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/middleware/security-headers.middleware.ts b/src/middleware/security-headers.middleware.ts new file mode 100644 index 0000000..959b7eb --- /dev/null +++ b/src/middleware/security-headers.middleware.ts @@ -0,0 +1,19 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; + +const CSP_VALUE = + process.env.CONTENT_SECURITY_POLICY ?? + "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'"; + +@Injectable() +export class SecurityHeadersMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: NextFunction): void { + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('X-XSS-Protection', '1; mode=block'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + res.setHeader('Content-Security-Policy', CSP_VALUE); + next(); + } +}