feat: add authorization
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
DATABASE_URL="file:./prisma/dev.db"
|
DATABASE_URL="file:./prisma/dev.db"
|
||||||
FRONTEND_ORIGIN="http://localhost:5173"
|
FRONTEND_ORIGIN="http://localhost:5173"
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
JWT_SECRET="replace-with-a-secret-at-least-32-characters-long"
|
||||||
|
|||||||
Generated
+303
-2
@@ -12,13 +12,19 @@
|
|||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/swagger": "11.4.5",
|
"@nestjs/swagger": "11.4.5",
|
||||||
"@prisma/adapter-better-sqlite3": "^7.9.1",
|
"@prisma/adapter-better-sqlite3": "^7.9.1",
|
||||||
"@prisma/client": "^7.9.1",
|
"@prisma/client": "^7.9.1",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^12.6.0",
|
"better-sqlite3": "^12.6.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
@@ -28,9 +34,12 @@
|
|||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.0",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
@@ -2343,6 +2352,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/jwt": {
|
||||||
|
"version": "11.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz",
|
||||||
|
"integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/jsonwebtoken": "9.0.10",
|
||||||
|
"jsonwebtoken": "9.0.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/mapped-types": {
|
"node_modules/@nestjs/mapped-types": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz",
|
||||||
@@ -2363,6 +2385,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/passport": {
|
||||||
|
"version": "11.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz",
|
||||||
|
"integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||||
|
"passport": "^0.5.0 || ^0.6.0 || ^0.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/platform-express": {
|
"node_modules/@nestjs/platform-express": {
|
||||||
"version": "11.1.29",
|
"version": "11.1.29",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.29.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.29.tgz",
|
||||||
@@ -3141,6 +3173,16 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bcrypt": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -3162,6 +3204,16 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/cookie-parser": {
|
||||||
|
"version": "1.4.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
||||||
|
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/express": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/cookiejar": {
|
"node_modules/@types/cookiejar": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||||
@@ -3371,6 +3423,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/jsonwebtoken": {
|
||||||
|
"version": "9.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||||
|
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/lodash": {
|
"node_modules/@types/lodash": {
|
||||||
"version": "4.17.25",
|
"version": "4.17.25",
|
||||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz",
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz",
|
||||||
@@ -3385,16 +3447,53 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.20.1",
|
"version": "22.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/passport": {
|
||||||
|
"version": "1.0.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
|
||||||
|
"integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/express": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/passport-jwt": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/jsonwebtoken": "*",
|
||||||
|
"@types/passport-strategy": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/passport-strategy": {
|
||||||
|
"version": "0.2.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
|
||||||
|
"integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/express": "*",
|
||||||
|
"@types/passport": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.15.1",
|
"version": "6.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||||
@@ -4532,6 +4631,20 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcrypt": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-addon-api": "^8.3.0",
|
||||||
|
"node-gyp-build": "^4.8.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/better-result": {
|
"node_modules/better-result": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz",
|
||||||
@@ -4702,6 +4815,12 @@
|
|||||||
"ieee754": "^1.1.13"
|
"ieee754": "^1.1.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/buffer-from": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
@@ -5217,6 +5336,25 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie-parser": {
|
||||||
|
"version": "1.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||||
|
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "0.7.2",
|
||||||
|
"cookie-signature": "1.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cookie-signature": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||||
@@ -5693,6 +5831,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ee-first": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -8222,6 +8369,49 @@
|
|||||||
"graceful-fs": "^4.1.6"
|
"graceful-fs": "^4.1.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
|
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^4.0.1",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -8324,6 +8514,42 @@
|
|||||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.memoize": {
|
"node_modules/lodash.memoize": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||||
@@ -8338,6 +8564,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/log-symbols": {
|
"node_modules/log-symbols": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
|
||||||
@@ -8944,6 +9176,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "8.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz",
|
||||||
|
"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || ^20 || >= 21"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-emoji": {
|
"node_modules/node-emoji": {
|
||||||
"version": "1.11.0",
|
"version": "1.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
|
||||||
@@ -8954,6 +9195,17 @@
|
|||||||
"lodash": "^4.17.21"
|
"lodash": "^4.17.21"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-gyp-build": {
|
||||||
|
"version": "4.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||||
|
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build": "bin.js",
|
||||||
|
"node-gyp-build-optional": "optional.js",
|
||||||
|
"node-gyp-build-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-int64": {
|
"node_modules/node-int64": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||||
@@ -9191,6 +9443,42 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/passport": {
|
||||||
|
"version": "0.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
|
||||||
|
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"passport-strategy": "1.x.x",
|
||||||
|
"pause": "0.0.1",
|
||||||
|
"utils-merge": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jaredhanson"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/passport-jwt": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jsonwebtoken": "^9.0.0",
|
||||||
|
"passport-strategy": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/passport-strategy": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -9275,6 +9563,11 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pause": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
||||||
|
},
|
||||||
"node_modules/perfect-debounce": {
|
"node_modules/perfect-debounce": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
||||||
@@ -11205,7 +11498,6 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/universalify": {
|
"node_modules/universalify": {
|
||||||
@@ -11312,6 +11604,15 @@
|
|||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/utils-merge": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/v8-compile-cache-lib": {
|
"node_modules/v8-compile-cache-lib": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||||
|
|||||||
+10
-1
@@ -17,19 +17,25 @@
|
|||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json"
|
"test:e2e": "DATABASE_URL=file:/tmp/wealth-ledger-e2e.db prisma migrate deploy && NODE_ENV=development NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/swagger": "11.4.5",
|
"@nestjs/swagger": "11.4.5",
|
||||||
"@prisma/adapter-better-sqlite3": "^7.9.1",
|
"@prisma/adapter-better-sqlite3": "^7.9.1",
|
||||||
"@prisma/client": "^7.9.1",
|
"@prisma/client": "^7.9.1",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^12.6.0",
|
"better-sqlite3": "^12.6.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
@@ -39,9 +45,12 @@
|
|||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.0",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Session" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"refreshTokenHash" TEXT NOT NULL,
|
||||||
|
"expiresAt" DATETIME NOT NULL,
|
||||||
|
"revokedAt" DATETIME,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "sqlite"
|
||||||
@@ -7,3 +7,25 @@ generator client {
|
|||||||
datasource db {
|
datasource db {
|
||||||
provider = "sqlite"
|
provider = "sqlite"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
sessions Session[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id
|
||||||
|
userId String
|
||||||
|
refreshTokenHash String
|
||||||
|
expiresAt DateTime
|
||||||
|
revokedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import { INestApplication } from '@nestjs/common';
|
|
||||||
import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '@nestjs/swagger';
|
|
||||||
import { AppController } from './app.controller';
|
|
||||||
import { AppService } from './app.service';
|
|
||||||
|
|
||||||
describe('AppController', () => {
|
|
||||||
let appController: AppController;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const app: TestingModule = await Test.createTestingModule({
|
|
||||||
controllers: [AppController],
|
|
||||||
providers: [AppService],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
appController = app.get<AppController>(AppController);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('documents the safe internal error response', async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
controllers: [AppController],
|
|
||||||
providers: [AppService],
|
|
||||||
}).compile();
|
|
||||||
const app: INestApplication = module.createNestApplication();
|
|
||||||
app.setGlobalPrefix('api/v1');
|
|
||||||
|
|
||||||
const document: OpenAPIObject = SwaggerModule.createDocument(
|
|
||||||
app,
|
|
||||||
new DocumentBuilder().build(),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(document.paths['/api/v1']?.get?.responses['500']).toEqual({
|
|
||||||
description: 'Internal server error',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: { $ref: '#/components/schemas/SafeErrorResponseDto' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(document.paths['/api/v1']?.get?.responses['200']).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: { type: 'string', example: 'Hello World!' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('root', () => {
|
|
||||||
it('should return "Hello World!"', () => {
|
|
||||||
expect(appController.getHello()).toBe('Hello World!');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
|
||||||
import { ApiOkResponse } from '@nestjs/swagger';
|
|
||||||
import { ApiSafeErrorResponse } from './common/decorators/api-safe-error-response.decorator';
|
|
||||||
import { AppService } from './app.service';
|
|
||||||
|
|
||||||
@Controller()
|
|
||||||
export class AppController {
|
|
||||||
constructor(private readonly appService: AppService) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
@ApiOkResponse({
|
|
||||||
schema: { type: 'string', example: 'Hello World!' },
|
|
||||||
})
|
|
||||||
@ApiSafeErrorResponse()
|
|
||||||
getHello(): string {
|
|
||||||
return this.appService.getHello();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-4
@@ -1,7 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { AppController } from './app.controller';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { AppService } from './app.service';
|
|
||||||
import { validateEnv } from './config/env.validation';
|
import { validateEnv } from './config/env.validation';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
|
||||||
@@ -12,8 +11,7 @@ import { PrismaModule } from './prisma/prisma.module';
|
|||||||
validate: validateEnv,
|
validate: validateEnv,
|
||||||
}),
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
AuthModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
|
||||||
providers: [AppService],
|
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AppService {
|
|
||||||
getHello(): string {
|
|
||||||
return 'Hello World!';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UseGuards,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiNoContentResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiProperty,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { CredentialsDto } from './auth.dto';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { AccessPayload } from './auth.types';
|
||||||
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
|
||||||
|
const REFRESH_COOKIE = 'refresh_token';
|
||||||
|
const COOKIE_PATH = '/api/v1/auth';
|
||||||
|
|
||||||
|
class UserDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthenticationDto {
|
||||||
|
@ApiProperty()
|
||||||
|
accessToken: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: UserDto })
|
||||||
|
user: UserDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('auth')
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly auth: AuthService) {}
|
||||||
|
|
||||||
|
@Post('register')
|
||||||
|
@ApiOkResponse({ type: AuthenticationDto })
|
||||||
|
async register(
|
||||||
|
@Body() credentials: CredentialsDto,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
return this.respond(
|
||||||
|
response,
|
||||||
|
await this.auth.register(credentials.email, credentials.password),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('login')
|
||||||
|
@ApiOkResponse({ type: AuthenticationDto })
|
||||||
|
async login(
|
||||||
|
@Body() credentials: CredentialsDto,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
return this.respond(
|
||||||
|
response,
|
||||||
|
await this.auth.login(credentials.email, credentials.password),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
@ApiOkResponse({ type: AuthenticationDto })
|
||||||
|
async refresh(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const token = this.refreshToken(request);
|
||||||
|
if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||||
|
return this.respond(response, await this.auth.refresh(token));
|
||||||
|
} catch (error) {
|
||||||
|
this.clearCookie(response);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
@HttpCode(204)
|
||||||
|
@ApiNoContentResponse()
|
||||||
|
async logout(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.auth.logout(this.refreshToken(request));
|
||||||
|
this.clearCookie(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOkResponse({ type: UserDto })
|
||||||
|
async me(@Req() request: Request & { user: AccessPayload }) {
|
||||||
|
const user = await this.auth.userById(request.user.sub);
|
||||||
|
return this.auth.toUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private respond(
|
||||||
|
response: Response,
|
||||||
|
result: {
|
||||||
|
user: { id: string; email: string };
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
|
||||||
|
return { user: result.user, accessToken: result.accessToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearCookie(response: Response): void {
|
||||||
|
response.clearCookie(REFRESH_COOKIE, this.cookieOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshToken(request: Request): string | undefined {
|
||||||
|
const token: unknown = request.cookies?.[REFRESH_COOKIE];
|
||||||
|
return typeof token === 'string' ? token : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cookieOptions() {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
secure: process.env['NODE_ENV'] !== 'development',
|
||||||
|
path: COOKIE_PATH,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
Validate,
|
||||||
|
ValidatorConstraint,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: 'email' })
|
||||||
|
class EmailConstraint {
|
||||||
|
validate(value: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
value.length <= 254 &&
|
||||||
|
value.split('@').length === 2 &&
|
||||||
|
!value
|
||||||
|
.split('')
|
||||||
|
.some(
|
||||||
|
(character) =>
|
||||||
|
/\s/.test(character) ||
|
||||||
|
character.charCodeAt(0) < 32 ||
|
||||||
|
character.charCodeAt(0) === 127,
|
||||||
|
) &&
|
||||||
|
value.split('@').every(Boolean)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
defaultMessage(): string {
|
||||||
|
return 'must be a valid email address';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: 'passwordBytes' })
|
||||||
|
class PasswordBytesConstraint {
|
||||||
|
validate(value: unknown): boolean {
|
||||||
|
return typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 72;
|
||||||
|
}
|
||||||
|
defaultMessage(): string {
|
||||||
|
return 'must not exceed 72 UTF-8 bytes';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CredentialsDto {
|
||||||
|
@ApiProperty({ example: 'user@example.com' })
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: { value: unknown }) =>
|
||||||
|
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||||
|
)
|
||||||
|
@Validate(EmailConstraint)
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ minLength: 12, maxLength: 72, example: 'a secure password' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(12)
|
||||||
|
@Validate(PasswordBytesConstraint)
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { Environment } from '../config/env.validation';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PassportModule,
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService<Environment, true>) => ({
|
||||||
|
secret: config.getOrThrow('JWT_SECRET'),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, JwtStrategy, JwtAuthGuard],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { createHash, randomUUID } from 'crypto';
|
||||||
|
import { Session, User } from '../../generated/prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AuthUser, RefreshPayload } from './auth.types';
|
||||||
|
|
||||||
|
const ACCESS_TTL = '15m';
|
||||||
|
const REFRESH_TTL = '7d';
|
||||||
|
const DUMMY_PASSWORD_HASH =
|
||||||
|
'$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly jwt: JwtService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async register(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||||
|
const normalizedEmail = this.normalizeEmail(email);
|
||||||
|
const existing = await this.prisma.user.findUnique({
|
||||||
|
where: { email: normalizedEmail },
|
||||||
|
});
|
||||||
|
if (existing)
|
||||||
|
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
||||||
|
|
||||||
|
const user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: randomUUID(),
|
||||||
|
email: normalizedEmail,
|
||||||
|
passwordHash: await bcrypt.hash(password, 12),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.createSession(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { email: this.normalizeEmail(email) },
|
||||||
|
});
|
||||||
|
const valid = await bcrypt.compare(
|
||||||
|
password,
|
||||||
|
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
||||||
|
);
|
||||||
|
if (!user || !valid)
|
||||||
|
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' });
|
||||||
|
return this.createSession(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(
|
||||||
|
token: string,
|
||||||
|
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||||
|
let payload: RefreshPayload;
|
||||||
|
try {
|
||||||
|
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
||||||
|
algorithms: ['HS256'],
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||||
|
}
|
||||||
|
if (payload.type !== 'refresh')
|
||||||
|
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||||
|
const session = await this.prisma.session.findUnique({
|
||||||
|
where: { id: payload.sid },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!session ||
|
||||||
|
session.userId !== payload.sub ||
|
||||||
|
session.revokedAt ||
|
||||||
|
session.expiresAt <= new Date()
|
||||||
|
) {
|
||||||
|
if (session && !session.revokedAt)
|
||||||
|
await this.prisma.session.delete({ where: { id: session.id } });
|
||||||
|
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||||
|
}
|
||||||
|
if (session.refreshTokenHash !== this.hash(token)) {
|
||||||
|
await this.prisma.session.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||||
|
}
|
||||||
|
return this.rotateSession(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(token?: string): Promise<void> {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
||||||
|
algorithms: ['HS256'],
|
||||||
|
});
|
||||||
|
if (payload.type !== 'refresh') return;
|
||||||
|
const session = await this.prisma.session.findUnique({
|
||||||
|
where: { id: payload.sid },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
session &&
|
||||||
|
!session.revokedAt &&
|
||||||
|
session.refreshTokenHash === this.hash(token)
|
||||||
|
) {
|
||||||
|
await this.prisma.session.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Logout is intentionally idempotent.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toUser(user: User): AuthUser {
|
||||||
|
return { id: user.id, email: user.email };
|
||||||
|
}
|
||||||
|
|
||||||
|
async userById(id: string): Promise<User> {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!user) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createSession(
|
||||||
|
user: User,
|
||||||
|
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||||
|
const session = await this.prisma.session.create({
|
||||||
|
data: {
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: user.id,
|
||||||
|
refreshTokenHash: '',
|
||||||
|
expiresAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.rotateSession({ ...session, user });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async rotateSession(
|
||||||
|
session: Session & { user: User },
|
||||||
|
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||||
|
const refreshToken = await this.jwt.signAsync(
|
||||||
|
{
|
||||||
|
sub: session.userId,
|
||||||
|
sid: session.id,
|
||||||
|
type: 'refresh',
|
||||||
|
jti: randomUUID(),
|
||||||
|
},
|
||||||
|
{ expiresIn: REFRESH_TTL, algorithm: 'HS256' },
|
||||||
|
);
|
||||||
|
await this.prisma.session.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: {
|
||||||
|
refreshTokenHash: this.hash(refreshToken),
|
||||||
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
user: this.toUser(session.user),
|
||||||
|
accessToken: await this.jwt.signAsync(
|
||||||
|
{ sub: session.userId, sid: session.id, type: 'access' },
|
||||||
|
{ expiresIn: ACCESS_TTL, algorithm: 'HS256' },
|
||||||
|
),
|
||||||
|
refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeEmail(email: string): string {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hash(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessPayload {
|
||||||
|
sub: string;
|
||||||
|
sid: string;
|
||||||
|
type: 'access';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshPayload {
|
||||||
|
sub: string;
|
||||||
|
sid: string;
|
||||||
|
type: 'refresh';
|
||||||
|
jti: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { Environment } from '../config/env.validation';
|
||||||
|
import { AccessPayload } from './auth.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(config: ConfigService<Environment, true>) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: config.getOrThrow('JWT_SECRET'),
|
||||||
|
algorithms: ['HS256'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(payload: AccessPayload): AccessPayload {
|
||||||
|
if (payload.type !== 'access')
|
||||||
|
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,25 +4,22 @@ import { HttpExceptionFilter } from './http-exception.filter';
|
|||||||
describe('HttpExceptionFilter', () => {
|
describe('HttpExceptionFilter', () => {
|
||||||
it('does not expose internal error details', () => {
|
it('does not expose internal error details', () => {
|
||||||
const status = jest.fn().mockReturnThis();
|
const status = jest.fn().mockReturnThis();
|
||||||
|
const type = jest.fn().mockReturnThis();
|
||||||
const json = jest.fn();
|
const json = jest.fn();
|
||||||
const filter = new HttpExceptionFilter();
|
const filter = new HttpExceptionFilter();
|
||||||
const host = {
|
const host = {
|
||||||
switchToHttp: () => ({
|
switchToHttp: () => ({
|
||||||
getRequest: () => ({
|
getResponse: () => ({ status, type, json }),
|
||||||
path: '/api/v1/health',
|
|
||||||
url: '/api/v1/health?access_token=secret',
|
|
||||||
}),
|
|
||||||
getResponse: () => ({ status, json }),
|
|
||||||
}),
|
}),
|
||||||
} as unknown as ArgumentsHost;
|
} as unknown as ArgumentsHost;
|
||||||
|
|
||||||
filter.catch(new Error('database password: secret'), host);
|
filter.catch(new Error('database password: secret'), host);
|
||||||
|
|
||||||
expect(status).toHaveBeenCalledWith(500);
|
expect(status).toHaveBeenCalledWith(500);
|
||||||
|
expect(type).toHaveBeenCalledWith('application/problem+json');
|
||||||
expect(json).toHaveBeenCalledWith({
|
expect(json).toHaveBeenCalledWith({
|
||||||
statusCode: 500,
|
status: 500,
|
||||||
message: 'Internal server error',
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
path: '/api/v1/health',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,24 +4,65 @@ import {
|
|||||||
ExceptionFilter,
|
ExceptionFilter,
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
ValidationError,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Request, Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class HttpExceptionFilter implements ExceptionFilter {
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
catch(exception: unknown, host: ArgumentsHost): void {
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
const context = host.switchToHttp();
|
const context = host.switchToHttp();
|
||||||
const response = context.getResponse<Response>();
|
const response = context.getResponse<Response>();
|
||||||
const request = context.getRequest<Request>();
|
|
||||||
const status =
|
const status =
|
||||||
exception instanceof HttpException
|
exception instanceof HttpException
|
||||||
? exception.getStatus()
|
? exception.getStatus()
|
||||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
|
||||||
response.status(status).json({
|
const payload =
|
||||||
statusCode: status,
|
exception instanceof HttpException ? exception.getResponse() : undefined;
|
||||||
message: status === 500 ? 'Internal server error' : 'Request failed',
|
const isValidation = Array.isArray(
|
||||||
path: request.path,
|
(payload as { message?: unknown })?.message,
|
||||||
});
|
);
|
||||||
|
response
|
||||||
|
.status(status)
|
||||||
|
.type('application/problem+json')
|
||||||
|
.json({
|
||||||
|
status,
|
||||||
|
code: isValidation ? 'VALIDATION_ERROR' : this.codeFor(status, payload),
|
||||||
|
...(isValidation
|
||||||
|
? {
|
||||||
|
errors: this.validationErrors(
|
||||||
|
(payload as { message: ValidationError[] }).message,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private codeFor(status: number, payload: unknown): string {
|
||||||
|
if (typeof payload === 'object' && payload !== null && 'code' in payload) {
|
||||||
|
const code = (payload as { code?: unknown }).code;
|
||||||
|
if (typeof code === 'string') return code;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
400: 'BAD_REQUEST',
|
||||||
|
401: 'UNAUTHORIZED',
|
||||||
|
404: 'NOT_FOUND',
|
||||||
|
415: 'UNSUPPORTED_MEDIA_TYPE',
|
||||||
|
500: 'INTERNAL_SERVER_ERROR',
|
||||||
|
}[status] ?? 'INTERNAL_SERVER_ERROR'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private validationErrors(
|
||||||
|
errors: ValidationError[],
|
||||||
|
): { pointer: string; detail: string }[] {
|
||||||
|
return errors.flatMap((error) =>
|
||||||
|
Object.values(error.constraints ?? {}).map((detail) => ({
|
||||||
|
pointer: `#/${error.property}`,
|
||||||
|
detail,
|
||||||
|
})),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ describe('validateEnv', () => {
|
|||||||
validateEnv({
|
validateEnv({
|
||||||
DATABASE_URL: 'file:./data.db',
|
DATABASE_URL: 'file:./data.db',
|
||||||
FRONTEND_ORIGIN: 'http://localhost:5173/',
|
FRONTEND_ORIGIN: 'http://localhost:5173/',
|
||||||
|
JWT_SECRET: 'a-secret-that-is-at-least-thirty-two-characters',
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
DATABASE_URL: 'file:./data.db',
|
DATABASE_URL: 'file:./data.db',
|
||||||
FRONTEND_ORIGIN: 'http://localhost:5173',
|
FRONTEND_ORIGIN: 'http://localhost:5173',
|
||||||
PORT: 3000,
|
PORT: 3000,
|
||||||
|
JWT_SECRET: 'a-secret-that-is-at-least-thirty-two-characters',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ describe('validateEnv', () => {
|
|||||||
validateEnv({
|
validateEnv({
|
||||||
DATABASE_URL: 'file:./data.db',
|
DATABASE_URL: 'file:./data.db',
|
||||||
FRONTEND_ORIGIN: origin,
|
FRONTEND_ORIGIN: origin,
|
||||||
|
JWT_SECRET: 'a-secret-that-is-at-least-thirty-two-characters',
|
||||||
}),
|
}),
|
||||||
).toThrow('FRONTEND_ORIGIN must be a valid HTTP origin');
|
).toThrow('FRONTEND_ORIGIN must be a valid HTTP origin');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export interface Environment {
|
export interface Environment {
|
||||||
DATABASE_URL: string;
|
DATABASE_URL: string;
|
||||||
FRONTEND_ORIGIN: string;
|
FRONTEND_ORIGIN: string;
|
||||||
|
JWT_SECRET: string;
|
||||||
PORT: number;
|
PORT: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
|
|||||||
const databaseUrl = config['DATABASE_URL'];
|
const databaseUrl = config['DATABASE_URL'];
|
||||||
const frontendOrigin = config['FRONTEND_ORIGIN'];
|
const frontendOrigin = config['FRONTEND_ORIGIN'];
|
||||||
const port = config['PORT'] ?? 3000;
|
const port = config['PORT'] ?? 3000;
|
||||||
|
const jwtSecret = config['JWT_SECRET'];
|
||||||
|
|
||||||
if (typeof databaseUrl !== 'string' || !databaseUrl.startsWith('file:')) {
|
if (typeof databaseUrl !== 'string' || !databaseUrl.startsWith('file:')) {
|
||||||
throw new Error('DATABASE_URL must be a SQLite file URL');
|
throw new Error('DATABASE_URL must be a SQLite file URL');
|
||||||
@@ -38,9 +40,14 @@ export function validateEnv(config: Record<string, unknown>): Environment {
|
|||||||
throw new Error('PORT must be a valid port number');
|
throw new Error('PORT must be a valid port number');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof jwtSecret !== 'string' || jwtSecret.length < 32) {
|
||||||
|
throw new Error('JWT_SECRET must be at least 32 characters long');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
DATABASE_URL: databaseUrl,
|
DATABASE_URL: databaseUrl,
|
||||||
FRONTEND_ORIGIN: parsedOrigin.origin,
|
FRONTEND_ORIGIN: parsedOrigin.origin,
|
||||||
|
JWT_SECRET: jwtSecret,
|
||||||
PORT: parsedPort,
|
PORT: parsedPort,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -1,4 +1,6 @@
|
|||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
@@ -11,8 +13,10 @@ async function bootstrap() {
|
|||||||
const config = app.get<ConfigService<Environment, true>>(ConfigService);
|
const config = app.get<ConfigService<Environment, true>>(ConfigService);
|
||||||
const frontendOrigin = config.getOrThrow<string>('FRONTEND_ORIGIN');
|
const frontendOrigin = config.getOrThrow<string>('FRONTEND_ORIGIN');
|
||||||
|
|
||||||
app.enableCors({ origin: frontendOrigin });
|
app.enableCors({ origin: frontendOrigin, credentials: true });
|
||||||
app.setGlobalPrefix('api/v1');
|
app.setGlobalPrefix('api/v1');
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
|
|
||||||
if (shouldExposeSwagger(process.env['NODE_ENV'])) {
|
if (shouldExposeSwagger(process.env['NODE_ENV'])) {
|
||||||
|
|||||||
+66
-6
@@ -1,10 +1,16 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { App } from 'supertest/types';
|
import { App } from 'supertest/types';
|
||||||
import { AppModule } from './../src/app.module';
|
import { AppModule } from './../src/app.module';
|
||||||
|
import { HttpExceptionFilter } from './../src/common/filters/http-exception.filter';
|
||||||
|
import { PrismaService } from './../src/prisma/prisma.service';
|
||||||
|
|
||||||
describe('AppController (e2e)', () => {
|
type Authentication = { user: { email: string }; accessToken: string };
|
||||||
|
type Problem = { code: string };
|
||||||
|
|
||||||
|
describe('Authentication (e2e)', () => {
|
||||||
let app: INestApplication<App>;
|
let app: INestApplication<App>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -13,13 +19,67 @@ describe('AppController (e2e)', () => {
|
|||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({ transform: true, whitelist: true }),
|
||||||
|
);
|
||||||
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
await app.init();
|
await app.init();
|
||||||
|
await app.get(PrismaService).session.deleteMany();
|
||||||
|
await app.get(PrismaService).user.deleteMany();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/ (GET)', () => {
|
afterEach(() => app.close());
|
||||||
return request(app.getHttpServer())
|
|
||||||
.get('/')
|
it('registers, guards profile, rotates refresh and logs out', async () => {
|
||||||
|
const agent = request.agent(app.getHttpServer());
|
||||||
|
const registration = await agent
|
||||||
|
.post('/api/v1/auth/register')
|
||||||
|
.send({ email: ' User@Example.com ', password: 'a secure password' })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const authentication = registration.body as Authentication;
|
||||||
|
expect(authentication.user.email).toBe('user@example.com');
|
||||||
|
const accessToken = authentication.accessToken;
|
||||||
|
await request(app.getHttpServer()).get('/api/v1/auth/me').expect(401);
|
||||||
|
await agent
|
||||||
|
.get('/api/v1/auth/me')
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.expect('Hello World!');
|
.expect(({ body }: { body: unknown }) =>
|
||||||
|
expect((body as { email: string }).email).toBe('user@example.com'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstCookie = registration.headers['set-cookie'][0];
|
||||||
|
await agent.post('/api/v1/auth/refresh').expect(201);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/auth/refresh')
|
||||||
|
.set('Cookie', firstCookie)
|
||||||
|
.expect(401)
|
||||||
|
.expect('Content-Type', /application\/problem\+json/);
|
||||||
|
await agent.post('/api/v1/auth/logout').expect(204);
|
||||||
|
await agent.post('/api/v1/auth/logout').expect(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate registration and invalid credentials', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/auth/register')
|
||||||
|
.send({ email: 'user@example.com', password: 'a secure password' })
|
||||||
|
.expect(201);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/auth/register')
|
||||||
|
.send({ email: 'USER@example.com', password: 'a secure password' })
|
||||||
|
.expect(409)
|
||||||
|
.expect(({ body }: { body: unknown }) =>
|
||||||
|
expect((body as Problem).code).toBe('EMAIL_ALREADY_REGISTERED'),
|
||||||
|
);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ email: 'user@example.com', password: 'wrong password' })
|
||||||
|
.expect(401)
|
||||||
|
.expect(({ body }: { body: unknown }) =>
|
||||||
|
expect((body as Problem).code).toBe('INVALID_CREDENTIALS'),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
process.env.DATABASE_URL = 'file:/tmp/wealth-ledger-e2e.db';
|
process.env.DATABASE_URL = 'file:/tmp/wealth-ledger-e2e.db';
|
||||||
process.env.FRONTEND_ORIGIN = 'http://localhost:5173';
|
process.env.FRONTEND_ORIGIN = 'http://localhost:5173';
|
||||||
|
process.env.JWT_SECRET = 'e2e-secret-that-is-at-least-thirty-two-characters';
|
||||||
|
|||||||
Reference in New Issue
Block a user